diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 66a6a36baa..bb752267aa 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -428,7 +428,9 @@ pub fn export(doc: &Document, opts: &DxfOptions) -> Result { if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { return Err("the export region is not finite".into()); } - let baked = vectorcraft_effects::bake_document(doc); + // Symbols' art is placed without clipping, as the scene places it. + let baked = + vectorcraft_effects::bake_document_placing_symbols(doc, false, vectorcraft_effects::WrittenLayers { hidden: true, non_printing: true }); let doc = baked.as_ref().unwrap_or(doc); Ok(scene::Scene::new(doc, opts).run()) } diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index a14af8d929..84e547709d 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1410,6 +1410,29 @@ fn a_stained_instance_is_written_in_its_stained_color() { assert!(!colors.is_empty() && colors.iter().all(|c| c.as_deref() == Some("255")), "{colors:?}"); } +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + // A line and the symbol's line below it, each moved 100 pt right by a Transform effect, start + // at the same x. + let moved = |mut n: Node| { + n.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + n + }; + let mut d = doc_with(|d| { + let line = path(d, shapes::line((10.0, 10.0).into(), (20.0, 10.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0); + vec![moved(line), Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })] + }); + let art = path(&mut d, shapes::line((10.0, 40.0).into(), (20.0, 40.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) }); + let starts: Vec = entities(&export(&d, &opts(&d)).unwrap()).iter().map(|e| all(e, 10).into_iter().fold(f64::INFINITY, f64::min)).collect(); + assert_eq!(starts.len(), 2, "{starts:?}"); + assert!((starts[0] - starts[1]).abs() < 1e-6, "{starts:?}"); +} + #[test] fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() { // Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index f36993a18c..2c269766d5 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -627,9 +627,8 @@ fn reoriented(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wr out } -/// A key's stroke read along its subpath turned as `o` says: its width profile (whose sides swap -/// when reversed), pen pressure, tilt, bearing and rotation, and on a reversed open path its -/// arrowheads. +/// A stroke read along its subpath turned as `o` says: its width profile (whose sides swap when +/// reversed), pen pressure, tilt, bearing and rotation. Its arrowheads stay as they are. fn reorient_stroke(s: &mut StrokeLayer, o: Orient) { if o.is_identity() { return; @@ -669,15 +668,45 @@ fn reorient_stroke(s: &mut StrokeLayer, o: Orient) { let out = reoriented(&p.points, o, |f| p.unwrapped_at(f), |v| v + turns, same); p.points = out; } - if o.reversed && !o.closed { - std::mem::swap(&mut s.start_arrow, &mut s.end_arrow); - s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0); +} + +/// `n` with every stroke read along its subpaths turned as `o` says, and on a reversed open path +/// its arrowheads swapped. +fn reorient_strokes(n: &mut Node, o: Orient) { + if o.is_identity() { + return; + } + for it in n.appearance.items.iter_mut() { + if let AppearanceItem::Stroke(s) = it { + reorient_stroke(s, o); + if o.reversed && !o.closed { + std::mem::swap(&mut s.start_arrow, &mut s.end_arrow); + s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0); + } + } } } -/// `n` with every stroke read along its subpaths turned as `o` says. -fn reorient_strokes(n: &mut Node, o: Orient) { - if o.is_identity() { +/// Turn the strokes of path `n` for Reverse Path Direction, before its subpaths flagged in +/// `reversed` are reversed with [`SubPath::reverse`] (a closed one then starts at its last +/// anchor): each stroke's width profile (sides swapped), pen pressure, tilt, bearing and rotation +/// then run the new way, so the stroke draws as it did. A stroke reads one profile along every +/// subpath, so it turns only when all of them turn alike. Arrowheads stay at the path's start and +/// end. +pub fn reverse_stroke_records(n: &mut Node, reversed: &[bool]) { + let NodeKind::Path { path, .. } = &n.kind else { return }; + let mut turns = path.subpaths.iter().enumerate().map(|(i, sp)| { + let mut o = Orient::of(sp); + if reversed.get(i).copied().unwrap_or(false) { + if sp.closed { + o.start_at(sp, sp.anchors.len().saturating_sub(1)); + } + o.reverse(); + } + o + }); + let Some(o) = turns.next() else { return }; + if o.is_identity() || !turns.all(|t| t.same_turn(&o)) { return; } for it in n.appearance.items.iter_mut() { diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 63b7e0ce7c..c846dfc1c6 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -251,6 +251,45 @@ fn undrawable(heights: &Heights, used: Def, depth: usize) -> bool { heights.get(&used).is_some_and(|h| h.is_none_or(|h| depth + h > MAX_DRAWN_DEPTH)) } +/// How deep the art of a document's symbols and patterns draws, to measure objects that use them +/// outside the document's layers ([`Self::deepest`]). +#[derive(Clone, Debug, Default)] +pub struct DrawnHeights { + symbols: HashMap, + patterns: HashMap, +} + +impl DrawnHeights { + /// The heights of `doc`'s symbols and patterns, as [`Document::too_deep`] counts them: the art + /// is 1 deep, and what it uses is counted under it. A symbol or pattern that uses itself, or + /// draws deeper than [`MAX_DRAWN_DEPTH`], has none. + pub fn of(doc: &Document) -> Self { + let mut out = Self::default(); + for ((pattern, name), h) in heights(doc) { + if let Some(h) = h { + let map = if pattern { &mut out.patterns } else { &mut out.symbols }; + map.insert(name.to_string(), h); + } + } + out + } + + /// The deepest `n`, placed `depth` deep, draws: its own objects and the art of their opacity + /// masks, and under the objects the art of the symbols and patterns they use. A use of a + /// symbol or pattern without a height ([`Self::of`]) counts as the object alone. `n`'s own + /// nesting may pass [`MAX_DEPTH`]. Without recursion. + pub fn deepest(&self, n: &Node, depth: usize) -> usize { + let mut out = depth; + let mut stack = vec![(n, depth)]; + while let Some((n, d)) = stack.pop() { + let used = uses(n).filter_map(|(pattern, name)| if pattern { self.patterns.get(name) } else { self.symbols.get(name) }).max(); + out = out.max(d + used.copied().unwrap_or(0)); + stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| (&**c, d + 1))); + } + out + } +} + /// Take out of `nodes` (each `depth` deep) the uses `bad` picks (given the depth of the object): /// an instance goes, a pattern paint becomes no paint. Returns whether anything changed. For trees /// within [`MAX_DEPTH`]. @@ -623,4 +662,23 @@ mod tests { d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "none")])); assert!(!d.too_deep(None) && !d.limit_depth()); } + + #[test] + fn drawn_heights_measure_objects_outside_the_layers() { + // s0 holds s1 two groups deep, which holds s2, which holds an instance of nothing known: + // s2 draws 3 deep, s1 6 and s0 9. + let d = symbols(3, 2, "none"); + let heights = DrawnHeights::of(&d); + let on_layer = instance(1, "s0"); + assert_eq!(heights.deepest(&on_layer, 2), 2 + 9); + // An object's own nesting and its mask's art count past MAX_DEPTH. + let mut deep = Node::group(NodeId(5), vec![nested(MAX_DEPTH + 10)]); + deep.extras_mut().mask = Some(Box::new(OpacityMask::new((*instance(6, "s2")).clone(), true))); + assert_eq!(heights.deepest(&deep, 1), 1 + 1 + MAX_DEPTH + 10); + let shallow = Node::group(NodeId(5), vec![instance(7, "s1")]); + assert_eq!(heights.deepest(&shallow, 100), 101 + 6); + // A symbol that holds itself has no height, so its instance counts alone. + let looped = DrawnHeights::of(&symbols(2, 1, "s0")); + assert_eq!(looped.deepest(&instance(1, "s0"), 2), 2); + } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index cb78d96ba9..c286492236 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -61,6 +61,7 @@ pub mod range; pub mod rastersettings; mod reach; pub mod recolor; +pub mod resample; pub mod selection; pub mod separated; pub mod setup; diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index b2f46700eb..80740b7cc2 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -229,7 +229,8 @@ fn proxy_png(bytes: &[u8]) -> Option> { /// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG. fn proxy_of(img: &image::DynamicImage) -> Option> { - let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE); + let (w, h) = crate::resample::fit_size(img.width(), img.height(), PROXY_SIZE, PROXY_SIZE); + let small = crate::resample::averaged(img, w, h); let mut png = Vec::new(); small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; Some(png) @@ -732,4 +733,19 @@ mod tests { image(&mut d, "c", None); assert_eq!(d.linked_only_images().into_iter().collect::>(), ["a"]); } + + /// The preview of an image with clear pixels keeps the color of its opaque ones: the clear + /// black beside them does not darken them, whichever way the preview is made. + #[test] + fn a_preview_keeps_the_color_beside_clear_pixels() { + let mut full = vec![]; + image::RgbaImage::from_fn(1024, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + .write_to(&mut Cursor::new(&mut full), image::ImageFormat::Png) + .unwrap(); + for blob in [ImageBlob::png(full.clone()).with_proxy(), ImageBlob::png(full.clone()).checked_with_proxy(true).unwrap()] { + let p = image::load_from_memory(blob.proxy.as_ref().unwrap()).unwrap().into_rgba8(); + assert_eq!(p.dimensions(), (PROXY_SIZE, 1)); + assert!(p.pixels().all(|q| q.0 == [200, 30, 30, 128]), "{:?}", p.get_pixel(0, 0)); + } + } } diff --git a/crates/doc/src/resample.rs b/crates/doc/src/resample.rs new file mode 100644 index 0000000000..bc72cce1db --- /dev/null +++ b/crates/doc/src/resample.rs @@ -0,0 +1,259 @@ +//! Scaling images whose pixels carry straight (not premultiplied) alpha. +//! +//! `image`'s filtered resampling filters each channel on its own, which is correct only for +//! premultiplied pixels, and its thumbnail averages each channel on its own too, so on +//! straight-alpha pixels the color under clear pixels bleeds into the opaque ones next to them +//! (clear black darkens a cut-out's edge). [`resized`], [`fitted`] and [`averaged`] weight each +//! pixel's color by its alpha while scaling, as the streaming shrink in the renderer does. + +use image::imageops::FilterType; +use image::{DynamicImage, RgbaImage}; + +/// How [`resized`] makes each new pixel from the old ones. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Resample { + /// `image`'s filtered resampling with this filter. + Filter(FilterType), + /// The mean of the old pixels each new one covers ([`averaged`]). + Average, +} + +/// `img` scaled to `w` × `h` pixels as `how` says, each pixel's color weighted by its alpha. +/// Filtered or averaged, an image with clear or partly clear pixels comes back as 8-bit RGBA +/// whatever its own pixel format, and a pixel that ends up wholly clear is transparent black. An +/// image without clear pixels is scaled as `image` scales it, in its own pixel format. +/// Nearest-neighbor picking leaves the pixels as they are, in their own format, and is not +/// weighted. Filtered, the colors keep the precision of premultiplied 8-bit pixels, and an 8-bit +/// RGBA image is premultiplied in place. +pub fn resized(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage { + if (w, h) == (img.width(), img.height()) { + return img; + } + let f = match how { + Resample::Average => return averaged(&img, w, h), + Resample::Filter(f) => f, + }; + if f == FilterType::Nearest || opaque(&img) { + return img.resize_exact(w, h, f); + } + let mut rgba = img.into_rgba8(); + for p in rgba.pixels_mut() { + let [r, g, b, a] = &mut p.0; + for c in [r, g, b] { + *c = times_alpha(*c, *a); + } + } + let mut out = image::imageops::resize(&rgba, w, h, f); + for p in out.pixels_mut() { + let [r, g, b, a] = &mut p.0; + for c in [r, g, b] { + *c = over_alpha(*c, *a); + } + } + DynamicImage::ImageRgba8(out) +} + +/// [`resized`] to the largest size that fits `w` × `h` pixels and keeps `img`'s proportions, the +/// size `image`'s own `resize` and `thumbnail` pick. +pub fn fitted(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage { + let (nw, nh) = fit_size(img.width(), img.height(), w, h); + resized(img, nw, nh, how) +} + +/// `img` scaled to `w` × `h` pixels, each new pixel the mean of the old pixels it covers, each old +/// pixel counted by the area of it covered and its color weighted by its alpha. `img` is read one +/// row at a time, so besides the result this takes one row of 8-bit RGBA at most. An image +/// without clear pixels is scaled as `image`'s thumbnail scales it. +pub fn averaged(img: &DynamicImage, w: u32, h: u32) -> DynamicImage { + let (sw, sh) = (img.width(), img.height()); + if opaque(img) || w == 0 || h == 0 || sw == 0 || sh == 0 { + return img.thumbnail_exact(w, h); + } + // Per new pixel of the row being made: red, green and blue times alpha, alpha (all times the + // area covered), and the area covered. + let mut sums = vec![([0f64; 4], 0f64); w as usize]; + let mut out = Vec::with_capacity(w as usize * h as usize * 4); + for ty in 0..h { + for (y, wy) in covered(ty, sh, h) { + let row = rgba_row(img, y as u32); + for ((s, area), tx) in sums.iter_mut().zip(0..w) { + for (x, wx) in covered(tx, sw, w) { + let Some(p) = row.get(x * 4..x * 4 + 4) else { continue }; + let k = wx * wy; + let a = f64::from(p[3]) * k; + s[0] += f64::from(p[0]) * a; + s[1] += f64::from(p[1]) * a; + s[2] += f64::from(p[2]) * a; + s[3] += a; + *area += k; + } + } + } + let byte = |v: f64| v.round().clamp(0.0, 255.0) as u8; + for (s, area) in &mut sums { + out.extend(if s[3] <= 0.0 { [0; 4] } else { [byte(s[0] / s[3]), byte(s[1] / s[3]), byte(s[2] / s[3]), byte(s[3] / *area)] }); + (*s, *area) = ([0.0; 4], 0.0); + } + } + RgbaImage::from_raw(w, h, out).map_or_else(|| img.thumbnail_exact(w, h), DynamicImage::ImageRgba8) +} + +/// Row `y` of `img` as 8-bit RGBA: borrowed from an 8-bit RGBA image, converted from others. +fn rgba_row(img: &DynamicImage, y: u32) -> std::borrow::Cow<'_, [u8]> { + let n = img.width() as usize * 4; + match img { + DynamicImage::ImageRgba8(b) => std::borrow::Cow::Borrowed(b.as_raw().get(y as usize * n..(y as usize + 1) * n).unwrap_or_default()), + other => std::borrow::Cow::Owned(other.crop_imm(0, y, other.width(), 1).into_rgba8().into_raw()), + } +} + +/// The old pixels that new pixel `t` of `tn` covers along a side of `n` old pixels, each with the +/// length of it covered (in old pixels), worked out as they are read so that nothing is stored. +fn covered(t: u32, n: u32, tn: u32) -> impl Iterator { + let scale = f64::from(n) / f64::from(tn); + let (a, b) = (f64::from(t) * scale, (f64::from(t) + 1.0) * scale); + let (first, end) = (a.floor() as u32, (b.ceil() as u32).min(n)); + (first..end.max(first + 1).min(n)).filter_map(move |i| { + let len = b.min(f64::from(i) + 1.0) - a.max(f64::from(i)); + (len > 0.0).then_some((i as usize, len)) + }) +} + +/// Each 4-channel pixel of `px` with all four channels multiplied by its value in `alpha` (one +/// sample a pixel, 0 to 255): ink amounts or colors made ready for scaling beside a separate alpha +/// channel. Pixels past the end of `alpha` are left as they are. +pub fn premultiply(px: &mut [u8], alpha: &[u8]) { + for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) { + for c in p { + *c = times_alpha(*c, a); + } + } +} + +/// [`premultiply`] undone after scaling: each 4-channel pixel of `px` divided by its value in the +/// scaled `alpha`, and 0 where that is 0. +pub fn unpremultiply(px: &mut [u8], alpha: &[u8]) { + for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) { + for c in p { + *c = over_alpha(*c, a); + } + } +} + +/// Does `img` have no alpha channel, or is every pixel of it opaque? +fn opaque(img: &DynamicImage) -> bool { + match img { + DynamicImage::ImageRgba8(b) => b.pixels().all(|p| p[3] == u8::MAX), + DynamicImage::ImageLumaA8(b) => b.pixels().all(|p| p[1] == u8::MAX), + DynamicImage::ImageRgba16(b) => b.pixels().all(|p| p[3] == u16::MAX), + DynamicImage::ImageLumaA16(b) => b.pixels().all(|p| p[1] == u16::MAX), + DynamicImage::ImageRgba32F(b) => b.pixels().all(|p| p[3] >= 1.0), + other => !other.color().has_alpha(), + } +} + +/// Channel `c` times alpha `a`, both 0 to 255. +fn times_alpha(c: u8, a: u8) -> u8 { + ((u16::from(c) * u16::from(a) + 127) / 255) as u8 +} + +/// Premultiplied channel `c` divided by alpha `a` (0 when `a` is 0). +fn over_alpha(c: u8, a: u8) -> u8 { + if a == 0 { + return 0; + } + let a = u32::from(a); + ((u32::from(c) * 255 + a / 2) / a).min(255) as u8 +} + +/// The largest size of at most `nw` × `nh` with the proportions of `w` × `h`, at least a pixel a +/// side. +pub fn fit_size(w: u32, h: u32, nw: u32, nh: u32) -> (u32, u32) { + if w == 0 || h == 0 { + return (nw.max(1), nh.max(1)); + } + let ratio = (f64::from(nw) / f64::from(w)).min(f64::from(nh) / f64::from(h)); + let side = |n: u32| ((f64::from(n) * ratio).round() as u32).max(1); + (side(w), side(h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A 4 × 2 image whose left half is opaque red and whose right half is clear black. + fn half_clear() -> DynamicImage { + DynamicImage::ImageRgba8(RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) })) + } + + /// Is the color of `p`, premultiplied by its alpha, within a step of `rgb` premultiplied by the + /// same alpha (the precision of premultiplied 8-bit pixels)? + fn close(p: [u8; 4], rgb: [u8; 3]) -> bool { + p[..3].iter().zip(rgb).all(|(&c, want)| times_alpha(c, p[3]).abs_diff(times_alpha(want, p[3])) <= 1) + } + + #[test] + fn averaging_keeps_the_color_of_the_opaque_pixels() { + let p = resized(half_clear(), 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0; + assert!(close(p, [200, 30, 30]) && p[3].abs_diff(128) <= 1, "{p:?}"); + } + + /// Three pixels averaged into two: each new pixel takes one old pixel whole and half of the + /// clear one in the middle. + #[test] + fn averaging_counts_the_part_of_a_pixel_covered() { + let img = RgbaImage::from_fn(3, 1, |x, _| if x == 1 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([200, 30, 30, 255]) }); + let out = averaged(&DynamicImage::ImageRgba8(img), 2, 1).into_rgba8(); + assert_eq!(out.into_raw(), [[200, 30, 30, 170], [200, 30, 30, 170]].concat()); + // Gray with alpha is read the same way. + let gray = image::GrayAlphaImage::from_fn(3, 1, |x, _| image::LumaA(if x == 1 { [0, 0] } else { [200, 255] })); + let out = averaged(&DynamicImage::ImageLumaA8(gray), 2, 1).into_rgba8(); + assert_eq!(out.into_raw(), [[200, 200, 200, 170], [200, 200, 200, 170]].concat()); + } + + #[test] + fn filtering_keeps_the_color_of_the_opaque_pixels() { + for f in [FilterType::Triangle, FilterType::CatmullRom, FilterType::Lanczos3] { + let out = resized(half_clear(), 2, 1, Resample::Filter(f)).into_rgba8(); + for x in 0..2 { + let p = out.get_pixel(x, 0).0; + assert!(p[3] == 0 || close(p, [200, 30, 30]), "{f:?} pixel {x}: {p:?}"); + } + } + } + + #[test] + fn fitted_keeps_proportions() { + let img = DynamicImage::ImageRgba8(RgbaImage::new(400, 100)); + let out = fitted(img, 64, 64, Resample::Average); + assert_eq!((out.width(), out.height()), (64, 16)); + } + + #[test] + fn opaque_and_alpha_free_images_scale_as_image_scales_them() { + let rgb = DynamicImage::ImageRgb8(image::RgbImage::from_fn(5, 3, |x, y| image::Rgb([x as u8 * 40, y as u8 * 60, 7]))); + let ours = resized(rgb.clone(), 2, 2, Resample::Filter(FilterType::Triangle)); + assert_eq!(ours, rgb.resize_exact(2, 2, FilterType::Triangle)); + let opaque = DynamicImage::ImageRgba8(rgb.to_rgba8()); + assert_eq!(resized(opaque.clone(), 2, 2, Resample::Average), opaque.thumbnail_exact(2, 2)); + } + + #[test] + fn a_wholly_clear_result_is_transparent_black() { + let img = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 4, image::Rgba([90, 90, 90, 0]))); + assert_eq!(resized(img, 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0, [0, 0, 0, 0]); + } + + #[test] + fn premultiply_and_unpremultiply_scale_beside_a_separate_alpha() { + // Two pixels of inks, the second clear: averaged, the first one's inks remain. + let mut px = vec![40, 80, 120, 160, 0, 0, 0, 0]; + premultiply(&mut px, &[255, 0]); + let img = RgbaImage::from_raw(2, 1, px).unwrap(); + let mut out = image::imageops::thumbnail(&img, 1, 1).into_raw(); + let alpha = image::imageops::thumbnail(&image::GrayImage::from_raw(2, 1, vec![255, 0]).unwrap(), 1, 1).into_raw(); + unpremultiply(&mut out, &alpha); + let want = [40, 80, 120, 160]; + assert!(out.iter().zip(want).all(|(&c, w)| times_alpha(c, alpha[0]).abs_diff(times_alpha(w, alpha[0])) <= 1), "{out:?} {alpha:?}"); + } +} diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index f75490557d..d648dd9258 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -17,7 +17,8 @@ //! object of its own, a stroke a path stroked by it alone (brushed strokes their brush art, made by //! the caller). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; use std::sync::Arc; use vectorcraft_doc::color::Paint; @@ -26,7 +27,7 @@ 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, clipping_text_outline, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; +use crate::reshape::{as_art, clipping_text_outline, dressed, 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, reshape, @@ -114,13 +115,181 @@ thread_local! { /// The objects of symbol art that [`bake_document`] has baked for instances inside the art of /// other instances, counted against [`crate::SYMBOL_ART_MAX_NODES`]. static NESTED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// While [`bake_document_placing_symbols`] bakes: the symbols whose instances become their + /// placed art ([`placed_symbol_art`]). + static PLACING: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// The objects in the baked art of the instances that [`bake_node`] has replaced by their + /// placed art for one export ([`placed_symbol_art`]), counted against [`Placing::most_nodes`]. + static PLACED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// The objects in the baked art of the instances that [`bake_node`] baked for one export and + /// then left as they are ([`placed_fits`] refused the art), counted with [`PLACED_NODES`] + /// against [`Placing::most_nodes`] before more art is baked. Never given back. + static DROPPED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// How deep the object [`bake_node`] is baking sits in the tree it returns: a top-level layer + /// is 1 deep. + static LEVEL: std::cell::Cell = const { std::cell::Cell::new(1) }; + /// Whether [`bake_node`] is baking inside an object the export does not write ([`unwritten`]), + /// where no instance is placed. + static UNWRITTEN: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// `f` run for an object one level deeper ([`LEVEL`]) than the one being baked. +fn one_deeper(f: impl FnOnce() -> T) -> T { + LEVEL.with(|c| c.set(c.get() + 1)); + let out = f(); + LEVEL.with(|c| c.set(c.get().saturating_sub(1))); + out +} + +/// The symbols whose instances [`bake_document_placing_symbols`] replaces by their placed art, and +/// how that art is placed through 9-slice guides. +struct Placing { + /// The names of the symbols whose art draws something that baking changes, directly or through + /// the instances of other such symbols in it ([`drawn_symbols`]). + symbols: HashSet, + /// Whether 9-slice cells are clipped ([`Document::place_symbol_art`]) or not + /// ([`Document::place_symbol_art_unclipped`]). + clip_slices: bool, + /// Most objects the baked art of all placed instances may hold together: + /// [`vectorcraft_doc::MAX_SYMBOL_NODES`] for an export. + most_nodes: u64, + /// How deep the document's symbols and patterns draw, for the instances and pattern paints + /// left in placed art. + heights: vectorcraft_doc::depth::DrawnHeights, + /// The hidden layers and layers that do not print that the export writes. + layers: WrittenLayers, +} + +/// The layers an exporter writes besides the shown, printing ones +/// ([`bake_document_placing_symbols`]). Template layers are never written. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct WrittenLayers { + /// Hidden layers (written turned off, as PDF layers and DXF write them). + pub hidden: bool, + /// Layers that do not print. + pub non_printing: bool, +} + +/// Does the export leave `n` and everything in it out, while [`bake_document_placing_symbols`] +/// bakes? A hidden object other than a layer, a template layer, and a hidden layer or a layer +/// that does not print when the export leaves those out ([`WrittenLayers`]). +fn unwritten(n: &Node) -> bool { + PLACING.with(|p| { + p.borrow().as_ref().is_some_and(|p| match n.kind { + NodeKind::Layer { template, printable, .. } => template || !(printable || p.layers.non_printing) || !(n.visible || p.layers.hidden), + _ => !n.visible, + }) + }) +} + +/// Can `baked`, the baked art of a placed instance, take the instance's place [`LEVEL`] deep, +/// when the art placed for the export so far holds `spent` objects and the art baked for +/// instances left as they are holds `dropped` ([`DROPPED_NODES`])? `Some` with the +/// objects placed then, when its objects (opacity masks' art included) fit with those within +/// [`Placing::most_nodes`] and nothing in it, the symbols and patterns it uses included, draws +/// deeper than [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`]. +fn placed_fits(baked: &Node, spent: u64, dropped: u64) -> Option { + PLACING.with(|p| { + let p = p.borrow(); + let p = p.as_ref()?; + let spent = spent.saturating_add(baked.count_with_masks() as u64); + let deepest = p.heights.deepest(baked, LEVEL.with(|c| c.get())); + (spent.saturating_add(dropped) <= p.most_nodes && deepest <= vectorcraft_doc::depth::MAX_DRAWN_DEPTH).then_some(spent) + }) +} + +/// The names of the symbols of `d` whose art needs baking ([`needs_bake`]), and of those whose art +/// holds an instance of one of them at any depth. +fn drawn_symbols(d: &Document) -> HashSet { + let mut out: HashSet = d.symbols.iter().filter(|s| needs_bake(&s.art)).map(|s| s.name.clone()).collect(); + if out.is_empty() { + return out; + } + let uses: Vec<(&str, Vec)> = d + .symbols + .iter() + .filter(|s| !out.contains(&s.name)) + .map(|s| { + let mut names = vec![]; + s.art.walk(&mut |c| { + if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { + names.push(symbol.clone()); + } + }); + (s.name.as_str(), names) + }) + .collect(); + loop { + let before = out.len(); + for (name, names) in &uses { + if !out.contains(*name) && names.iter().any(|x| out.contains(x)) { + out.insert((*name).to_string()); + } + } + if out.len() == before { + return out; + } + } +} + +/// Does `n`'s subtree hold an instance of a symbol that [`bake_document_placing_symbols`] replaces +/// by its placed art? False outside it, and for objects the export does not write ([`unwritten`]). +fn holds_placed_symbol(n: &Node) -> bool { + fn walk(n: &Node, symbols: &HashSet) -> bool { + !unwritten(n) + && (matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbols.contains(symbol)) + || n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols)))) + } + !UNWRITTEN.with(|c| c.get()) && PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols))) +} + +/// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it +/// places, as the art it draws: the symbol's art with the instance's own appearance changes and +/// its stain ([`vectorcraft_doc::symbol_overrides::instance_art`]), placed by the instance as the exporters +/// place it, in a group with the instance's id, name, visibility, transparency, opacity mask, +/// raster effects and attributes (its URL among them). The art is placed before its effects are baked, as the canvas draws it, so an +/// instance that scales its art does not scale what its effects do. `None` for other objects, +/// for instances the export does not write ([`unwritten`], hidden or inside such an object), for +/// instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art +/// as placed, before its effects are baked, would already take the art placed for the export, +/// with the art baked for instances left as they are ([`DROPPED_NODES`]), past +/// [`Placing::most_nodes`]; those instances stay as they are. `Some` with the objects in the art +/// as placed, before its effects are baked. [`bake_node`] checks the baked art with +/// [`placed_fits`]. +fn placed_symbol_art(d: &Document, n: &Node) -> Option<(Node, u64)> { + let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; + let (clip, most_nodes) = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| (p.clip_slices, p.most_nodes)))?; + if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH || UNWRITTEN.with(|c| c.get()) || unwritten(n) { + return None; + } + let sym = d.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n); + let most = match d.symbol_slice_map(symbol, *xf) { + Some(m) if clip => m.most_placed(&art, d), + Some(m) => m.most_placed_unclipped(&art, d), + None => art.count_with_masks() as u64, + }; + if PLACED_NODES.with(|c| c.get()).saturating_add(DROPPED_NODES.with(|c| c.get())).saturating_add(most) > most_nodes { + return None; + } + let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?; + let mut g = dressed(Node::group(n.id, vec![Arc::new(placed)]), n); + g.extras_mut().attrs = n.extras().attrs.clone(); + Some((g, most)) } /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { + // Inside an object the export leaves out, effects are baked but no instance is placed. + if !UNWRITTEN.with(|c| c.get()) && unwritten(n) { + UNWRITTEN.with(|c| c.set(true)); + let out = bake_node(d, n); + UNWRITTEN.with(|c| c.set(false)); + return out; + } // A cancelled export leaves the rest of the document as it is: the caller drops the result. // A ruler guide draws nothing and stays as it is, last in its layer. - if n.is_ruler_guide() || !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { + if n.is_ruler_guide() || !(needs_bake(n) || holds_placed_symbol(n)) || vectorcraft_doc::cancel::is_cancelled() { return None; } // Colour adjustments: the object recoloured (inside it too), then baked as it is. @@ -181,6 +350,26 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { } return Some(baked.unwrap_or(m)); } + // Symbol instances whose art draws live effects, when the caller asks for them: the art + // placed, then baked. When the baked art passes the export's budget, or would draw too deep + // where the instance is, it is dropped with the art placed for the instances inside it, and + // the instance stays as it is. While the art bakes, its objects before baking are held in + // the budget, so the instances inside it are placed only within what is left; art baked and + // then dropped still counts against the budget for the instances after it. + if let Some((m, most)) = placed_symbol_art(d, n) { + let spent = PLACED_NODES.with(|c| c.get()); + PLACED_NODES.with(|c| c.set(spent.saturating_add(most))); + INSTANCE_DEPTH.with(|c| c.set(c.get() + 1)); + let baked = bake_node(d, &m); + INSTANCE_DEPTH.with(|c| c.set(c.get() - 1)); + let baked = baked.unwrap_or(m); + if let Some(now) = placed_fits(&baked, spent, DROPPED_NODES.with(|c| c.get())) { + PLACED_NODES.with(|c| c.set(now)); + return Some(baked); + } + PLACED_NODES.with(|c| c.set(spent)); + DROPPED_NODES.with(|c| c.set(c.get().saturating_add(baked.count_with_masks() as u64))); + } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own, so its gradient fits it rather than spanning // every copy and its strokes and patterns can differ from the others'. @@ -214,11 +403,11 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { .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); + let mut a = one_deeper(|| 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()), + None => one_deeper(|| bake_node(d, c)).map(Arc::new).unwrap_or_else(|| c.clone()), }) .collect(); if let Some(ch) = m.children_mut() { @@ -295,7 +484,7 @@ fn bake_pieces(d: &mut Document, mut m: Node) -> Node { .into_iter() .map(|c| { let c = Arc::unwrap_or_clone(c); - let mut c = bake_node(d, &c).unwrap_or(c); + let mut c = one_deeper(|| bake_node(d, &c)).unwrap_or(c); fresh_ids(d, &mut c, &mut seen); Arc::new(c) }) @@ -772,8 +961,60 @@ fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node, outline_strokes: /// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when /// there is nothing to do (export it as is). pub fn bake_document(doc: &Document) -> Option { + bake(doc, None) +} + +/// [`bake_document`], and every symbol instance whose symbol's art draws something baking changes +/// (live geometry effects, color adjustments and the rest [`needs_bake`] counts, in its own art or +/// in the art of the symbols it places) replaced by that art as the instance places it, baked +/// ([`placed_symbol_art`]), so the art draws as on the canvas wherever the instance is. For +/// exporters that write each instance's art in its place (PDF, print, EPS, DXF), rather than +/// writing each symbol once. `clip_slices`: whether 9-slice cells are clipped +/// ([`Document::place_symbol_art`]) or not ([`Document::place_symbol_art_unclipped`]), as the +/// exporter places other instances. `layers`: the hidden layers and layers that do not print +/// that the exporter writes. Instances the export does not write (hidden ones, those in hidden +/// objects, on template layers, and on the layers `layers` leaves out) stay as they are. The +/// symbols' own art is left as it is. +pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool, layers: WrittenLayers) -> Option { + bake(doc, Some((clip_slices, vectorcraft_doc::MAX_SYMBOL_NODES, layers))) +} + +/// Restores what [`bake`] changes in the thread's baking state when it ends. +struct Restore(Option>, u64, u64, usize, bool); + +impl Drop for Restore { + fn drop(&mut self) { + PLACING.with(|p| *p.borrow_mut() = self.0.take()); + PLACED_NODES.with(|c| c.set(self.1)); + DROPPED_NODES.with(|c| c.set(self.2)); + LEVEL.with(|c| c.set(self.3)); + UNWRITTEN.with(|c| c.set(self.4)); + } +} + +/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices`, +/// [`Placing::most_nodes`] and [`Placing::layers`]. +fn bake(doc: &Document, placing: Option<(bool, u64, WrittenLayers)>) -> Option { let placed = doc.with_placed_art(); - if !placed.layers.iter().any(|l| needs_bake(l)) { + let placing = placing + .map(|(clip_slices, most_nodes, layers)| (drawn_symbols(&placed), clip_slices, most_nodes, layers)) + .filter(|p| !p.0.is_empty()) + .map(|(symbols, clip_slices, most_nodes, layers)| Placing { + symbols, + clip_slices, + most_nodes, + heights: vectorcraft_doc::depth::DrawnHeights::of(&placed), + layers, + }); + // A bake inside another one (of art an effect makes) places symbols only when it asks to. + let _restore = Restore( + PLACING.with(|p| p.replace(placing.map(Rc::new))), + PLACED_NODES.with(|c| c.replace(0)), + DROPPED_NODES.with(|c| c.replace(0)), + LEVEL.with(|c| c.replace(1)), + UNWRITTEN.with(|c| c.replace(false)), + ); + if !placed.layers.iter().any(|l| needs_bake(l) || holds_placed_symbol(l)) { return match placed { std::borrow::Cow::Owned(d) => Some(d), std::borrow::Cow::Borrowed(_) => None, diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 8181f0e02e..7b353f78f9 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -1115,3 +1115,345 @@ fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() { // Three squares of their own, not one path spanning all three. assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]); } + +/// A 10 × 10 square at (10, 40), with id `id`, that a Transform effect moves 100 pt right. +fn moved_square(id: u64) -> Node { + let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art()); + n.appearance.effects.push(fx("distort.transform", json!({"moveH": 100.0}))); + n +} + +fn instance(id: u64, symbol: &str, xf: vectorcraft_geom::Affine) -> Node { + Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +fn add_symbol(d: &mut Document, name: &str, art: Node) { + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); +} + +fn bounds(d: &Document, id: u64) -> Rect { + d.node(NodeId(id)).and_then(|n| n.geometric_bounds()).unwrap_or_else(|| panic!("node {id} has bounds")) +} + +fn near_rect(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6) +} + +/// Each instance draws its symbol's art with the art's effects applied where the instance places +/// it: an instance that doubles the art's size moves it the effect's 100 pt, as the canvas draws +/// it, not 200 pt. Plain baking leaves the instances and the symbols' art as they are. +#[test] +fn placing_symbols_bakes_their_art_where_each_instance_places_it() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)) * Affine::scale(2.0))).unwrap(); + add_symbol(&mut d, "S", moved_square(800)); + assert!(bake_document(&d).is_none(), "nothing in the layers needs baking"); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + assert!(matches!(out.node(NodeId(900)).unwrap().kind, NodeKind::Group { .. })); + assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 40.0, 120.0, 50.0)), "{:?}", bounds(&out, 900)); + assert!(near_rect(bounds(&out, 901), Rect::new(120.0, 180.0, 140.0, 200.0)), "{:?}", bounds(&out, 901)); + assert_eq!(out.symbols[0].art.appearance.effects.len(), 1, "the symbol's art keeps its effect"); +} + +/// An instance of a symbol whose art holds an instance of another symbol with effects draws that +/// art with its effects too. +#[test] +fn placing_symbols_reaches_the_art_of_nested_instances() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", moved_square(800)); + add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::translate((0.0, 100.0))))])); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 140.0, 120.0, 150.0)), "{:?}", bounds(&out, 900)); +} + +/// A symbol whose art holds an instance of itself is placed until the depth limit, and the export +/// ends. +#[test] +fn placing_a_symbol_that_holds_itself_stops() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Loop", Affine::IDENTITY)); + add_symbol( + &mut d, + "Loop", + Node::group(NodeId(801), vec![Arc::new(moved_square(800)), Arc::new(instance(802, "Loop", Affine::translate((0.0, 1.0))))]), + ); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + let mut lefts = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| lefts.extend(n.path_data().and_then(|p| p.bounds()).map(|b| b.x0))); + assert!(lefts.len() > 1 && lefts.iter().all(|x| (x - 110.0).abs() < 1e-6), "{lefts:?}"); + let mut depth = 0; + let mut n = out.node(NodeId(900)).unwrap(); + while let Some(next) = n.children().and_then(|ch| ch.last()) { + n = next; + depth += 1; + } + assert!(depth <= 3 * (vectorcraft_doc::depth::MAX_DEPTH + 1), "{depth}"); +} + +/// A dynamic symbol instance's own paint for an object of the art applies before the object's +/// effects are baked: a fill whose own Transform effect makes the square a group of pieces still +/// paints in the instance's red, and its sibling keeps the symbol's paint. +#[test] +fn placing_symbols_keeps_dynamic_instances_changes() { + use vectorcraft_geom::Affine; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let mut square = Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art()); + if let Some(AppearanceItem::Fill(f)) = square.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.effects.push(fx("distort.transform", json!({"moveH": 100.0}))); + } + let sibling = Node::path(NodeId(801), shapes::rectangle(Rect::new(10.0, 60.0, 20.0, 70.0)), Appearance::default_art()); + let mut inst = instance(900, "D", Affine::IDENTITY); + inst.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(red.clone()), ..Default::default() }]; + let mut d = doc_with(inst); + add_symbol(&mut d, "D", Node::group(NodeId(802), vec![Arc::new(square), Arc::new(sibling)])); + d.symbols[0].dynamic = true; + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + let mut fills = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| { + if n.path_data().is_some() + && let Some(b) = n.geometric_bounds() + { + fills.push((b.x0.round(), b.y0.round(), n.appearance.fill_paint())); + } + }); + assert!(fills.contains(&(110.0, 40.0, red)), "{fills:?}"); + assert!(fills.contains(&(10.0, 60.0, Paint::solid(Color::WHITE))), "{fills:?}"); +} + +/// An instance whose fill stains its symbol's art places the art in its stained colors, with the +/// art's effects applied after the stain: a Transform moves the stained square and an Invert +/// inverts the stained color. +#[test] +fn placing_symbols_keeps_the_instances_stain() { + use vectorcraft_geom::Affine; + let blue = Color::rgb8(30, 90, 220); + let mut inst = instance(900, "Mark", Affine::IDENTITY); + inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + inst.appearance.items.retain(|i| i.is_fill()); + if let AppearanceItem::Fill(f) = &mut inst.appearance.items[0] { + f.opacity = 0.5; + } + let square = + Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(Paint::solid(blue), Paint::None, 0.0)); + let stained = vectorcraft_doc::symbol_overrides::instance_art(&square, &inst).appearance.fill_paint().color().unwrap(); + assert_ne!(stained.to_hex(), blue.to_hex()); + let [r, g, b] = stained.to_rgb(); + let inverted = Color::rgb(1.0 - r, 1.0 - g, 1.0 - b); + for (id, params, want) in [("distort.transform", json!({"moveH": 100.0}), stained), (crate::addons::color::INVERT, json!({}), inverted)] { + let mut art = square.clone(); + art.appearance.effects.push(fx(id, params)); + let mut d = doc_with(inst.clone()); + add_symbol(&mut d, "Mark", art); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + assert!(!is_instance(&out, 900), "{id}: the instance is placed"); + let mut fills = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| { + if n.path_data().is_some() { + fills.extend(n.appearance.fill_paint().color().map(|c| c.to_hex())); + } + }); + assert_eq!(fills, [want.to_hex()], "{id}"); + } +} + +/// Every layer written, as DXF writes them (template layers are never written). +const ALL: WrittenLayers = WrittenLayers { hidden: true, non_printing: true }; + +fn is_instance(d: &Document, id: u64) -> bool { + d.node(NodeId(id)).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })) +} + +/// The art placed for an export is counted as baked: the copies an effect makes count, so an +/// instance whose baked art passes what is left of the budget stays an instance, and the budget +/// holds the baked art of the instances placed, exactly. +#[test] +fn placing_symbols_counts_the_baked_art() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)))).unwrap(); + add_symbol(&mut d, "S", gradient_copies(800)); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let each = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + assert!(each > 2, "the instance's group, and the copies as objects of their own: {each}"); + assert_eq!(all.node(NodeId(901)).map(|n| n.count_with_masks() as u64), Some(each)); + let fits = bake(&d, Some((true, 2 * each, ALL))).unwrap(); + assert!(!is_instance(&fits, 900) && !is_instance(&fits, 901)); + let short = bake(&d, Some((true, 2 * each - 1, ALL))).unwrap(); + assert_eq!(usize::from(is_instance(&short, 900)) + usize::from(is_instance(&short, 901)), 1, "one instance stays as it is"); +} + +/// The baked art of an instance placed inside another's art is counted once: a budget of exactly +/// the outer instance's baked art places both. +#[test] +fn placing_symbols_counts_nested_art_once() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", gradient_copies(800)); + add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::IDENTITY))])); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let total = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + let exact = bake(&d, Some((true, total, ALL))).unwrap(); + assert!(!is_instance(&exact, 900) && !is_instance(&exact, 802), "both are placed within {total}"); + let short = bake(&d, Some((true, total - 1, ALL))).unwrap(); + assert!(is_instance(&short, 900), "the outer instance stays as it is"); +} + +/// While an instance's art bakes, the art's own objects are held in the budget: an instance +/// nested in it whose art does not fit in what is left stays as it is, and the outer instance is +/// placed around it. +#[test] +fn placing_symbols_holds_the_outer_art_while_it_bakes() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", Node::group(NodeId(803), vec![Arc::new(gradient_copies(800))])); + let mut art: Vec> = (0..5) + .map(|i| Arc::new(Node::path(NodeId(810 + i), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()))) + .collect(); + art.push(Arc::new(instance(802, "Inner", Affine::IDENTITY))); + let outer = Node::group(NodeId(801), art); + // The outer instance's group around its art, with the inner instance left as it is. + let around = 1 + outer.count_with_masks() as u64; + add_symbol(&mut d, "Outer", outer); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let inner = all.node(NodeId(802)).map_or(0, |n| n.count_with_masks() as u64); + assert!(inner > 2 && inner <= around, "the inner art alone fits: {inner} <= {around}"); + let out = bake(&d, Some((true, around, ALL))).unwrap(); + assert!(!is_instance(&out, 900) && is_instance(&out, 802), "the outer instance is placed around the inner one"); +} + +/// Art baked for an instance and then left out, because it passes the budget, still counts: an +/// instance after it whose art would fit in the budget alone stays as it is, and a budget with +/// room for both places both. +#[test] +fn placing_symbols_counts_the_art_it_drops() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(901, "Small", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(900, "Big", Affine::IDENTITY)).unwrap(); + add_symbol(&mut d, "Big", gradient_copies(800)); + add_symbol(&mut d, "Small", moved_square(810)); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let count = |id: u64| all.node(NodeId(id)).map_or(0, |n| n.count_with_masks() as u64); + let (big, small) = (count(900), count(901)); + assert!(small < big, "{small} < {big}"); + let short = bake(&d, Some((true, big - 1, ALL))).unwrap(); + assert!(is_instance(&short, 900), "the big art passes {}", big - 1); + assert!(is_instance(&short, 901), "the big art's {big} objects count, so the small art does not fit"); + let both = bake(&d, Some((true, big + small, ALL))).unwrap(); + assert!(!is_instance(&both, 900) && !is_instance(&both, 901)); +} + +/// Instances the export does not write are not placed and do not count against the budget: with +/// room for one instance's baked art, hidden instances, an instance in a hidden group, one on a +/// template layer, one on a layer that does not print and one on a hidden layer (when the export +/// leaves those layers out) come first and stay as they are, and the visible instance after them +/// is placed. An export that writes those layers places their instances first. +#[test] +fn placing_symbols_skips_instances_the_export_leaves_out() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + add_symbol(&mut d, "S", gradient_copies(800)); + let each = bake(&d, Some((true, u64::MAX, ALL))).unwrap().node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + let layer = d.layers[0].id; + let mut hidden = instance(901, "S", Affine::IDENTITY); + hidden.visible = false; + let mut group = Node::group(NodeId(902), vec![Arc::new(instance(903, "S", Affine::IDENTITY))]); + group.visible = false; + for (i, n) in [hidden, group].into_iter().enumerate() { + d.insert(Some(layer), i, n).unwrap(); + } + let mut left_out = vec![]; + for (id, template, printable, visible) in [(910, true, true, true), (920, false, false, true), (930, false, true, false)] { + let mut l = Node::layer(NodeId(id), &format!("L{id}"), vectorcraft_doc::LayerColor::Preset(0)); + if let NodeKind::Layer { template: t, printable: p, .. } = &mut l.kind { + *t = template; + *p = printable; + } + l.visible = visible; + if let Some(ch) = l.children_mut() { + ch.push(Arc::new(instance(id + 1, "S", Affine::IDENTITY))); + } + left_out.push(Arc::new(l)); + } + d.layers.splice(0..0, left_out); + let out = bake(&d, Some((true, each, WrittenLayers::default()))).unwrap(); + assert!(!is_instance(&out, 900), "the visible instance is placed within {each}"); + for id in [901, 903, 911, 921, 931] { + assert!(is_instance(&out, id), "{id} stays as it is"); + } + // An export that writes the layers that do not print, or the hidden layers, places that + // layer's instance first; the template layer's instance is never placed. + for (layers, first) in [(WrittenLayers { non_printing: true, hidden: false }, 921), (WrittenLayers { non_printing: false, hidden: true }, 931)] { + let out = bake(&d, Some((true, each, layers))).unwrap(); + assert!(!is_instance(&out, first) && is_instance(&out, 900) && is_instance(&out, 911), "{layers:?}"); + } +} + +/// `leaf` in `levels` groups, with ids from `id` up. +fn in_groups(id: u64, levels: u64, leaf: Node) -> Node { + (0..levels).fold(leaf, |n, i| Node::group(NodeId(id + i), vec![Arc::new(n)])) +} + +/// Placed art is drawn no deeper than the document may draw: an instance whose art's effects add +/// a level past it stays an instance, and the instances around it are placed. +#[test] +fn placing_symbols_keeps_the_baked_art_within_the_drawn_depth() { + use vectorcraft_doc::depth::{DrawnHeights, MAX_DRAWN_DEPTH}; + use vectorcraft_geom::Affine; + // The instance on the layer is 2 deep; A to D each hold the next 31 objects down, and E's + // square is 2 deep in its art: 2 + 4 × 31 + 2 = MAX_DRAWN_DEPTH. + let mut d = doc_with(instance(900, "A", Affine::IDENTITY)); + for (i, (name, next)) in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")].into_iter().enumerate() { + let base = 1000 * (i as u64 + 1); + add_symbol(&mut d, name, in_groups(base, 30, instance(base + 100, next, Affine::IDENTITY))); + } + add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100))); + assert!(!d.too_deep(None)); + assert_eq!(DrawnHeights::of(&d).deepest(&d.layers[0], 1), MAX_DRAWN_DEPTH); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + // E's square becomes a group of copies, a level past the cap: E's instance stays. + assert!(is_instance(&out, 4100), "E's instance stays as it is"); + assert!(!is_instance(&out, 900) && !is_instance(&out, 3100), "A to D are placed"); + assert!(DrawnHeights::of(&out).deepest(&out.layers[0], 1) <= MAX_DRAWN_DEPTH); + // Without the chain above it, E's art is placed. + let mut shallow = doc_with(instance(900, "E", Affine::IDENTITY)); + add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); + assert!(!is_instance(&bake_document_placing_symbols(&shallow, true, ALL).unwrap(), 900)); +} + +/// Art baked for an instance and then left out because it draws too deep still counts when the +/// baked art of a later instance is checked: that art fits before its effects add copies, but +/// not once they do. +#[test] +fn placing_symbols_counts_art_dropped_for_depth_against_later_baked_art() { + use vectorcraft_geom::Affine; + // The chain of placing_symbols_keeps_the_baked_art_within_the_drawn_depth, then F, whose + // square becomes a group of copies once baked. + let mut d = doc_with(instance(900, "A", Affine::IDENTITY)); + for (i, (name, next)) in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")].into_iter().enumerate() { + let base = 1000 * (i as u64 + 1); + add_symbol(&mut d, name, in_groups(base, 30, instance(base + 100, next, Affine::IDENTITY))); + } + add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100))); + add_symbol(&mut d, "F", gradient_copies(6000)); + let layer = d.layers[0].id; + d.insert(Some(layer), 1, instance(901, "F", Affine::IDENTITY)).unwrap(); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + assert!(is_instance(&all, 4100) && !is_instance(&all, 900) && !is_instance(&all, 901)); + let count = |d: &Document, id: u64| d.node(NodeId(id)).map_or(0, |n| n.count_with_masks() as u64); + let (a, f) = (count(&all, 900), count(&all, 901)); + // E's baked art, as placed where nothing above it draws deep. + let mut shallow = doc_with(instance(4100, "E", Affine::IDENTITY)); + add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); + let e = count(&bake(&shallow, Some((true, u64::MAX, ALL))).unwrap(), 4100); + assert!(f > 2, "F's copies are objects of their own: {f}"); + let short = bake(&d, Some((true, a + e + f - 1, ALL))).unwrap(); + assert!(!is_instance(&short, 900) && is_instance(&short, 4100), "A to D are placed, E is not"); + assert!(is_instance(&short, 901), "E's dropped {e} objects count, so F's baked {f} do not fit"); + let room = bake(&d, Some((true, a + e + f, ALL))).unwrap(); + assert!(!is_instance(&room, 900) && !is_instance(&room, 901)); +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c18a31d842..3d74ac4fe0 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -68,7 +68,10 @@ pub use adjust::{ ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment, is_channel, }; -pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; +pub use bake::{ + StrokeArt, WrittenLayers, bake_appearance, bake_document, bake_document_placing_symbols, bake_type_appearance, expand_art, expand_leaf, + fresh_ids, needs_bake, +}; pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent}; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, diff --git a/crates/effects/src/lighting.rs b/crates/effects/src/lighting.rs index 8592776a95..257576822a 100644 --- a/crates/effects/src/lighting.rs +++ b/crates/effects/src/lighting.rs @@ -136,8 +136,86 @@ pub(crate) fn facet_art( composed_art(id, path, paint, Ramp::Rgb(light), seam) } -/// Composite smooth solid-fill facets with one clip and three channel groups per surface. -/// Black channel backdrops keep shared edges opaque before the final coverage is applied. +/// The width of the seam stroke of a facet's underlay: a round stroke that reaches 1 pt past each +/// edge, over the pixels that the antialiased edges of two neighboring facets each cover only in +/// part at 1:1. A clip to the surface's outline removes what it paints past the surface. +pub(crate) const UNDERLAY_SEAM: f64 = 2.0; + +/// The area facet `path` and its round seam stroke `seam` wide paint together: the stroke's outer +/// contours and the facet, all wound one way with a positive area. The stroke's inner contours, +/// wound the other way, are left out: the facet covers them, and contours that cancel each other +/// leave partial coverage where a union of footprints meets at a pixel. +pub(crate) fn footprint(path: &vectorcraft_geom::BezPath, seam: f64) -> vectorcraft_geom::BezPath { + use kurbo::{PathEl, Shape}; + let stroke = kurbo::stroke(path.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05); + let sign = if stroke.area() < 0.0 { -1.0 } else { 1.0 }; + let mut footprint = vectorcraft_geom::BezPath::new(); + let mut contour = vectorcraft_geom::BezPath::new(); + for el in stroke.elements().iter().chain(std::iter::once(&PathEl::MoveTo(kurbo::Point::ZERO))) { + if matches!(el, PathEl::MoveTo(_)) && !contour.elements().is_empty() { + if contour.area() * sign >= 0.0 { + footprint.extend(contour.iter()); + } + contour = vectorcraft_geom::BezPath::new(); + } + contour.push(*el); + } + let mut boundary = path.clone(); + if boundary.area() * sign < 0.0 { + boundary = boundary.reverse_subpaths(); + } + footprint.extend(boundary.iter()); + if sign < 0.0 { footprint.reverse_subpaths() } else { footprint } +} + +/// Colored-light facets `faces` (in drawing order) drawn over an underlay of themselves, the same +/// facets grown to seams [`UNDERLAY_SEAM`] wide, all clipped once to the union of the facets' +/// footprints. Where two facets' antialiased edges meet, the underlay fills what they leave +/// uncovered with the neighbors' own colors, so the background does not show through. `None` unless +/// every face is a facet a surface draws: a path filled and stroked by its seam, or a facet composed +/// in a clip of its footprint, neither with an opacity mask. +pub(crate) fn sealed(id: vectorcraft_doc::NodeId, faces: &[std::sync::Arc]) -> Option { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, NodeKind}; + let mut outline = vectorcraft_geom::BezPath::new(); + let mut underlay = Vec::with_capacity(faces.len()); + for face in faces { + if face.extras().mask.is_some() { + return None; + } + match &face.kind { + NodeKind::Path { clipping: false, .. } => { + let path = face.path_data()?.to_bezpath(); + let mut seam = None; + let mut grown = (**face).clone(); + for item in &mut grown.appearance.items { + if let AppearanceItem::Stroke(s) = item { + seam = Some(seam.map_or(s.width, |w: f64| w.max(s.width))); + s.width = UNDERLAY_SEAM; + } + } + outline.extend(footprint(&path, seam?).iter()); + underlay.push(Arc::new(grown)); + } + NodeKind::Group { children, clip: true } if children.len() == 2 => { + let clip = children.first()?.path_data()?.to_bezpath(); + let wider = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&clip, UNDERLAY_SEAM)), Appearance::default()); + let colors = children.get(1)?.clone(); + outline.extend(clip.iter()); + underlay.push(Arc::new(Node::new(id, NodeKind::Group { children: vec![Arc::new(wider), colors], clip: true }))); + } + _ => return None, + } + } + let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&outline), Appearance::default()); + underlay.extend(faces.iter().cloned()); + Some(Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(Node::group(id, underlay))], clip: true })) +} + +/// Composite smooth solid-fill facets with one clip and three channel groups per surface. In each +/// channel, an underlay of the facets with wide seams lies under the facets themselves, so a +/// shared edge is opaque in that channel's own light before the channels are screened together; +/// the black backdrop shows only outside the surface, which the clip removes. pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_three_d::Facet], paint: &Paint) -> vectorcraft_doc::Node { use kurbo::Shape; use std::sync::Arc; @@ -167,20 +245,36 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t if let Some(value) = color.get_mut(channel) { *value = 1.0; } + let paints: Vec = facets + .iter() + .map(|face| { + if let Some(ramp) = face.rgb_gradient { + smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE) + } else { + Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) + } + }) + .collect(); + let draw = |seam: fn(&vectorcraft_three_d::Facet) -> f64| -> Vec> { + facets + .iter() + .zip(&paints) + .map(|(face, paint)| { + let edge = seam_stroke(paint.clone(), seam(face)); + Arc::new(Node::path( + id, + face.path(), + Appearance { + items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(edge)], + ..Default::default() + }, + )) + }) + .collect() + }; let mut children = vec![Arc::new(cover(Paint::solid(Color::BLACK)))]; - children.extend(facets.iter().map(|face| { - let paint = if let Some(ramp) = face.rgb_gradient { - smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE) - } else { - Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) - }; - let edge = seam_stroke(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); - Arc::new(Node::path( - id, - face.path(), - Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)], ..Default::default() }, - )) - })); + children.extend(draw(|_| UNDERLAY_SEAM)); + children.extend(draw(|face| (face.thickness() / 2.0).clamp(0.05, 0.75))); let mut group = Node::group(id, children); group.isolate = true; group.blend = BlendMode::Screen; @@ -228,24 +322,25 @@ pub(crate) fn opaque_paint(paint: &Paint) -> (Paint, Option) { } fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, paint: &Paint, light: Ramp, seam: f64) -> vectorcraft_doc::Node { - use kurbo::Shape; use std::sync::Arc; use vectorcraft_doc::color::BlendMode; use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask}; use vectorcraft_geom::{Rect, shapes}; - let (base, mask) = opaque_paint(paint); - // Clip once to the union of this single-contour face and its round seam stroke. - // Align their winding so the nonzero fill adds coverage instead of cancelling it. - let mut boundary = path.to_bezpath(); - let mut footprint = kurbo::stroke(boundary.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05); - if boundary.area() * footprint.area() < 0.0 { - boundary = boundary.reverse_subpaths(); + // An unplaced gradient (and a freeform gradient without points) stays fitted to the face's + // bounds grown by 1 pt, not to the larger cover below. + let mut paint = paint.clone(); + if let Paint::Gradient(g) = &mut paint { + g.pin(path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0)); } - footprint.extend(boundary.iter()); - let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default()); + let (base, mask) = opaque_paint(&paint); + // Clip once to the union of this single-contour face and its round seam stroke. + let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&path.to_bezpath(), seam)), Appearance::default()); // Opaque rectangles cover the complete seam stroke, without separately antialiasing - // either ramp. Opaque sources need only vector clipping, not full-frame opacity masks. - let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0); + // either ramp. Opaque sources need only vector clipping, not full-frame opacity masks. They + // reach at least half a point past the seam stroke grown for an underlay ([`sealed`]), so + // their own edges stay outside either clip. + let margin = seam.max(1.0) + UNDERLAY_SEAM / 2.0; + let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(margin, margin); let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0)); let mut illumination = match light { Ramp::Single(light) => cover(smooth_paint(light, Color::WHITE)), @@ -380,4 +475,30 @@ mod tests { assert_eq!(point.color.to_rgb(), [1.0, 0.25, 0.25]); assert_eq!(shaded.swatch, None, "a changed freeform point detaches the gradient swatch"); } + + /// Under colored light, a face's unplaced source gradient is fitted to the face's bounds grown by + /// 1 pt, whatever the size of the cover rectangle that draws it; a placed one keeps its placement. + #[test] + fn composed_faces_fit_unplaced_gradients_to_the_face_not_the_cover() { + use vectorcraft_doc::NodeKind; + use vectorcraft_geom::{Rect, shapes}; + use vectorcraft_three_d::{RgbGradient, ShadeGradient}; + let face = shapes::rectangle(Rect::new(10.0, 20.0, 50.0, 40.0)); + let ramp = |b| ShadeGradient { start: Point::new(10.0, 20.0), end: Point::new(50.0, 20.0), brightness: [b, 1.0] }; + let light = RgbGradient { channels: [ramp(0.2), ramp(0.5), ramp(0.8)] }; + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let unplaced = GradientPaint { gradient: Gradient::new(GradientKind::Linear, stops), geom: None, angle: 0.0, swatch: None, freeform: None }; + let placed = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(80.0, 0.0), aspect: 1.0, focal: None }; + let fitted = unplaced.resolve(Rect::new(9.0, 19.0, 51.0, 41.0)); + for (geom, expected) in [(None, fitted), (Some(placed), placed)] { + let paint = Paint::Gradient(Box::new(GradientPaint { geom, ..unplaced.clone() })); + for seam in [0.05, 0.75] { + let art = facet_art(vectorcraft_doc::NodeId(1), &face, &paint, light, seam); + let NodeKind::Group { children, clip: true } = &art.kind else { panic!("a clipped composite") }; + let base = &children[1].children().unwrap()[0]; + let Paint::Gradient(g) = base.appearance.items[0].paint() else { panic!("the source gradient") }; + assert_eq!(g.geom, Some(expected), "seam {seam}"); + } + } + } } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index ee7d3f8322..f0d2601d1e 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -868,7 +868,7 @@ fn as_art_opts( /// `art`, the art made of `n`, with `n`'s name, visibility, transparency, opacity mask and /// remaining (raster) effects. -fn dressed(mut art: Node, n: &Node) -> Node { +pub(crate) fn dressed(mut art: Node, n: &Node) -> Node { art.name = n.name.clone(); art.visible = n.visible; art.opacity = n.opacity; diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 1536f33bb3..1fae38c611 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -635,11 +635,26 @@ fn paint_ref(item: &AppearanceItem) -> &Paint { } } -/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored -/// lights give each facet derived paints (`lighting::solid_surface`, or `lighting::facet_art` for -/// each face). +/// Are `facets` drawn over an underlay of themselves ([`crate::lighting::sealed`], +/// [`crate::lighting::solid_surface`])? Under colored lights, when any facet takes a smooth light +/// ramp. +fn underlaid(facets: &[Facet], colored: bool) -> bool { + colored && facets.iter().any(|f| f.rgb_gradient.is_some()) +} + +/// The most paint copies `surface` makes for one facet ([`facet_copies`]). +const MAX_FACET_COPIES: usize = 12; + +/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), twice that when +/// the facets lie over an underlay of themselves, and [`MAX_FACET_COPIES`] when colored lights also +/// give each facet derived paints in three channels (`lighting::solid_surface`). fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize { - facets.len().saturating_mul(if composites_facets(facets, paint, colored) { 6 } else { 2 }) + let per_facet = match (underlaid(facets, colored), paint.color().is_some()) { + (true, true) => MAX_FACET_COPIES, + (true, false) => 4, + (false, _) => 2, + }; + facets.len().saturating_mul(per_facet) } /// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the @@ -832,14 +847,14 @@ fn rects_overlap(a: Rect, b: Rect) -> bool { /// How many faces [`cut_stroke_faces`] may add to solids painted by `items` that hold `faces` faces /// and whose paint copies take `paint_bytes` so far: as many as keep the faces within -/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as six copies -/// of the costliest stroke's paint. +/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as +/// [`MAX_FACET_COPIES`] copies of the costliest stroke's paint. fn cut_room<'a>(items: impl Iterator, faces: usize, paint_bytes: usize) -> usize { let per_face = items .filter(|item| matches!(item, AppearanceItem::Stroke(_))) .map(|item| { let mut copies = 0usize; - crate::revolve::paint_copies_add(&mut copies, 6, paint_ref(item)); + crate::revolve::paint_copies_add(&mut copies, MAX_FACET_COPIES, paint_ref(item)); copies }) .max() @@ -1693,6 +1708,10 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile let split = split && solids.iter().filter(|(item, _, _, f, _)| composite(item, f)).map(|(.., f, _)| f.len()).sum::() <= MAX_FACE_COMPOSITES; let members: Vec = solids.iter().map(|s| s.4).collect(); + // Which surfaces lie over an underlay of their facets, in one order or whole + // ([`in_one_order`], [`underlaid_surface`]). + let seals: Vec = solids.iter().map(|(.., f, _)| underlaid(f, colored)).collect(); + let seal = |range: std::ops::Range| seals.get(range).is_some_and(|s| s.iter().any(|s| *s)); let surfaces: Vec = solids .into_iter() .map(|(item, member, scopes, f, shape)| { @@ -1728,19 +1747,37 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile run.push(s); } let flat = run.iter().all(|s| plain(s) && s.1.opacity >= 1.0 && s.1.blend == BlendMode::Normal && s.1.extras().mask.is_none()); + let range = first..first + run.len(); if ordered(member) && flat { let scopes = run.first().map_or(&[][..], |s| s.2); - out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed))))); + let seals = seals.get(range).unwrap_or_default(); + out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed, seals))))); } else { - out.extend(with_graphics(run.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), first, &arts)); + let run = run.into_iter().zip(range).map(|((_, s, scopes, _), k)| (scopes, Arc::new(underlaid_surface(n.id, s, seal(k..k + 1))))); + out.extend(with_graphics(run.collect(), first, &arts)); } } return Some(masked(n.id, out)); } if !opaque || !surfaces.iter().all(plain) { + let surfaces = + surfaces.into_iter().enumerate().map(|(k, (d, s, scopes, e))| (d, underlaid_surface(n.id, s, seal(k..k + 1)), scopes, e)).collect(); return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); } - Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed)))]) + Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed, &seals)))]) +} + +/// A plain `surface` ([`surface`]: a group of its facets) over an underlay of its facets when +/// `seal`, keeping the surface's opacity, blend mode and opacity mask. +fn underlaid_surface(id: NodeId, surface: Node, seal: bool) -> Node { + let NodeKind::Group { children, clip: false } = &surface.kind else { return surface }; + let Some(mut out) = seal.then(|| crate::lighting::sealed(id, children)).flatten() else { return surface }; + out.opacity = surface.opacity; + out.blend = surface.blend; + if let Some(mask) = surface.extras().mask.clone() { + out.extras_mut().mask = Some(mask); + } + out } /// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in @@ -1748,7 +1785,16 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile /// surfaces right after the last face it is drawn on, of the surfaces that share it. A surface in a gradient with transparent stops /// carries an opacity mask: its faces go in runs ([`in_runs`]), each carrying the mask, and each of /// its graphics carries the mask too ([`with_graphics`]), so a graphic is hidden where its cap is. -fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: &[(usize, PlanarFace, Arc)], sealed: bool) -> Vec> { +/// The faces of the surfaces that `seals` marks lie over underlays of themselves +/// ([`underlaid_runs`], [`underlaid_surface`]). +fn in_one_order( + id: NodeId, + surfaces: Vec>, + first: usize, + arts: &[(usize, PlanarFace, Arc)], + sealed: bool, + seals: &[bool], +) -> Vec> { let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); let arts: Vec<&(usize, PlanarFace, Arc)> = arts.iter().filter(|(at, ..)| (first..first + surfaces.len()).contains(at)).collect(); let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); @@ -1787,7 +1833,46 @@ fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: (masks.len(), art) })); } - if masks.iter().any(Option::is_some) { in_runs(id, drawn, &masks) } else { drawn.into_iter().map(|(_, face)| face).collect() } + let takes_underlay = |surface: usize| seals.get(surface) == Some(&true); + if masks.iter().any(Option::is_some) { + // The faces of a surface without a mask are sealed run by run before the masked surfaces' + // faces go in runs of their own, each holding one surface's faces, sealed with its mask. + let unmasked = underlaid_runs(id, drawn, |s| takes_underlay(s) && masks.get(s).is_some_and(Option::is_none)); + return in_runs(id, unmasked, &masks) + .into_iter() + .map(|run| match &run.kind { + NodeKind::Group { clip: false, .. } if seals.iter().any(|s| *s) && run.extras().mask.is_some() => { + Arc::new(underlaid_surface(id, (*run).clone(), true)) + } + _ => run, + }) + .collect(); + } + underlaid_runs(id, drawn, takes_underlay).into_iter().map(|(_, face)| face).collect() +} + +/// `faces` in their drawing order, each with the index of its surface. Each run of consecutive +/// faces of one surface that `seal` takes lies over an underlay of its own faces, clipped to their +/// outline ([`crate::lighting::sealed`]), so no underlay reaches under another surface's faces or a +/// graphic drawn between them. A sealed run keeps its surface's index. +fn underlaid_runs(id: NodeId, faces: Vec<(usize, Arc)>, seal: impl Fn(usize) -> bool) -> Vec<(usize, Arc)> { + let mut out = Vec::with_capacity(faces.len()); + let mut run: Vec> = vec![]; + let mut faces = faces.into_iter().peekable(); + while let Some((surface, face)) = faces.next() { + run.push(face); + if faces.peek().is_some_and(|(next, _)| *next == surface) { + continue; + } + match seal(surface).then(|| crate::lighting::sealed(id, &run)).flatten() { + Some(sealed) => { + out.push((surface, Arc::new(sealed))); + run.clear(); + } + None => out.extend(run.drain(..).map(|face| (surface, face))), + } + } + out } /// `faces` in their back-to-front order, each with the index of its surface, where some surfaces diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 8222484c20..ab163e45cd 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -535,14 +535,53 @@ fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() { assert_eq!(drawn(&solid, Some(colored)).0, 1); } +/// The faces `run` stands for: a run of faces over an underlay of themselves ([`crate::lighting::sealed`]: +/// a clip and a group of the underlay's copies followed by the faces) gives its faces, any other +/// node gives itself. +fn unsealed(run: &Arc) -> Vec> { + if let NodeKind::Group { clip: true, children } = &run.kind + && let [clip, inner] = children.as_slice() + && matches!(clip.kind, NodeKind::Path { .. }) + && let NodeKind::Group { clip: false, children: kids } = &inner.kind + && !kids.is_empty() + && kids.len() % 2 == 0 + { + let (under, faces) = kids.split_at(kids.len() / 2); + let underlay = |k: &Arc| match &k.kind { + NodeKind::Group { clip, .. } => *clip, + _ => k.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.width == crate::lighting::UNDERLAY_SEAM)), + }; + if under.first().is_some_and(underlay) { + return faces.to_vec(); + } + } + vec![run.clone()] +} + +/// The bounds of every face under `n`, a sealed run's faces one by one ([`unsealed`]). +fn face_bounds_under(n: &Arc, out: &mut Vec) { + let faces = unsealed(n); + if faces.len() != 1 || !Arc::ptr_eq(&faces[0], n) { + out.extend(faces.iter().map(|f| drawn_face_bounds(f))); + return; + } + if let NodeKind::Group { clip: true, .. } = &n.kind { + out.push(drawn_face_bounds(n)); + } + for child in n.children().into_iter().flatten() { + face_bounds_under(child, out); + } +} + /// The bounds of face `f` of 3D art: its path's, or for a clipped colored composite -/// ([`crate::lighting::facet_art`]) its color cover's, which is the face's bounds grown by 1 pt. +/// ([`crate::lighting::facet_art`]) its color cover's, which is the face's bounds grown by 2 pt (a +/// point past the seam and half the underlay's seam). fn drawn_face_bounds(f: &Node) -> Rect { match &f.kind { NodeKind::Path { .. } => f.path_data().and_then(PathData::bounds).unwrap(), NodeKind::Group { clip: true, children } => { let cover = &children[1].children().unwrap()[0]; - cover.path_data().and_then(PathData::bounds).unwrap().inflate(-1.0, -1.0) + cover.path_data().and_then(PathData::bounds).unwrap().inflate(-2.0, -2.0) } _ => panic!("a face is a path or a clipped composite"), } @@ -587,7 +626,7 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() { let art = solid_art(&with).unwrap(); let surfaces = art.children().unwrap(); assert_eq!(surfaces.len(), 1, "{what}: one order"); - let faces = surfaces[0].children().unwrap(); + let faces: Vec> = surfaces[0].children().unwrap().iter().flat_map(unsealed).collect(); assert_eq!(faces.len(), parts.len(), "{what}: one face per facet"); for (k, (face, (facet, ..))) in faces.iter().zip(&parts).enumerate() { let (a, b) = (drawn_face_bounds(face), facet.path().bounds().unwrap()); @@ -616,11 +655,9 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() { g.appearance.effects.push(fx(id, params.clone())); let bounds = |art: &Node| { let mut out = vec![]; - art.walk(&mut |n| { - if let NodeKind::Group { clip: true, .. } = &n.kind { - out.push(drawn_face_bounds(n)); - } - }); + for child in art.children().into_iter().flatten() { + face_bounds_under(child, &mut out); + } out }; let (plain, scoped) = (solid_art(&plain).unwrap(), solid_art(&g).unwrap()); @@ -637,6 +674,89 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() { assert!(whole_misdrawn > 0, "whole surfaces show farther faces"); } +/// Under a colored light, the faces of an Inflate in gradients, in one order, lie over underlays +/// run by run: each run of consecutive faces of one surface (the fill's or the dashed stroke's) +/// over an underlay of its own faces, so no underlay reaches under the other surface's faces. +#[test] +fn a_colored_inflate_in_gradients_lies_over_underlays_of_each_surfaces_runs() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = |c: Color| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, c), GradientStop::new(1.0, Color::WHITE)], + )))) + }; + let mut n = stroked(&letter_c()); + for item in &mut n.appearance.items { + match item { + AppearanceItem::Fill(f) => f.paint = gradient(RED), + AppearanceItem::Stroke(s) => { + s.paint = gradient(BLUE); + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + } + } + } + let p = json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60}); + let cut = drawn_parts(&n, INFLATE, &p).len(); + let mut lit = p.clone(); + lit["lights"] = json!([{"color": "#ff8080"}]); + n.appearance.effects.push(fx(INFLATE, lit)); + // Which surface a face belongs to, by the first stop of its paint (a composed face's own paint, + // or a path face's shaded one): red for the fill's, blue for the stroke's. + fn surface_of(face: &Node) -> Option { + let item = match &face.kind { + NodeKind::Path { .. } => face.appearance.items.first()?, + _ => face.children()?.get(1)?.children()?.first()?.appearance.items.first()?, + }; + let Paint::Gradient(g) = item.paint() else { return None }; + let [r, _, b] = g.gradient.stops.first()?.color.to_rgb(); + Some(if r > b { RED } else { BLUE }) + } + // The faces in `order`, and the surface of each run over an underlay, looking into the groups + // that carry a surface's opacity mask. + fn runs(order: &[Arc], faces: &mut usize, surfaces: &mut Vec) { + for child in order { + let NodeKind::Group { children, clip } = &child.kind else { + assert!(surface_of(child).is_some(), "a face"); + *faces += 1; + continue; + }; + if !clip { + assert!(child.extras().mask.is_some(), "a masked run"); + runs(children, faces, surfaces); + continue; + } + // A composed face isolates its colors; a run's faces are not isolated together. + if children[1].isolate { + assert!(surface_of(child).is_some(), "a composed face"); + *faces += 1; + continue; + } + let stack = children[1].children().unwrap(); + let (under, own) = stack.split_at(stack.len() / 2); + assert_eq!(under.len(), own.len(), "one underlay face for each face"); + let colors: Vec<_> = own.iter().map(|f| surface_of(f).unwrap()).collect(); + assert!(colors.iter().all(|c| *c == colors[0]), "a run holds one surface's faces"); + surfaces.push(colors[0]); + *faces += own.len(); + } + } + let (mut faces, mut surfaces) = (0, vec![]); + runs(solid_art(&n).unwrap().children().unwrap()[0].children().unwrap(), &mut faces, &mut surfaces); + assert!(faces >= cut, "{faces} faces, {cut} under a white light, whose cells are not triangulated"); + assert!(surfaces.contains(&RED) && surfaces.contains(&BLUE), "{surfaces:?}"); + // With transparent stops in the fill's gradient, the fill's faces go in masked runs; the + // stroke's faces, without a mask, still lie over underlays of their own runs. + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) + && let Paint::Gradient(g) = &mut f.paint + { + g.gradient.stops.iter_mut().for_each(|s| s.opacity = 0.5); + } + let (mut faces, mut surfaces) = (0, vec![]); + runs(solid_art(&n).unwrap().children().unwrap()[0].children().unwrap(), &mut faces, &mut surfaces); + assert!(faces >= cut && surfaces.contains(&RED) && surfaces.contains(&BLUE), "{faces} faces, {surfaces:?}"); +} + /// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they /// overlap is cut along the line where their depths meet, into the piece in front of the wall and /// the piece behind it; a face on one side of the wall, another shape's stroke, a covering stroke @@ -724,7 +844,7 @@ fn cutting_stays_within_the_face_limit_and_the_paint_allowance() { let items = stroked(&letter_c()).appearance.items.clone(); let stroke = items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap(); let mut one = 0usize; - crate::revolve::paint_copies_add(&mut one, 6, paint_ref(stroke)); + crate::revolve::paint_copies_add(&mut one, MAX_FACET_COPIES, paint_ref(stroke)); let max = crate::revolve::MAX_PAINT_COPIES; assert_eq!(cut_room(items.iter(), 10, 0), (MAX_OBJECT_FACES - 10).min(max / one)); assert_eq!(cut_room(items.iter(), MAX_OBJECT_FACES + 5, 0), 0); @@ -1595,12 +1715,14 @@ fn a_colored_solid_face_on_its_own_keeps_the_composites_colors() { assert_eq!(one[0], paint, "the paint"); // The composite: its paint cover, then each channel's black backdrop and the face's ramp. let all_fills = fills(&batch); - let ramps: Vec<&Paint> = all_fills.iter().filter(|p| **p != Paint::solid(Color::BLACK)).skip(1).collect(); + let mut ramps: Vec<&Paint> = all_fills.iter().filter(|p| **p != Paint::solid(Color::BLACK)).skip(1).collect(); + // Each channel's ramp is drawn twice, in the underlay and in the facet itself. + ramps.dedup(); assert_eq!(all_fills.first(), Some(&paint)); assert_eq!(ramps, one.iter().skip(1).collect::>(), "the same light ramps"); // The single face's clip holds its seam: the clip reaches past the facet by half the seam. let seam = (face.thickness() / 2.0).clamp(0.05, 0.75); - let widths: Vec = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0).collect(); + let widths: Vec = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0 && *w != crate::lighting::UNDERLAY_SEAM).collect(); assert!(widths.len() == 3 && widths.iter().all(|w| (w - seam).abs() < 1e-12), "{widths:?}"); let NodeKind::Group { children, .. } = &single.kind else { panic!("a clip group") }; let (clip, facet) = (children[0].path_data().and_then(PathData::bounds).unwrap(), face.path().bounds().unwrap()); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 19695fd4ff..a2d0316913 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -892,6 +892,10 @@ pub use buildcmds::live_paint_gaps; /// Control bar enable it when it changes one). pub use brushsym::reset_transformation_changes; +/// The byte order of UTF-16 text without a byte-order mark, by Import Data Sets' rule (the Graph +/// Data window reads files by it too). +pub use variablecmds::utf16_order_without_mark; + /// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`. /// They run as before but the command palette leaves them out (it lists the command once). pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")]; diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index 3dd2e0e43b..b6b0669f12 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -518,16 +518,18 @@ fn sort(s: &mut Session, p: &Value) -> Result { } let wanted: Vec<(&Sorted, f64)> = rank.iter().filter_map(|i| items.get(*i)).zip(values).collect(); if apply == "opacity" { - if wanted.iter().all(|(o, v)| (o.opacity - v).abs() < 1e-6) { + // The opacity each object is given, as stored; an object whose stored opacity it already is has not changed. + let given: Vec<(NodeId, f32)> = wanted.iter().map(|(o, v)| (o.id, (v / 100.0) as f32)).collect(); + let changed: Vec = given.iter().filter(|(id, a)| d.node(*id).is_some_and(|n| n.opacity != *a)).map(|(id, _)| *id).collect(); + if changed.is_empty() { return Ok(count); } s.edit("Sort Objects", |d, _| { - for (o, v) in &wanted { - if let Some(n) = d.node_mut(o.id) { - n.opacity = (v / 100.0) as f32; + for (id, a) in &given { + if let Some(n) = d.node_mut(*id) { + n.opacity = *a; } } - let changed: Vec = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect(); keep_series_opacity(d, &changed); Ok(()) })?; diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 53949fdaf0..ce067ddc9c 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -11,7 +11,7 @@ pub fn specs() -> Vec { "Pencil", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", + "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end, and closes it when `closed` is true (left out, when the stroke ends within four Fidelities of the path's other end, 6 to 400 pt); with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", has_doc, freehand ), diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 0439aaa081..401bdd2977 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -217,11 +217,18 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { } None => None, }; - // An extending stroke that ends within four Fidelities of the path's other end closes it: - // at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at. + // `closed`, when given, says whether the stroke closes the path: the Pencil and the + // Paintbrush send it, by their Close Paths When Ends Are Within and Alt at the release. + // Left out, an extending stroke that ends within four Fidelities of the path's other end + // closes it: at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at. let close_tol = (tol * 4.0).clamp(6.0, 400.0); - let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol); - if closing && let Some(l) = pts.last_mut() { + let near_first = pts.last().is_some_and(|p| p.distance(first) <= close_tol); + let wants_close = if p.get("closed").is_some_and(|v| !v.is_null()) { closed } else { near_first }; + let closing = connect.is_none() && sp.anchors.len() >= 2 && wants_close; + // A stroke ending near the other end ends on it; one ending farther away closes with a + // straight segment back to it. + let ends_on_first = closing && near_first; + if ends_on_first && let Some(l) = pts.last_mut() { *l = first; } if let Some((_, _, y, _)) = &connect @@ -238,13 +245,23 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { add.remove(0); } sp.anchors.extend(add); - if closing + if ends_on_first && sp.anchors.len() > 2 && let Some(l) = sp.anchors.pop() && let Some(a0) = sp.anchors.first_mut() { a0.h_in = l.h_in; sp.closed = true; + } else if closing && !ends_on_first && sp.anchors.len() > 2 { + // Closed from away from the other end: a straight segment back to it, whatever + // handles the open ends carried. + sp.closed = true; + if let Some(l) = sp.anchors.last_mut() { + l.h_out = l.p; + } + if let Some(f) = sp.anchors.first_mut() { + f.h_in = f.p; + } } for a in &mut sp.anchors { *a = Anchor::with_handles(a.p, a.h_in, a.h_out); diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index eb3989597f..121cc0b279 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -352,20 +352,8 @@ fn svg_darkness_preserves_source_model_and_gradient_appearance() { #[test] fn svg_darkness_uses_rendered_shadow_in_non_srgb_working_space() { - const CHILD: &str = "VECTORCRAFT_TEST_SHADOW_RGB_PROFILE"; - if std::env::var_os(CHILD).is_none() { - // Color settings are process-wide: exercise this profile in a separate test process. - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space", "--nocapture"]) - .env(CHILD, "1") - .output() - .unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), - "isolated color profile test failed:\n{stdout}\n{}", - String::from_utf8_lossy(&output.stderr), - ); + // Color settings are process-wide: exercise this profile in a separate test process. + if !crate::tests::in_own_process("cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space") { return; } use vectorcraft_color::{Color, Paint, cms}; diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 0a788c4330..f56ec5a5dc 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -840,6 +840,24 @@ fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() { assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6)); } +/// A Sort Objects that gives series marks the opacities they already have changes nothing: the value the graph stores +/// stays and no undo step is made, even when the evenly stepped percentage differs from a mark's in the last digits. +#[test] +fn an_opacity_sort_objects_that_changes_no_mark_keeps_the_stored_opacity() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 65})).unwrap(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + // From left to right the columns have 30%, 65% and 100%, the values an even step from 30% to 100% gives them. + let undo = s.doc().unwrap().history.undo.len(); + let ids = [marks[0].0, marks[1].0, marks[2].0]; + s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap(); + let opacities: Vec = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect(); + assert_eq!(opacities, [0.3, 0.65, 1.0]); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); +} + /// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark /// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them /// when the graph is drawn again. diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 75d5852ea2..ac3c780c11 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -472,7 +472,7 @@ impl Session { pub fn transparency_info(&self) -> TransparencyInfo { match (self.active(), transparency_targets(self, &Value::Null)) { (Some(st), Ok(ids)) => { - let points = super::paint::picked_mesh_points_of(self, &ids); + let points = self.mesh_points_picked(); TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points) } _ => TransparencyInfo::default(), @@ -483,7 +483,8 @@ impl Session { fn info(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let ids = transparency_targets(s, p)?; - let points = super::paint::picked_mesh_points_of(s, &ids); + // The points `transparency.set` with the same `ids` (or none) fades. + let points = if p.get("ids").is_none() && p.get("id").is_none() { s.mesh_points_picked() } else { super::paint::picked_mesh_points_of(s, &ids) }; let i = TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points); Ok(json!({ "ids": i.ids.iter().map(|id| id.0).collect::>(), diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index e7b89ade16..4f822da5db 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, 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)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, 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)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", has_doc, transparency ), @@ -154,7 +154,7 @@ pub fn proxy_specs() -> Vec { "Fill and Stroke", [], None, - "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy}", + "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art; when that object is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso and no fill item is active, the fill is its first picked point's color), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy (a gradient mesh with points picked counts with their colors)}", always, proxies ), @@ -179,9 +179,13 @@ impl Session { /// The fill and stroke the Fill/Stroke proxies show: the Appearance panel's active item (in the /// first selected object's own stack) for the proxy of its kind, else the first selected /// object's (a group's first painted object's; type: its first run's), else the defaults for - /// new art. + /// new art. When the object whose fill the proxy shows is a gradient mesh with points picked + /// ([`Session::fill_mesh_points`]), the fill is its first picked point's color, which a solid + /// fill color changes. pub fn proxy_paints(&self) -> (Paint, Paint) { + let points = self.fill_mesh_points(); let show = |stroke: bool| match self.proxy_source(stroke) { + Some((n, None)) if !stroke && let Some(c) = picked_colors(&n, &points).first() => Paint::solid(*c), Some((n, item)) => proxy_paint(&n, stroke, item), None if stroke => self.paint.stroke.clone(), None => self.paint.fill.clone(), @@ -273,6 +277,13 @@ impl DocState { /// each selected object's own stack is compared. One walk of the document, so callers that ask /// every frame cache it by revision. pub fn proxy_mixed(&self, item: Option) -> (bool, bool) { + self.proxy_mixed_with(item, &[]) + } + + /// [`DocState::proxy_mixed`] with the gradient mesh points a solid fill color recolors + /// (`points`, see [`Session::fill_mesh_points`]): a mesh among them counts with the colors of + /// its picked points instead of its fill. + fn proxy_mixed_with(&self, item: Option, points: &[(NodeId, Vec)]) -> (bool, bool) { if self.selection.is_empty() { return (false, false); } @@ -291,7 +302,10 @@ impl DocState { let differ = |stroke: bool| { let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke)); let set = if item.is_some() { &selected } else { &nodes }; - let mut paints = set.iter().map(|n| proxy_paint(n, stroke, item)); + let mut paints = set.iter().flat_map(|n| match picked_colors(n, if stroke || item.is_some() { &[] } else { points }) { + c if c.is_empty() => vec![proxy_paint(n, stroke, item)], + c => c.into_iter().map(Paint::solid).collect(), + }); let Some(p0) = paints.next() else { return false }; paints.any(|p| !same_in_proxy(&p0, &p)) }; @@ -313,8 +327,9 @@ impl DocState { impl Session { /// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item. + /// A gradient mesh with points picked ([`Session::fill_mesh_points`]) counts with their colors. pub fn proxy_mixed(&self) -> (bool, bool) { - self.active().map_or((false, false), |st| st.proxy_mixed(self.appearance_item())) + self.active().map_or((false, false), |st| st.proxy_mixed_with(self.appearance_item(), &self.fill_mesh_points())) } } @@ -539,8 +554,8 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let selected = item.targets(s, p)?; let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint }; - // The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill - // recolours just those points of their meshes. + // The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso: a solid + // fill recolors just those points of their meshes (the proxies show them: `fill_mesh_points`). let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] }; // Without `item`, unless the Appearance panel's active fill item takes the paint. let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top { @@ -871,6 +886,55 @@ pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option)]) -> Vec { + match (&n.kind, points.iter().find(|(id, _)| *id == n.id)) { + (NodeKind::Mesh(m), Some((_, v))) => v.iter().filter_map(|i| m.points.get(*i)).map(|q| q.color).collect(), + _ => vec![], + } +} + +impl Session { + /// The picked gradient mesh points that a solid fill color (`paint.setFill` without `ids` or + /// `item`) recolors instead of their meshes' fills, each mesh with its points: the targets are + /// resolved as [`set_paint`] resolves them, so this is empty while the Appearance panel's + /// active item is a fill row or an object of a symbol instance is selected, and a mesh with + /// points picked counts even when other objects are selected with it. + pub fn fill_mesh_points(&self) -> Vec<(NodeId, Vec)> { + let Some(st) = self.active() else { return vec![] }; + // Only gradient meshes have points to pick: a selection with no mesh and no container that + // could hold one skips reading the tool's options. + let may_hold_mesh = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)) || n.is_container()); + if st.selection.symbol_part.is_some() || !st.selection.subjects().iter().any(may_hold_mesh) { + return vec![]; + } + let Ok(item) = item_target(self, &Value::Null, "paint.setFill").map(|t| t.of_kind(self, true)) else { return vec![] }; + if item != ItemTarget::Top { + return vec![]; + } + let Ok(selected) = item.targets(self, &Value::Null) else { return vec![] }; + picked_mesh_points(self, &super::graph::paint_targets(&st.doc, &selected)) + } + + /// The picked gradient mesh points that the Transparency panel's opacity (`transparency.set` + /// without `ids` or `item`) fades instead of their objects: `None` unless every target is a + /// gradient mesh with points picked, and while the Appearance panel's active item takes the + /// opacity (outside opacity mask editing). + pub fn mesh_points_picked(&self) -> Option)>> { + let st = self.active()?; + let ids = super::opacitymask::transparency_targets(self, &Value::Null).ok()?; + // Only gradient meshes have points to pick: other targets skip reading the tool's options. + if ids.is_empty() || !ids.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Mesh(_)))) { + return None; + } + if st.doc.mask_edit.is_none() && self.appearance_item().is_some() { + return None; + } + picked_mesh_points_of(self, &ids) + } +} + /// A type run's stroke; a painted stroke on a run without one gets 1 pt. pub(super) fn set_run_stroke(style: &mut CharStyle, paint: Paint) { if !paint.is_none() { @@ -1086,8 +1150,8 @@ fn transparency(s: &mut Session, p: &Value) -> Result { q.remove("item"); q.remove("id"); q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::>())); - // The gradient mesh points picked with the Mesh tool or Direct Selection take the opacity - // themselves, so only the mesh around them fades (#4206, #4170, #4208). + // The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso take the + // opacity themselves, so only the mesh around them fades (#4206, #4170, #4208). if matches!(item, ItemTarget::Top) && let Some(o) = q.get("opacity").and_then(Value::as_f64) && let Some(points) = picked_mesh_points_of(s, &ids) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index b497a9f096..2ad2b70049 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Reverse Path Direction", ["Object", "Path"], None, - "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}", + "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step; when every subpath of a path turns alike, its strokes' width profiles (sides swapped) and pen pressure, tilt, bearing and rotation turn with it, so they draw as before → {changed: paths}", has_selected_path, reverse ), @@ -462,22 +462,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result { let mut changed = 0; for id in &ids { let path = path_mut(d, *id)?; - if let Some(si) = subpath { - if let Some(sp) = path.subpaths.get_mut(si) { + let flip: Vec = match subpath { + Some(si) => (0..path.subpaths.len()).map(|i| i == si).collect(), + None => path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(), + }; + if !flip.contains(&true) { + continue; + } + // The stroke's width profile and pen recordings turn with the path, read before it turns. + if let Some(n) = d.node_mut(*id) { + vectorcraft_doc::blend::reverse_stroke_records(n, &flip); + } + let path = path_mut(d, *id)?; + for (sp, f) in path.subpaths.iter_mut().zip(flip) { + if f { sp.reverse(); - changed += 1; - } - } else { - let flip: Vec = path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(); - if flip.contains(&true) { - for (sp, f) in path.subpaths.iter_mut().zip(flip) { - if f { - sp.reverse(); - } - } - changed += 1; } } + changed += 1; } Ok(changed) })?; diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index a558545e4e..dc95f925b6 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), diff --git a/crates/engine/src/cmd/place/text.rs b/crates/engine/src/cmd/place/text.rs index 25f5f36dbd..7d8afc92d4 100644 --- a/crates/engine/src/cmd/place/text.rs +++ b/crates/engine/src/cmd/place/text.rs @@ -3,6 +3,7 @@ use serde_json::Value; +use super::super::utf16_order_without_mark; use super::{Point, Rect, Result, bad, bool_or, fileio, shapes, str_param}; /// Text files as `file.place` reports them (not a format `document.open` reads). @@ -76,8 +77,10 @@ pub(super) fn import(bytes: &[u8], o: TextOptions, cmd: &str) -> Result Ok(clean(&decode(bytes, o), o)) } -/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark; bytes that aren't UTF-8 are -/// read as the platform's 8-bit set), or the platform's 8-bit character set. +/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark, or without one when the +/// file has an even length, reads as valid UTF-16 with no NUL character in the byte order +/// [`utf16_order_without_mark`] gives; bytes that aren't UTF-8 are read as the platform's 8-bit +/// set), or the platform's 8-bit character set. fn decode(bytes: &[u8], o: TextOptions) -> String { let eight_bit = |b: &[u8]| -> String { b.iter().map(|&c| if o.mac { mac_roman(c) } else { windows_1252(c) }).collect() }; if o.ansi { @@ -91,7 +94,17 @@ fn decode(bytes: &[u8], o: TextOptions) -> String { [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), [0xFF, 0xFE, rest @ ..] => utf16(rest, true), [0xFE, 0xFF, rest @ ..] => utf16(rest, false), - _ => std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string), + _ => { + let without_mark = (bytes.len().is_multiple_of(2) && bytes.contains(&0)) + .then(|| utf16_order_without_mark(bytes)) + .flatten() + .and_then(|le| { + let units = bytes.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) }); + char::decode_utf16(units).collect::>().ok() + }) + .filter(|text| !text.contains('\0')); + without_mark.unwrap_or_else(|| std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string)) + } } } diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 9acd31e664..5f8d6605b3 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -26,6 +26,7 @@ use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids mod csv; mod library; +pub use csv::utf16_order_without_mark; use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import}; pub(crate) use library::{give_svg_library, svg_library}; use library::{load_library, save_library}; diff --git a/crates/engine/src/cmd/variablecmds/csv.rs b/crates/engine/src/cmd/variablecmds/csv.rs index 38dd42d922..be9db3b841 100644 --- a/crates/engine/src/cmd/variablecmds/csv.rs +++ b/crates/engine/src/cmd/variablecmds/csv.rs @@ -59,25 +59,35 @@ fn utf16(bytes: &[u8], le: bool) -> Option { char::decode_utf16(units).collect::>().ok().filter(|_| odd.is_empty()) } -/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16, or `None`. At least half of -/// the two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with -/// fewer, such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is -/// the one that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or -/// U+0D00, so a file that reads one in both orders is refused. A file that reads none in either -/// order has no data rows, and is read little-endian so that the import reports that. The text -/// must be valid UTF-16 in that order and hold no NUL character. -fn utf16_without_mark(bytes: &[u8]) -> Option { - let (units, odd) = bytes.as_chunks::<2>(); - if !odd.is_empty() || units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() { +/// The byte order of `bytes`, which hold NUL bytes and no byte-order mark, read as UTF-16: +/// `Some(true)` for little-endian, or `None` when they don't read as UTF-16. At least half of the +/// two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with fewer, +/// such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is the one +/// that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or U+0D00, +/// so a file that reads one in both orders is refused. A file that reads none in either order (one +/// line) is big-endian when more of its units start with a NUL byte than end with one, as +/// characters from U+0001 to U+00FF do in that order, and little-endian otherwise. An odd last +/// byte is left out of the count. +pub fn utf16_order_without_mark(bytes: &[u8]) -> Option { + let units = bytes.as_chunks::<2>().0; + if units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() { return None; } let is_line_break = |u: u16| matches!(u, 0x0A | 0x0D); let lines_le = units.iter().any(|&c| is_line_break(u16::from_le_bytes(c))); let lines_be = units.iter().any(|&c| is_line_break(u16::from_be_bytes(c))); - if lines_le && lines_be { - return None; + match (lines_le, lines_be) { + (true, true) => None, + (false, false) => Some(units.iter().filter(|c| c[0] == 0).count() <= units.iter().filter(|c| c[1] == 0).count()), + (_, lines_be) => Some(!lines_be), } - utf16(bytes, !lines_be).filter(|text| !text.contains('\0')) +} + +/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16 in the byte order +/// [`utf16_order_without_mark`] gives, or `None`. The file must have an even length, and the text +/// must be valid UTF-16 in that order and hold no NUL character. +fn utf16_without_mark(bytes: &[u8]) -> Option { + utf16(bytes, utf16_order_without_mark(bytes)?).filter(|text| !text.contains('\0')) } /// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2f849901a4..57445a36be 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -241,6 +241,25 @@ pub(crate) fn key_guide(s: &Session) -> Option { st.selection.key_guide.and_then(|k| crate::cmd::guide_indexes(&st.doc, &[k]).first().copied()) } +/// Runs the unit test `path` (`tests::newdoc::...`) again in a child process of the test binary +/// → whether this is that child. For a test that changes process-wide state the tests running +/// beside it read, such as the active color settings. +pub(crate) fn in_own_process(path: &str) -> bool { + const CHILD: &str = "VECTORCRAFT_TEST_OWN_PROCESS"; + if std::env::var_os(CHILD).is_some() { + return true; + } + let output = + std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact", path, "--nocapture"]).env(CHILD, "1").output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "{path} failed in its own process:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + false +} + #[test] fn create_and_undo_redo() { let mut s = session(); diff --git a/crates/engine/src/tests/attributes.rs b/crates/engine/src/tests/attributes.rs index d3edf2615b..8ddfc352a1 100644 --- a/crates/engine/src/tests/attributes.rs +++ b/crates/engine/src/tests/attributes.rs @@ -163,6 +163,61 @@ fn reverse_path_direction_on_and_off_set_the_winding() { assert!(s.execute("path.reverse", &json!({})).is_err(), "nothing to reverse"); } +#[test] +fn reverse_path_direction_turns_the_width_profile_and_pen_records_with_the_path() { + use vectorcraft_doc::{AngleProfile, PressureProfile, WidthProfile}; + let mut s = session(); + let open = + NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 0, "y": 50}, {"x": 40, "y": 50}, {"x": 100, "y": 50}]}))["id"].as_u64().unwrap()); + let closed = rect(&mut s, 10.0); + let profile = WidthProfile { points: vec![(0.0, 0.2, 0.6), (0.3, 1.0, 0.4), (1.0, 0.5, 0.9)] }; + let pressure = PressureProfile { points: vec![(0.0, 0.1), (0.25, 0.9), (1.0, 0.4)] }; + let bearing = AngleProfile { points: vec![(0.0, 10.0), (0.6, 80.0), (1.0, 170.0)] }; + s.edit("set", |d, _| { + for id in [open, closed] { + let st = d.node_mut(id).unwrap().appearance.stroke_mut().unwrap(); + st.profile = Some(profile.clone()); + st.pressure = Some(pressure.clone()); + st.tilt = Some(pressure.clone()); + st.bearing = Some(bearing.clone()); + st.rotation = Some(bearing.clone()); + } + Ok(()) + }) + .unwrap(); + // (anchor point, its fraction of the path's length) for each anchor. + let along = |s: &Session, id: NodeId| { + let sp = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].clone(); + let pos = sp.anchor_arc_positions(); + let total = *pos.last().unwrap(); + sp.anchors.iter().zip(pos).map(|(a, at)| (a.p, at / total)).collect::>() + }; + let before = [along(&s, open), along(&s, closed)]; + run(&mut s, "select.set", json!({"ids": [open.0, closed.0]})); + assert_eq!(run(&mut s, "path.reverse", json!({}))["changed"], 2); + for ((id, before), closed) in [open, closed].into_iter().zip(before).zip([false, true]) { + let after = along(&s, id); + let st = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone(); + // Each anchor keeps the widths it had (its sides swapped, as the path runs the other way) + // and what the pen recorded there. (A closed path's start is where its profile steps.) A + // closed path now starts part way along a span of the width profile, which is resampled + // there, so its smooth curve moves a little. + let near = if closed { 0.02 } else { 1e-6 }; + for (p, t) in before.iter().filter(|(_, t)| !closed || *t > 0.0) { + let u = after.iter().find(|(q, _)| q.distance(*p) < 1e-9).unwrap().1; + let (l, r) = profile.at(*t); + let (l2, r2) = st.profile.as_ref().unwrap().at(u); + assert!((l - r2).abs() < near && (r - l2).abs() < near, "{id:?} at {p:?}: {:?} then {:?}", (l, r), (l2, r2)); + for got in [&st.pressure, &st.tilt] { + assert!((pressure.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} pressure at {p:?}"); + } + for got in [&st.bearing, &st.rotation] { + assert!((bearing.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} bearing at {p:?}"); + } + } + } +} + #[test] fn attributes_survive_a_native_round_trip() { let mut s = session(); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index e0d68ee949..01eb7f7132 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3878,6 +3878,37 @@ fn live_paint_bucket_alt_shift_gradient_samples_active_stroke_only() { assert!(matches!(node(&s, src).appearance.fill_paint(), Paint::Gradient(_))); } +#[test] +fn live_paint_selection_double_click_selects_the_faces_no_stroked_edge_parts() { + let mut s = session(); + let g = live(&mut s); + // The edge between the middle and right faces draws no stroke. + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100.0, 50.0], "none": true})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("livePaintSelection", v).unwrap(); + let gn = node(&s, g); + let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().id; + let before = s.doc().unwrap().doc.clone(); + let double_click = |s: &mut Session, x: f64, m: Mods| { + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { + s.pointer(&PointerEvent::new(kind, x, 50.0).with_mods(m), v).unwrap(); + } + let mut ids = s.doc().unwrap().selection.objects.clone(); + ids.sort(); + ids + }; + let sorted = |mut ids: Vec| { + ids.sort(); + ids + }; + assert_eq!(double_click(&mut s, 125.0, Mods::default()), sorted(vec![face(75.0), face(125.0)])); + // The stroked edge at x = 50 parts the left face from the others. + assert_eq!(double_click(&mut s, 25.0, Mods::default()), vec![face(25.0)]); + // Shift-double-click adds the faces reached to the selection. + assert_eq!(double_click(&mut s, 75.0, Mods { shift: true, ..Default::default() }), sorted(vec![face(25.0), face(75.0), face(125.0)])); + assert_eq!(s.doc().unwrap().doc, before, "selecting changes no paint"); +} + #[test] fn live_paint_selection_marquee_selects_what_it_touches() { let mut s = session(); diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index 99d099f68d..2d024f552b 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -16,7 +16,10 @@ use crate::*; /// Every test here reads or changes the process-wide proof view or colour settings: one at a time. /// Tests that switch the Color Settings away from the defaults live in `tests/color_settings.rs` /// (their own process): every colour shown or rendered goes through them, so no lock could cover -/// all the tests that would see the change. +/// all the tests that would see the change. A unit test that assigns its front document a profile +/// other than the working space changes the active settings the same way until another document +/// comes to the front: it starts with `crate::tests::in_own_process`, which runs it again in a +/// child process. pub(crate) static GLOBAL: Mutex<()> = Mutex::new(()); fn session() -> Session { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index d47ca0ccaf..9eeb5813bc 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -5269,6 +5269,47 @@ fn pencil_extend_closes_within_400_pt_at_any_fidelity() { } } +/// `closed`, when given, decides whether a stroke continuing an open path closes it (the tools +/// send it by Close Paths When Ends Are Within): false leaves it open even ending on the other end; +/// true closes one ending 50 pt away with a straight segment back, the stroke's end kept, and one +/// ending 2 pt away on the other end itself. +#[test] +fn pencil_extend_closes_as_closed_says() { + let mut s = session(); + for (end, closed, ends_on_start) in [([0i32, 0], false, false), ([0, 50], true, false), ([0, 2], true, true)] { + let l = line(&mut s, 0.0, 0.0, 200.0, 0.0); + let points = json!([[200, 0], [200, 100], [100, 150], end]); + let p = json!({"points": points, "fidelity": 1.5, "closed": closed, "extend": {"id": l.0}, "keepSelected": false}); + s.execute("path.freehand", &p).unwrap(); + let pd = path(&s, l); + let sp = &pd.subpaths[0]; + assert_eq!(sp.closed, closed, "ending at {end:?}"); + if closed { + let stroke_end = Point::new(f64::from(end[0]), f64::from(end[1])); + let kept = sp.anchors.iter().any(|a| a.p.distance(stroke_end) < 1e-6); + assert_eq!(kept, !ends_on_start, "ending at {end:?}: {:?}", sp.anchors); + let on_start = sp.anchors.iter().filter(|a| a.p.distance(Point::new(0.0, 0.0)) < 1e-6).count(); + assert_eq!((sp.anchors[0].p, on_start), (Point::new(0.0, 0.0), 1), "ending at {end:?}"); + } + } +} + +/// A continued path closed from away from its other end closes with a straight segment, even when +/// that end carries a handle the open path didn't use. +#[test] +fn pencil_extend_closes_far_away_with_a_straight_segment() { + let mut s = session(); + let anchors = json!([{"x": 0, "y": 0, "in": [-40, 30]}, {"x": 200, "y": 0}]); + let l = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap()); + let points = json!([[200, 0], [200, 100], [100, 150], [0, 50]]); + let p = json!({"points": points, "fidelity": 1.5, "closed": true, "extend": {"id": l.0}, "keepSelected": false}); + s.execute("path.freehand", &p).unwrap(); + let sp = path(&s, l).subpaths[0].clone(); + assert!(sp.closed); + let (first, last) = (sp.anchors[0], sp.anchors[sp.anchors.len() - 1]); + assert_eq!((first.h_in, last.h_out), (first.p, last.p), "the closing segment has no handles: {:?}", sp.anchors); +} + /// At Fidelity 0.01 short, one-point and repeated-point strokes still draw or are refused, never /// crash, and what they draw has finite anchors. #[test] diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 209d22a42a..094f634799 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -683,6 +683,9 @@ fn the_new_document_links_from_the_profile_file() { /// is a warning. #[test] fn a_profile_brings_the_color_profile_its_file_carries() { + if !super::in_own_process("tests::newdoc::a_profile_brings_the_color_profile_its_file_carries") { + return; + } use vectorcraft_color::cms; let (mut s, dir) = with_profiles("icc"); let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap(); diff --git a/crates/engine/src/tests/rasterfilters.rs b/crates/engine/src/tests/rasterfilters.rs index 236580d5b7..534f79b1bc 100644 --- a/crates/engine/src/tests/rasterfilters.rs +++ b/crates/engine/src/tests/rasterfilters.rs @@ -5,6 +5,7 @@ //! as images of the effected object. use serde_json::{Value, json}; +use vectorcraft_color::cms; use vectorcraft_doc::NodeKind; use vectorcraft_geom::Rect; use vectorcraft_render::{Rendered, Renderer}; @@ -173,16 +174,22 @@ fn each_effect_changes_the_canvas_and_draws_the_same_twice() { EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(SKETCH).chain(TEXTURE).chain(VIDEO) { let (mut s, g) = striped(); + // Every color drawn goes through the process-wide color settings. A test that changes them + // runs in a process of its own (`crate::tests::in_own_process`). + let colors = cms::active(); + assert_eq!(cms::active_settings(), cms::ColorSettings::default(), "{id}: another test left the color settings changed"); let plain = render(&s); let params: Value = serde_json::from_str(params).unwrap(); s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap(); let a = render(&s); - assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a)); - assert_eq!(a.pixels, render(&s).pixels, "{id} is deterministic"); + let again = render(&s); // The multithreaded pipeline draws the same. let mut mt = Renderer::new(); mt.threads = 4; let b = mt.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + assert!(std::sync::Arc::ptr_eq(&colors, &cms::active()), "{id}: another test changed the color settings while this one drew"); + assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a)); + assert_eq!(a.pixels, again.pixels, "{id} is deterministic"); assert!(mean_diff(&a, &b) < 0.002, "{id}: multithreaded differs by {}", mean_diff(&a, &b)); } } diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index a5b19563a9..0f7205eaae 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -112,6 +112,9 @@ fn a_linked_pngs_bytes_are_embedded() { #[test] fn an_embedded_profile_is_restored() { + if !super::in_own_process("tests::saveoptions::an_embedded_profile_is_restored") { + return; + } let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap(); let icc = cms::icc_bytes(&builtin.name).unwrap(); let name = format!("Studio RGB {}", std::process::id()); diff --git a/crates/engine/src/tests/textimport.rs b/crates/engine/src/tests/textimport.rs index 0749964ee6..b369346096 100644 --- a/crates/engine/src/tests/textimport.rs +++ b/crates/engine/src/tests/textimport.rs @@ -68,6 +68,49 @@ fn type_placed_on_an_artboard_edge_takes_that_artboards_frame() { } } +/// `text` as UTF-16 without a byte-order mark, little-endian when `le` is set. +fn utf16(text: &str, le: bool) -> Vec { + text.encode_utf16().flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }).collect() +} + +/// Unicode text in UTF-16 without a byte-order mark is read as UTF-16 when at least half of its +/// two-byte units hold a NUL byte, in the byte order that reads a line break, or for one line, the +/// order its NUL bytes give. Other bytes keep today's reading. +#[test] +fn utf16_without_a_byte_order_mark_places_by_its_nul_bytes_and_line_breaks() { + let mut s = session(); + for le in [true, false] { + let r = place_text(&mut s, &utf16("Hello, 世界 café!\r\nSecond line", le), json!({})); + assert_eq!(placed(&s, &r).0, "Hello, 世界 café!\nSecond line", "little-endian: {le}"); + let r = place_text(&mut s, &utf16("One line, 世界", le), json!({})); + assert_eq!(placed(&s, &r).0, "One line, 世界", "little-endian: {le}"); + } + // One line with NUL bytes in both places (U+4E00 ends in a zero byte) takes the order more of + // its units give, and a tie is little-endian. + let r = place_text(&mut s, &utf16("一AB", false), json!({})); + assert_eq!(placed(&s, &r).0, "一AB"); + let r = place_text(&mut s, &utf16("一A", true), json!({})); + assert_eq!(placed(&s, &r).0, "一A"); + // Not read as UTF-16, so read as UTF-8 or else Windows-1252 with NUL bytes dropped: an odd + // length, an unpaired surrogate, a NUL character, and line breaks in both byte orders (U+0A00). + let r = place_text(&mut s, b"A\0B", json!({})); + assert_eq!(placed(&s, &r).0, "AB"); + let r = place_text(&mut s, b"\0a\0b\xd8\0", json!({})); + assert_eq!(placed(&s, &r).0, "abØ"); + let r = place_text(&mut s, b"\0\0\xc3\xa9", json!({})); + assert_eq!(placed(&s, &r).0, "é"); + let r = place_text(&mut s, &utf16("A\u{0A00}\nB", true), json!({})); + assert_eq!(placed(&s, &r).0, "A\n\nB"); + // Fewer than half of the two-byte units hold a NUL byte: UTF-8, and Windows-1252. + let r = place_text(&mut s, "Fünf\0 Zeilen!".as_bytes(), json!({})); + assert_eq!(placed(&s, &r).0, "Fünf Zeilen!"); + let r = place_text(&mut s, b"caf\xe9\0x", json!({})); + assert_eq!(placed(&s, &r).0, "caféx"); + // ANSI reads each byte as a character of the 8-bit set; NUL and other control bytes are dropped. + let r = place_text(&mut s, &utf16("Hi, 世界", true), json!({"text": {"characterSet": "ansi"}})); + assert_eq!(placed(&s, &r).0, "Hi, NLu"); +} + #[test] fn text_files_report_and_queue_with_their_options() { let mut s = session(); diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index eea8ae4986..6256088842 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -2201,6 +2201,16 @@ fn datasets_csv_reads_utf16_files_without_a_byte_order_mark() { let e = import(&mut s, &bytes).unwrap_err().to_string(); assert!(e.contains("CSV has a header but no data rows"), "{e}"); } + // With no line break, the side of each unit that holds the NUL byte gives the byte order, and a + // tie is little-endian. + let order = crate::cmd::utf16_order_without_mark; + assert_eq!(order(&utf16("City", false)), Some(false)); + assert_eq!(order(&utf16("City", true)), Some(true)); + assert_eq!(order(b"\0aa\0"), Some(true)); + assert_eq!(order(&[utf16("City\n", false), vec![0x0A]].concat()), Some(false), "an odd last byte is left out"); + // Read big-endian, this one-line file ends in an unpaired surrogate. + let e = import(&mut s, b"\0a\0b\xd8\0").unwrap_err().to_string(); + assert!(e.contains("CSV holds NUL bytes"), "{e}"); import(&mut s, &utf16("City\tName\n一二三丁目\tKai\n", true)).unwrap(); assert_eq!(value(&s, "Data Set 1"), text_value("一二三丁目")); // Most units hold their NUL byte where a big-endian file would (U+4E00); the line breaks diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index f507b765c6..1a41488285 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -1115,6 +1115,92 @@ fn lasso_picks_gradient_mesh_points() { assert_eq!(red, top, "only the points inside the loop are recoloured"); } +/// With gradient mesh points picked, the Fill proxy (and the Color panel that edits it) shows +/// the color a solid fill gives them, not the mesh object's own fill: "?" when the picked +/// points' colors differ, the object's fill again once no point is picked. +#[test] +fn the_fill_proxy_shows_the_picked_mesh_points_color() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + let object_fill = s.proxy_paints().0; + // Round the top row of points. + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + let shown = s.execute("paint.proxies", &json!({})).unwrap(); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into()), "the picked points' color: {shown}"); + assert_eq!(shown["fillMixed"], json!(false)); + assert_eq!(shown["fill"], json!(s.proxy_paints().0)); + // One picked point takes another color: the points differ. + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + let first = m.idx(0, 0); + s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": first, "color": "#00ff00"})).unwrap(); + assert!(s.proxy_mixed().0, "the picked points' colors differ"); + // Picking none (the Selection tool) shows the object's fill again. + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(s.proxy_paints().0, object_fill); + assert!(!s.proxy_mixed().0); +} + +/// With the Appearance panel's stroke row active, a solid fill still recolors the picked mesh +/// points, so the Fill proxy shows them; with its fill row active, the fill goes to that row, and +/// the proxy shows the row. +#[test] +fn the_fill_proxy_follows_where_the_fill_goes_with_an_appearance_row_active() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + let items = &s.doc().unwrap().doc.node(a).unwrap().appearance.items; + let fill_row = items.iter().position(|it| it.is_fill()).expect("a fill row"); + let stroke_row = items.iter().position(|it| !it.is_fill()).expect("a stroke row"); + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + assert_eq!(s.fill_mesh_points().first().map(|(_, v)| v.len()), Some(3), "the top row is picked"); + let point_color = |s: &Session| { + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + m.points[m.idx(0, 0)].color.to_hex() + }; + + s.execute("appearance.setActiveItem", &json!({"index": stroke_row})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + assert_eq!(point_color(&s), "#3366ff", "the stroke row leaves the fill on the picked points"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into())); + assert!(!s.proxy_mixed().0); + + s.execute("appearance.setActiveItem", &json!({"index": fill_row})).unwrap(); + assert!(s.fill_mesh_points().is_empty()); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(point_color(&s), "#3366ff", "the fill row takes the fill"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#00ff00".into())); +} + +/// A gradient mesh with points picked selected with another object: a solid fill recolors the +/// points and fills the other object, and the Fill proxy and its "?" count the points' colors. +#[test] +fn the_fill_proxy_counts_picked_mesh_points_beside_other_objects() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 300.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + s.execute("select.add", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); + assert_eq!(s.fill_mesh_points().first().map(|(id, v)| (*id, v.len())), Some((a, 3)), "the top row is still picked"); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + assert_eq!(m.points[m.idx(0, 0)].color.to_hex(), "#3366ff"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into())); + assert!(!s.proxy_mixed().0, "the points and the other object are the same color"); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); + assert!(s.proxy_mixed().0, "the picked points and the other object's fill differ"); +} + /// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are /// deselected and the others selected, the rest of the selection staying as it was. #[test] diff --git a/crates/engine/tests/it/dimensions.rs b/crates/engine/tests/it/dimensions.rs index 240a7fe280..c2d47e9d5e 100644 --- a/crates/engine/tests/it/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -1771,8 +1771,9 @@ fn both_points_snap_onto_anchors_with_a_visible_indicator() { } /// Smart Guides for the Linear picks: hovering away from any line, the first point slides into -/// line with another object's corner and its guides show before the click; the second slides onto -/// the construction guide through the first. The dimension measures between the snapped points. +/// line with another object's corner and its guides show before the click; the second slides into +/// line with the other corner, with no construction guide through the first point pulling it +/// sideways. The dimension measures between the snapped points. #[test] fn linear_picks_show_and_snap_to_smart_guides() { use vectorcraft_engine::tools::Overlay; @@ -1788,18 +1789,20 @@ fn linear_picks_show_and_snap_to_smart_guides() { assert!(o.iter().any(|o| matches!(o, Overlay::Line { .. } | Overlay::Label { .. })), "its guides show before the click: {o:?}"); ev(&mut s, PointerKind::Down, 250.0, 202.5); ev(&mut s, PointerKind::Up, 250.0, 202.5); - // Straight below the first point, 1.5 pt off: the rubber band ends on the vertical through it. - ev(&mut s, PointerKind::Move, 251.5, 260.0); + // Below the first point, 1.5 pt to its right and 2.5 pt off the bottom edge's level: the + // rubber band ends on that level, where the pointer is across it. + ev(&mut s, PointerKind::Move, 251.5, 262.5); let end = overlays(&mut s).into_iter().find_map(|o| if let Overlay::Line { b, dashed: true, .. } = o { Some(b) } else { None }).unwrap(); - assert_eq!((end.x, end.y), (250.0, 260.0), "on the construction guide"); - ev(&mut s, PointerKind::Down, 251.5, 260.0); - ev(&mut s, PointerKind::Up, 251.5, 260.0); + assert_eq!((end.x, end.y), (251.5, 260.0), "in line with the bottom edge, not on a vertical through the first point"); + ev(&mut s, PointerKind::Down, 251.5, 262.5); + ev(&mut s, PointerKind::Up, 251.5, 262.5); ev(&mut s, PointerKind::Move, 220.0, 230.0); assert!(!overlays(&mut s).iter().any(|o| matches!(o, Overlay::Label { .. })), "placing: the guides are gone"); ev(&mut s, PointerKind::Down, 220.0, 230.0); ev(&mut s, PointerKind::Up, 220.0, 230.0); let id = s.doc().unwrap().selection.objects[0].0; - assert_eq!(text(&s, id), "60.00 pt"); + // Aligned from (250, 200) to (251.5, 260): √(1.5² + 60²). + assert_eq!(text(&s, id), "60.02 pt"); } /// The page content streams of exported PDF `v`, inflated. diff --git a/crates/engine/tests/it/three_d_light_color.rs b/crates/engine/tests/it/three_d_light_color.rs index 38013a372b..381bc92f15 100644 --- a/crates/engine/tests/it/three_d_light_color.rs +++ b/crates/engine/tests/it/three_d_light_color.rs @@ -481,6 +481,151 @@ fn solid_gradient_opacity_is_applied_once_even_without_shading() { } } +/// How `image`, `d` drawn at 1:1 on 400 × 400 pixels, differs from `d` drawn at 3x and averaged +/// down, on the pixels inside the art: those whose 3x average has some alpha, the same as every +/// pixel within 3 of them. Left out too are pixels where the 3x average bends sharply (a channel's +/// second difference across the pixel, along x or y, over 4 levels), such as the rim where a cap +/// meets a body: a gradient sampled at each pixel's center matches an area average only where the +/// light changes close to linearly across the pixel. Returns (pixels measured, premultiplied RGB +/// channels more than 3 levels off, the largest RGB difference, alphas off: below 255 where the 3x +/// average is opaque, otherwise more than 3 levels off, the first few pixels off). +fn seams_against_supersampled(d: &Document, image: &vectorcraft_render::Rendered) -> (usize, usize, u8, usize, Vec) { + let fine = vectorcraft_render::Renderer::new().render( + d, + 1200, + 1200, + kurbo::Affine::scale(3.0), + &vectorcraft_render::RenderOptions { background: None, artboards: false, ..Default::default() }, + ); + let average = fine.area_resized(400, 400).unwrap(); + let (mut measured, mut color, mut max, mut holes) = (0, 0, 0, 0); + let mut worst = vec![]; + for y in 3..397 { + for x in 3..397 { + let q = average.pixel(x, y); + if q[3] == 0 || !(y - 3..=y + 3).all(|y| (x - 3..=x + 3).all(|x| average.pixel(x, y)[3] == q[3])) { + continue; + } + let bend = |a: [u8; 4], b: [u8; 4]| (0..3).any(|c| (i32::from(a[c]) + i32::from(b[c]) - 2 * i32::from(q[c])).abs() > 4); + if bend(average.pixel(x - 1, y), average.pixel(x + 1, y)) || bend(average.pixel(x, y - 1), average.pixel(x, y + 1)) { + continue; + } + measured += 1; + let p = image.pixel(x, y); + for c in 0..3 { + let diff = p[c].abs_diff(q[c]); + max = max.max(diff); + color += usize::from(diff > 3); + } + let hole = if q[3] == 255 { p[3] < 255 } else { p[3].abs_diff(q[3]) > 3 }; + holes += usize::from(hole); + if (p.into_iter().zip(q).any(|(a, b)| a.abs_diff(b) > 3) || hole) && worst.len() < 4 { + worst.push(format!("({x},{y}) {p:?} vs {q:?}")); + } + } + } + (measured, color, max, holes, worst) +} + +/// Colored lights on smooth 3D surfaces, compared at 1:1 with a 3x render averaged down, on every +/// pixel inside the art ([`seams_against_supersampled`]). A solid fill draws as one batched surface; +/// a source gradient composes each facet on its own, and one with transparent stops applies their +/// opacity once over the surface. Inflate seen straight on, one layer of facets, has no alpha off, and +/// in opaque fills no RGB channel more than 3 levels off. Inflate turned so facets narrow to slivers +/// at the edges, and Revolve with a cap over its body, draw facets over other facets, and have no +/// alpha off. +#[test] +fn colored_light_surfaces_match_supersampling_at_internal_facet_edges() { + let lights = json!([ + {"color":"#ff0000","rotation":135,"height":35,"intensity":70}, + {"color":"#0000ff","rotation":-35,"height":25,"intensity":65} + ]); + let fills = |start: [u32; 2], end: [u32; 2]| { + [ + json!({"color":"#ffcc66"}), + json!({"gradient":{"kind":"linear","start":start,"end":end,"stops":[ + {"offset":0,"color":"#ffffff"},{"offset":1,"color":"#808080"} + ]}}), + json!({"gradient":{"kind":"linear","start":start,"end":end,"stops":[ + {"offset":0,"color":"#ffffff","opacity":0.6},{"offset":1,"color":"#808080","opacity":0.6} + ]}}), + ] + }; + let mut cases = vec![]; + for rotation in [(0, 0), (50, 35)] { + let (mut s, _) = setup("threeD.inflate"); + let change = json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights,"rotationX":rotation.0,"rotationY":rotation.1}}); + s.execute("effect.setParams", &change).unwrap(); + cases.push((format!("inflate {rotation:?}"), s, fills([100, 150], [200, 150]), rotation == (0, 0))); + } + let (mut s, _) = capped_profile(); + s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights}})).unwrap(); + cases.push(("revolve".into(), s, fills([80, 200], [240, 200]), false)); + let mut failures = vec![]; + for (name, mut s, fills, one_layer) in cases { + for fill in fills { + s.execute("paint.setFill", &fill).unwrap(); + let image = render(doc(&s)); + let (measured, color, max, holes, worst) = seams_against_supersampled(doc(&s), &image); + assert!(measured > 2_000, "{name} {fill}: {measured} pixels inside"); + let opaque = !fill.to_string().contains("opacity"); + if holes > 0 || (one_layer && opaque && color > 0) { + failures.push(format!( + "{name} {fill}: of {measured} pixels, {color} channels off by more than 3 (at most {max}), {holes} alphas off: {worst:?}" + )); + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +/// The closed facet edges of colored-light surfaces survive SVG and PDF, read back and drawn at +/// 1:1, and Expand Appearance: inside the art, every pixel stays opaque and within 6 levels of the +/// live art. +#[test] +fn colored_light_seams_stay_closed_in_svg_pdf_and_expansion() { + let lights = json!([ + {"color":"#ff0000","rotation":135,"height":35,"intensity":70}, + {"color":"#0000ff","rotation":-35,"height":25,"intensity":65} + ]); + for fill in [ + json!({"color":"#ffcc66"}), + json!({"gradient":{"kind":"linear","start":[100,150],"end":[200,150],"stops":[ + {"offset":0,"color":"#ffffff"},{"offset":1,"color":"#808080"} + ]}}), + ] { + let (mut s, _) = setup("threeD.inflate"); + s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights}})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + let live = render(doc(&s)); + let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned(); + assert!(svg.contains(" bool { shape(t).as_ref().and_then(segments).is_some_and(|s| !(open && closed(&s))) } }; - // Area type that flows around wrap objects: the data doesn't hold their wrap settings. + // Area type with wrap shapes: the text document doesn't hold them. The reader works them out + // again from the art above the type, which can wrap it differently (a live object written as + // its drawn pieces) or not at all. shape_ok && t.wrap.is_empty() && t.ruby.is_empty() @@ -962,6 +964,7 @@ impl TextDoc { matches!(t.kind, TextKind::Area { .. }) && t.vertical == vertical && writable(n.id) + // The text document doesn't hold wrap shapes (see `holds`). && t.wrap.is_empty() && plain(n) && t.ruby.is_empty() diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index bdb3b9cf50..c791f7c459 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -94,7 +94,7 @@ pub(crate) use ai::{ }; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; -pub(crate) use native::{MAX_TEXT, scale_document}; +pub(crate) use native::{MAX_TEXT, scale_document, scale_symbols}; pub use text::family_style; pub(crate) use text::{document_font, postscript_face}; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 5ef013ecb5..0cf1c64a89 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -215,6 +215,9 @@ pub struct Structure { pub text_patterns: HashMap, /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. pub canvas_scale: f64, + /// The data's symbols are at their own size, whatever its scale: VectorCraft wrote it (its + /// `%%Creator` is [`crate::ai_editing::CREATOR_TEXT`]). + pub symbols_own_size: bool, /// The objects by their XML id (`AI10_ArtUID`), a text object by its slot's id: `None` for an /// id two objects have. pub xml_ids: HashMap>, @@ -648,6 +651,8 @@ struct Reader<'a> { page_art: bool, /// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not. canvas_scale: f64, + /// [`Structure::symbols_own_size`], from the header's `%%Creator`. + symbols_own_size: bool, /// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels). ruler_units: Option, /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). @@ -823,6 +828,7 @@ impl<'a> Reader<'a> { large_canvas: None, page_art: false, canvas_scale: 1.0, + symbols_own_size: false, ruler_units: None, pattern: None, pattern_tiles: HashMap::new(), diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index f86ee6603b..0fe6b9da87 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -79,6 +79,10 @@ impl<'a> Reader<'a> { } return Ok(()); } + if let Some(v) = bytes.strip_prefix(b"%Creator:") { + self.symbols_own_size = v.trim_ascii() == crate::ai_editing::CREATOR_TEXT.as_bytes(); + return Ok(()); + } if bytes == b"%EndComments" { self.header_done = true; } diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ecae1e8954..ba6a8bd2de 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -467,6 +467,10 @@ pub struct Symbols { /// The global and spot colors the symbols' art is linked to, as swatches, as [`read`] adds /// them to a document read from the layers. pub swatches: Vec, + /// The data's declared large canvas scale (1 when it declares none), not applied to the art. + pub canvas_scale: f64, + /// [`Structure::symbols_own_size`]. + pub own_size: bool, } /// The symbols of the editing data `data`, for a file whose layers aren't read (a symbol library @@ -529,7 +533,7 @@ pub fn symbols(data: &[u8], names: &mut obj::Names) -> Option { .chain(art_names(symbols.iter().map(|(s, _)| &*s.art))) .collect(); names.absorb_among(std::mem::take(&mut r.names), &used); - Some(Symbols { symbols, images, swatches }) + Some(Symbols { symbols, images, swatches, canvas_scale: r.canvas_scale, own_size: r.symbols_own_size }) } /// [`artboards`] as far as reading `data` from its start gets: those read before an error, and @@ -1194,6 +1198,11 @@ impl<'a> Reader<'a> { drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); super::on_path::apply(&mut self.doc, &self.on_path); let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); + // A Large Canvas copy's symbols are at its art's scale too ([`Self::ensure_space`]), except + // in data VectorCraft wrote. + if let Some(s) = self.large_canvas.filter(|s| *s != 1.0 && !self.symbols_own_size) { + crate::import::scale_symbols(&mut doc, s); + } // The active artboard, when it is not the first (the default). let active = self.active_board.filter(|i| *i > 0 && *i < self.artboards.len()); let boards: Vec = if self.artboards.is_empty() { @@ -1374,6 +1383,7 @@ impl<'a> Reader<'a> { styles_read, text_patterns, canvas_scale: self.canvas_scale, + symbols_own_size: self.symbols_own_size, xml_ids: self.xml_ids, bindings, bindings_skipped, diff --git a/crates/eps/src/import/cid.rs b/crates/eps/src/import/cid.rs index 4c00486de9..18498352f7 100644 --- a/crates/eps/src/import/cid.rs +++ b/crates/eps/src/import/cid.rs @@ -70,6 +70,8 @@ pub(super) struct CidChar { /// The base-font code or glyph name a `bfchar` or `bfrange` mapping gives it, instead of a /// CID. pub base: Option, + /// The value of its code (`<8001>` is 32769). + pub value: u32, } impl CMap { @@ -146,12 +148,12 @@ impl CMap { work += at.map_or(self.bases.len(), |k| k + 1) as u64; let base = at.and_then(|k| self.bases.get(k)).and_then(|m| Some((m.4, base_of(&m.3, c - m.1)?))); out.push(match (matched.is_some(), find(&self.cids, &mut work), base) { - (true, Some((font, cid, _)), _) => CidChar { font, cid, notdef, base: None }, - (true, None, Some((font, base))) => CidChar { font, cid: 0, notdef: None, base: Some(base) }, + (true, Some((font, cid, _)), _) => CidChar { font, cid, notdef, base: None, value: c }, + (true, None, Some((font, base))) => CidChar { font, cid: 0, notdef: None, base: Some(base), value: c }, // No character mapping: the notdef mapping's CID, else font 0's CID 0. _ => match find(&self.notdefs, &mut work) { - Some((font, _, base)) if matched.is_some() => CidChar { font, cid: base, notdef: None, base: None }, - _ => CidChar { font: 0, cid: 0, notdef: None, base: None }, + Some((font, _, base)) if matched.is_some() => CidChar { font, cid: base, notdef: None, base: None, value: c }, + _ => CidChar { font: 0, cid: 0, notdef: None, base: None, value: c }, }, }); i += n; diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index eb5e815e80..5acaba34a4 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -47,9 +47,9 @@ mod layers; mod plan; mod slots; -pub(crate) use build::scale_document; #[cfg(test)] use build::{Frame, MADE, prune_unseen}; +pub(crate) use build::{scale_document, scale_symbols}; #[cfg(test)] use compare::{ MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour, diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 1b212194a1..c469f02cd4 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -10,7 +10,7 @@ use super::slots::{ use super::{ Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, VariableKind, Vec2, ai, }; -use vectorcraft_doc::PlacedDocument; +use vectorcraft_doc::{PlacedDocument, Symbol}; /// Which box of the editing copy is the page. #[derive(Clone, Copy)] @@ -83,12 +83,11 @@ pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64, names: ai: }; let in_units = |r: Option, k: f64| k.is_finite() && k > 1.0 && fits(r, k) && !fits(r, 1.0); let scale = [unit, structure.canvas_scale].into_iter().find(|&k| in_units(board, k) || (!fits(board, 1.0) && in_units(art, k))); - let styles = structure.styles_read; + let (styles, own_size) = (structure.styles_read, structure.symbols_own_size); let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?; if let Some(k) = scale { // The graphic styles read from the file are drawn in its units too. - scale_document(&mut doc, k, styles); - relayout_wrapped(&mut doc); + scale_read_document(&mut doc, k, styles, own_size); } Ok((doc, notes)) } @@ -108,12 +107,11 @@ pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f6 let Some(scale) = alone_scale(&structure, data, pages, unit) else { return Err(Box::new(structure)); }; - let styles = structure.styles_read; + let (styles, own_size) = (structure.styles_read, structure.symbols_own_size); let built = build_from(structure, data, None, Page::Artboard, false, false, 1.0).map(|(mut doc, notes)| { if scale != 1.0 { // The graphic styles read from the file are drawn in its units too. - scale_document(&mut doc, scale, styles); - relayout_wrapped(&mut doc); + scale_read_document(&mut doc, scale, styles, own_size); } (doc, notes) }); @@ -268,6 +266,7 @@ pub(super) fn build_weighing( styles_read, text_patterns, canvas_scale, + symbols_own_size, xml_ids, bindings, bindings_skipped, @@ -577,8 +576,7 @@ pub(super) fn build_weighing( bind_variables(&mut doc, &bindings, bindings_skipped, &xml_ids, &filled, &mut notes); placed_documents(&mut doc, &placed_pages); if small.is_some() { - scale_document(&mut doc, unit, styles_read); - relayout_wrapped(&mut doc); + scale_read_document(&mut doc, unit, styles_read, symbols_own_size); } Ok((doc, notes)) } @@ -1916,7 +1914,7 @@ fn drop_guides_off_boards(doc: &mut Document) { /// styles. pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { let a = Affine::scale(k); - let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; + let scaling = document_scaling(); for layer in &mut doc.layers { let layer = Arc::make_mut(layer); layer.transform(a, scaling); @@ -1935,6 +1933,101 @@ pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { } } +/// What [`scale_document`] and [`scale_symbols`] scale with the art: strokes, effects and pattern +/// tiles. +fn document_scaling() -> Scaling { + Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() } +} + +/// A document read in units of `1/k` points drawn at its size: [`scale_document`], and its wrapped +/// type laid out again. Unless `own_size` (data VectorCraft wrote, whose symbols and opacity masks +/// that don't move with their object are at their own size), also the art of those masks on the +/// layers and its symbols ([`scale_symbols`]). +fn scale_read_document(doc: &mut Document, k: f64, styles: bool, own_size: bool) { + scale_document(doc, k, styles); + if !own_size { + let a = Affine::scale(k); + for layer in &mut doc.layers { + transform_unlinked_masks(Arc::make_mut(layer), a, document_scaling()); + } + scale_symbols(doc, k); + } + relayout_wrapped(doc); +} + +/// Draw the art of `doc`'s symbols `k` times as large ([`scale_symbol_art`]), with every instance +/// on the layers and in a pattern's tile (opacity masks included) placed by its transform times +/// `1/k`, so it draws where and as large as before, with the symbol's strokes and effects `k` +/// times as wide. +pub(crate) fn scale_symbols(doc: &mut Document, k: f64) { + if !(k.is_finite() && k > 0.0) || k == 1.0 || doc.symbols.is_empty() { + return; + } + scale_symbol_art(&mut doc.symbols, k); + let back = Affine::scale(1.0 / k); + for n in doc.layers.iter_mut().chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) { + place_instances_by(n, back); + } +} + +/// Draw the art of `symbols` `k` times as large, from each symbol's origin, with its strokes, +/// effects and pattern tiles, as [`scale_document`] draws the layers. An instance in the art keeps +/// its own scale and turn: it is placed by its transform times `1/k`. +pub(crate) fn scale_symbol_art(symbols: &mut [Symbol], k: f64) { + if !(k.is_finite() && k > 0.0) || k == 1.0 { + return; + } + let (a, back) = (Affine::scale(k), Affine::scale(1.0 / k)); + for s in symbols { + let art = Arc::make_mut(&mut s.art); + art.transform(a, document_scaling()); + transform_unlinked_masks(art, a, document_scaling()); + scale_wrap_offsets(art, k); + place_instances_by(&mut s.art, back); + } +} + +/// The art of the opacity masks in `n` that don't move with their object, which +/// [`Node::transform`] leaves where it is, transformed by `a` with `scaling` as `n` was: a change of +/// units moves them too. +fn transform_unlinked_masks(n: &mut Node, a: Affine, scaling: Scaling) { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + let linked = m.linked; + let art = Arc::make_mut(&mut m.art); + if !linked { + art.transform(a, scaling); + } + transform_unlinked_masks(art, a, scaling); + } + if let Some(children) = n.children_mut() { + for c in children { + transform_unlinked_masks(Arc::make_mut(c), a, scaling); + } + } +} + +/// Every symbol instance in `n` (opacity masks included) placed by its transform times `m`. Only +/// the objects on the way to an instance are copied. +fn place_instances_by(n: &mut Arc, m: Affine) { + let mut holds = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| holds |= matches!(c.kind, NodeKind::SymbolInstance { .. })); + if !holds { + return; + } + let n = Arc::make_mut(n); + if let NodeKind::SymbolInstance { xf, .. } = &mut n.kind { + *xf *= m; + } + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + place_instances_by(&mut mask.art, m); + } + if let Some(children) = n.children_mut() { + for c in children { + place_instances_by(c, m); + } + } +} + /// The text wrap offsets of `n` and what it holds, times `k` (within the -1000 to 1000 points /// they may be). fn scale_wrap_offsets(n: &mut Node, k: f64) { @@ -2256,6 +2349,85 @@ mod candidate_tests { assert_eq!(offsets, [wrap(60.0), wrap(1000.0), wrap(-1000.0)]); } + /// Symbols drawn ten times as large keep every instance where and as large as it was (on a layer, + /// in an opacity mask's art, linked or not, and in a pattern's tile), and an instance in a + /// symbol's art keeps its own scale and turn, moved with the art. + #[test] + fn scaling_the_symbols_keeps_their_instances_in_place() { + let mut doc = Document::new(100.0, 100.0); + let boxed = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(-1.0, -0.5, 1.0, 0.5)), Default::default()); + doc.symbols.push(Symbol { name: "Box".into(), art: Arc::new(boxed), dynamic: false }); + let turn = Affine::translate((3.0, 4.0)) * Affine::rotate(0.5) * Affine::scale(2.0); + let nested = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: turn }); + let mut pair = Node::group(doc.alloc_id(), vec![Arc::new(nested)]); + // An opacity mask that doesn't move with its object, holding an instance too. + let masking = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: turn }); + let mut unlinked = vectorcraft_doc::OpacityMask::new(masking, true); + unlinked.linked = false; + pair.extras_mut().mask = Some(Box::new(unlinked)); + doc.symbols.push(Symbol { name: "Pair".into(), art: Arc::new(pair), dynamic: false }); + let place = Affine::translate((40.0, 30.0)) * Affine::scale(3.0); + let mut on_layer = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Pair".into(), xf: place }); + let in_mask = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: place }); + on_layer.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(in_mask, true))); + doc.insert(Some(doc.layers[0].id), 0, on_layer).unwrap(); + let tiled = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: place }); + doc.patterns.push(vectorcraft_doc::PatternDef::new("Tiles", vec![Arc::new(tiled)])); + // Each instance's box on the page, by the art of Box it places (through Pair's instance). + let placed = |d: &Document| { + let xf = |n: &Node| match &n.kind { + NodeKind::SymbolInstance { xf, .. } => *xf, + other => panic!("an instance: {other:?}"), + }; + let art = d.symbols[0].art.geometric_bounds().unwrap(); + let layer = &d.layers[0].children().unwrap()[0]; + let mask = &layer.extras().mask.as_ref().unwrap().art; + let tile = &d.patterns[0].art[0]; + let nested = &d.symbols[1].art.children().unwrap()[0]; + let masking = &d.symbols[1].art.extras().mask.as_ref().unwrap().art; + [xf(layer) * xf(nested), xf(layer) * xf(masking), xf(mask), xf(tile)].map(|m| m.transform_rect_bbox(art)) + }; + let before = placed(&doc); + scale_symbols(&mut doc, 10.0); + let after = placed(&doc); + for (a, b) in after.iter().zip(&before) { + let far = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(far < 1e-9, "{after:?} {before:?}"); + } + let pair = &doc.symbols[1].art.children().unwrap()[0]; + let NodeKind::SymbolInstance { xf, .. } = &pair.kind else { panic!("an instance") }; + let want = Affine::translate((30.0, 40.0)) * Affine::rotate(0.5) * Affine::scale(2.0); + let far = xf.as_coeffs().iter().zip(want.as_coeffs()).fold(0.0f64, |m, (a, b)| m.max((a - b).abs())); + assert!(far < 1e-9, "{xf:?}"); + let art = doc.symbols[0].art.geometric_bounds().unwrap(); + assert_eq!(art, Rect::new(-10.0, -5.0, 10.0, 5.0)); + } + + /// Layers read in the units of a page scaled up to points take the art of their opacity masks + /// with them, a mask that doesn't move with its object included, except in data VectorCraft + /// wrote, where that mask's art is at its own size. + #[test] + fn scaling_a_read_document_scales_unlinked_opacity_masks() { + for own_size in [false, true] { + let mut doc = Document::new(100.0, 100.0); + let mut boxes = vec![]; + for linked in [true, false] { + let mask = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(1.0, 2.0, 3.0, 4.0)), Default::default()); + let mut mask = vectorcraft_doc::OpacityMask::new(mask, true); + mask.linked = linked; + let mut n = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Default::default()); + n.extras_mut().mask = Some(Box::new(mask)); + boxes.push((linked, doc.insert(Some(doc.layers[0].id), 0, n).unwrap())); + } + scale_read_document(&mut doc, 10.0, false, own_size); + for (linked, id) in boxes { + let mask = doc.node(id).and_then(|n| n.extras().mask.as_ref().and_then(|m| m.art.geometric_bounds())); + let want = if linked || !own_size { Rect::new(10.0, 20.0, 30.0, 40.0) } else { Rect::new(1.0, 2.0, 3.0, 4.0) }; + assert_eq!(mask, Some(want), "linked {linked}, own size {own_size}"); + } + } + } + /// Area type in `doc`'s first layer at `index`, in the frame `r`. fn insert_area(doc: &mut Document, index: usize, r: Rect) -> NodeId { let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped", Default::default()); diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index d1af3301df..afd8f2568c 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -1,4 +1,6 @@ -use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art}; +use super::build::{ + PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art, scale_symbol_art, +}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -45,7 +47,7 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus let own_palette = use_palette(data, &mut v.document, &mut read); use_brushes(data, &mut v.document, have, &mut v.warnings, &mut read); use_styles(data, &mut v.document, true, 1.0, None, &mut read); - use_symbols(data, &mut v.document, own_palette, &mut v.warnings, &mut read); + use_symbols(data, &mut v.document, own_palette, None, &mut v.warnings, &mut read); use_variables(data, &mut v.document, &mut v.warnings, &mut read); add_notes(&mut v.warnings, read.notes()); } @@ -153,7 +155,7 @@ pub fn layered_ai_from( let own_palette = use_palette(&data, &mut visible, &mut read); use_brushes(&data, &mut visible, have, &mut warnings, &mut read); use_styles(&data, &mut visible, false, unit, Some(&boards), &mut read); - use_symbols(&data, &mut visible, own_palette, &mut warnings, &mut read); + use_symbols(&data, &mut visible, own_palette, Some((unit, boards.as_slice())), &mut warnings, &mut read); use_variables(&data, &mut visible, &mut warnings, &mut read); add_notes(&mut warnings, read.notes()); return (visible, warnings, false); @@ -172,7 +174,7 @@ pub fn layered_ai_from( use_brushes(&data, &mut visible, have, &mut warnings, &mut read); use_styles(&data, &mut visible, false, unit, Some(&boards), &mut read); let mut all = unread(warnings, why); - use_symbols(&data, &mut visible, own_palette, &mut all, &mut read); + use_symbols(&data, &mut visible, own_palette, Some((unit, boards.as_slice())), &mut all, &mut read); use_variables(&data, &mut visible, &mut all, &mut read); add_notes(&mut all, read.notes()); (visible, all, false) @@ -377,26 +379,34 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo if styles.is_empty() { return; } - if !large_canvas && (unit != 1.0 || canvas != 1.0) { - let read; - let boards = match boards { - Some(b) => b, - None => { - read = ai::artboards(data, &mut ai::Names::default()).0; - &read - } - }; - let k = board_scale(boards, doc, unit).or_else(|| board_scale(boards, doc, canvas)); - if let Some(k) = k.filter(|k| *k != 1.0) { - for g in &mut styles { - g.appearance.scale_strokes(k); - g.appearance.scale_effects(k, vectorcraft_effects::scale_effect); - } + if !large_canvas && let Some(k) = data_scale(data, doc, unit, canvas, boards) { + for g in &mut styles { + g.appearance.scale_strokes(k); + g.appearance.scale_effects(k, vectorcraft_effects::scale_effect); } } doc.graphic_styles = styles.into(); } +/// How many times as large `doc` (a file read as its PDF part) draws the art of its editing data +/// `data`, whose declared large canvas scale is `canvas`, when it isn't 1: `unit` when the editing +/// data's artboards (`boards`, or read from `data`) are the size of `doc`'s pages in units of +/// `unit` points, else `canvas` when they are the pages' size at that scale ([`board_scale`]). +fn data_scale(data: &[u8], doc: &Document, unit: f64, canvas: f64, boards: Option<&[(String, Rect)]>) -> Option { + if unit == 1.0 && canvas == 1.0 { + return None; + } + let read; + let boards = match boards { + Some(b) => b, + None => { + read = ai::artboards(data, &mut ai::Names::default()).0; + &read + } + }; + board_scale(boards, doc, unit).or_else(|| board_scale(boards, doc, canvas)).filter(|k| *k != 1.0) +} + /// `doc` (a file read as its page or PDF part, in place of its layers) with the symbols of its /// editing data `data` ([`ai::symbols`]), their registration points, the images their art shows and /// the global and spot colors it is linked to that `doc` has no such swatch for (a color whose name @@ -405,15 +415,34 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo /// of the name, which the art then names, as does one whose name a color group has), as [`build`] gives a document read from the layers, and /// the note about instances that can't be drawn added to `warnings`. An object of their art linked /// to a graphic style keeps the link when [`use_styles`] gave `doc` the editing data's styles. The -/// symbols' art keeps the editing data's units, as in a document read from the layers: when -/// [`use_styles`] draws the styles' strokes and effects larger (a file in larger units), linked art -/// isn't scaled with them. Left as it is when `doc` has symbols already or the editing data has -/// none this reads. `names` reads the symbols' names and keeps the notes on them. -fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mut Vec, names: &mut ai::Names) { +/// symbols' art is drawn as large as a document read from the layers draws it +/// ([`scale_symbol_art`]): with `units` `None` (an EPS's page), as many times as large as the data's +/// declared large canvas scale; with `Some((unit, boards))`, as many times as large as +/// [`use_styles`] draws the styles' strokes and effects ([`data_scale`], `boards` the editing data's +/// artboards); at its own size in data VectorCraft wrote. Left as it is when `doc` has symbols +/// already or the editing data has none this reads. `names` reads the symbols' names and keeps the +/// notes on them. +fn use_symbols( + data: &[u8], + doc: &mut Document, + own_palette: bool, + units: Option<(f64, &[(String, Rect)])>, + warnings: &mut Vec, + names: &mut ai::Names, +) { if !doc.symbols.is_empty() { return; } - let Some(ai::Symbols { symbols, images, swatches }) = ai::symbols(data, names) else { return }; + let Some(ai::Symbols { symbols, images, swatches, canvas_scale, own_size }) = ai::symbols(data, names) else { return }; + let (mut symbols, registrations): (Vec<_>, Vec<_>) = symbols.into_iter().unzip(); + let k = match units { + _ if own_size => None, + None => Some(canvas_scale), + Some((unit, boards)) => data_scale(data, doc, unit, canvas_scale, Some(boards)), + }; + if let Some(k) = k { + scale_symbol_art(&mut symbols, k); + } for (key, blob) in images { doc.images.entry(key).or_insert(blob); } @@ -444,7 +473,7 @@ fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mu *link = Some(new.clone()); true }; - for (mut symbol, registration) in symbols { + for (mut symbol, registration) in symbols.into_iter().zip(registrations) { let mut art = reid_page_art(doc, &symbol.art); if !renamed.is_empty() { vectorcraft_doc::swatches::map_node_solid_paints(&mut art, &mut follow); diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index f63a77e09b..43b088c66f 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -136,6 +136,16 @@ fn codes_map_by_their_codespace_with_notdef_fallbacks() { width("<01>", 30.0); } +/// `kshow` over a CMap runs its procedure between each two codes with their values, whatever +/// their length, and the next character follows where it moves the point (PLRM 3rd ed., `kshow`). +#[test] +fn kshow_runs_between_each_two_cmap_codes() { + let fonts = String::from_utf8(cid_font(true)).unwrap() + CMAP; + let check = |cond: &str| check(&format!("{fonts} /TestFont 50 selectfont {cond}")); + check("gsave nulldevice 10 20 moveto [ {} <418001> kshow ] grestore aload length 2 eq exch 16#8001 eq and exch 65 eq and"); + check("gsave nulldevice 10 20 moveto {pop pop 100 0 rmoveto} <418001> kshow currentpoint grestore pop 165 sub abs 0.01 lt"); +} + /// `usecmap` brings in another CMap's ranges and mappings; the CMap's own come after them. #[test] fn usecmap_takes_the_parents_mappings() { diff --git a/crates/eps/src/import/tests_composite.rs b/crates/eps/src/import/tests_composite.rs index e682ce10ec..2f4074af97 100644 --- a/crates/eps/src/import/tests_composite.rs +++ b/crates/eps/src/import/tests_composite.rs @@ -119,6 +119,35 @@ fn widthshow_matches_a_composite_character_by_font_number_and_code() { check(format!("{} {} sub abs 0.01 lt", end(2), 10.0 + 100.0 * wh)); } +/// `kshow` runs its procedure between each two characters of a composite font, with the values of +/// their codes (font number times 256 plus code), and the next character follows where it moves +/// the current point (PLRM 3rd ed., `kshow`). +#[test] +fn kshow_runs_between_each_two_composite_characters() { + let (_, wh) = glyph('H'); + let (_, wi) = glyph('I'); + let fonts = fonts().replace("/Encoding [0] def", "/Encoding [0 0] def"); + let check = |cond: String| super::tests::check(&format!("{fonts} /Comp findfont 50 scalefont setfont {cond}")); + check("gsave nulldevice 10 20 moveto [ {} (\\000\\001\\001\\002\\000\\001) kshow ] grestore aload length 4 eq exch 1 eq and exch 258 eq and exch 258 eq and exch 1 eq and".into()); + check(format!( + "gsave nulldevice 10 20 moveto {{pop pop 100 0 rmoveto}} (\\000\\001\\000\\002) kshow currentpoint grestore pop {} sub abs 0.01 lt", + 10.0 + 50.0 * (wh + wi) + 100.0 + )); + // Drawn, the I starts where the procedure moved the point. + let r = read(&format!("{fonts}/Comp findfont 50 scalefont setfont 10 20 moveto {{pop pop 100 0 rmoveto}} (\\000\\001\\000\\002) kshow")); + let paths: Vec<_> = all(&r.document).iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).map(|n| bounds(n)).collect(); + assert_eq!(paths.len(), 2, "{paths:?}"); + assert!(paths[1].x0 > 10.0 + 50.0 * wh + 100.0 - 1.0, "{paths:?}"); + // A procedure that draws: its square goes after the glyphs' group, outside it. + let r = read(&format!( + "{fonts}/Comp findfont 50 scalefont setfont 10 20 moveto {{pop pop currentpoint 2 2 rectfill}} (\\000\\001\\000\\002\\000\\001) kshow" + )); + let top = super::tests::objects(&r.document); + assert_eq!(top.len(), 3, "{top:?}"); + assert_eq!(top[0].children().map(Vec::len), Some(3)); + assert!(top[1..].iter().all(|n| matches!(n.kind, NodeKind::Path { .. }) && (bounds(n).width() - 2.0).abs() < 0.01), "{top:?}"); +} + /// Measuring a composite font's Type 42 glyphs (`stringwidth` on a long string in a `loop`) /// counts against the operation budget, as drawing them does. #[test] diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 1f634b3744..738f02d857 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1809,6 +1809,44 @@ fn an_eps_without_layers_keeps_its_symbols() { assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings); } +/// The box of the art of `d`'s first symbol (Bar, 12 × 2 about the origin in the editing data). +fn bar_bounds(d: &Document) -> Option { + d.symbols.first().and_then(|s| s.art.geometric_bounds()) +} + +/// A symbol library without layers whose editing data is drawn larger than its units comes in with +/// its symbols' art as large as a document read from its layers has it: an EPS of a Large Canvas +/// copy at its declared scale, and a `.ai` whose artboard is its PDF part's page at that scale or +/// in the units of its `UserUnit`. One whose artboard is the page in points keeps its art's size. +#[test] +fn a_symbol_library_in_larger_units_comes_in_at_the_pages_size() { + let board = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let library = |setup: &str| symbol_library().replace("%%BeginSetup\n", &format!("%%BeginSetup\n{setup}")); + let ten = Some(vectorcraft_geom::Rect::new(-60.0, -10.0, 60.0, 10.0)); + let r = import(&eps(&page_square(50, 50), &library("%AI24_LargeCanvasScale: 10\n"))).unwrap(); + assert_eq!(bar_bounds(&r.document), ten, "{:?}", r.warnings); + let large = format!("%AI24_LargeCanvasScale: 10\n{board}"); + let (d, warnings, read) = crate::layered_ai(&private(&library(&large)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 1.0); + assert!(!read, "{warnings:?}"); + assert_eq!(bar_bounds(&d), ten, "{warnings:?}"); + let (d, warnings, _) = crate::layered_ai(&private(&library(board)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 10.0); + assert_eq!(bar_bounds(&d), ten, "{warnings:?}"); + let points = board.replace("0 100 /Real", "0 1000 /Real").replace("100 0 /Real", "1000 0 /Real"); + let (d, warnings, _) = crate::layered_ai(&private(&library(&points)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 10.0); + assert_eq!(bar_bounds(&d), Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0)), "{warnings:?}"); + // A library VectorCraft wrote (its `%%Creator`) has its symbols at their own size, whatever its + // scale. + let own = + |setup: &str| library(setup).replacen("%!PS-Adobe-3.0 \n", &format!("%!PS-Adobe-3.0 \n%%Creator: {}\n", crate::ai_editing::CREATOR_TEXT), 1); + let own_size = Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0)); + let r = import(&eps(&page_square(50, 50), &own("%AI24_LargeCanvasScale: 10\n"))).unwrap(); + assert_eq!(bar_bounds(&r.document), own_size, "{:?}", r.warnings); + let (d, warnings, _) = crate::layered_ai(&private(&own(&large)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 1.0); + assert_eq!(bar_bounds(&d), own_size, "{warnings:?}"); +} + /// A `.ai` that comes in as its PDF part, and an EPS that comes in as its page, because their /// layers can't be used (their editing data is for another page) get the symbols defined before /// their layers. @@ -2769,6 +2807,94 @@ fn a_large_canvas_eps_keeps_its_graphic_styles_at_the_pages_size() { assert_eq!((object.appearance.stroke_width(), object.extras().graphic_style), (2.0, Some(1)), "{object:#?}"); } +/// The definition of the symbol Box: a rectangle 2 × 1 about the origin, stroked 0.4 wide. +fn stroked_box_symbol() -> String { + let art = "0 0 0 1 K\n0.4 w\n-1 -0.5 m\n-1 0.5 L\n1 0.5 L\n1 -0.5 L\n-1 -0.5 L\nS\n"; + format!("%AI5_Begin_NonPrinting\n{}%AI5_End_NonPrinting--\n", symbol_def("Box", art, 5)) +} + +/// The art of `d`'s symbol Box ([`stroked_box_symbol`]) is ten times as large, its stroke 4 wide, +/// and its instance on `d`'s second layer, placed in the file at twice its size about (`x`, `y`), +/// keeps that scale and draws a box of 40 × 20 about `center`. +fn box_symbol_at_ten_times(d: &Document, center: (f64, f64), notes: &[String]) { + let art = &d.symbols.iter().find(|s| s.name == "Box").unwrap_or_else(|| panic!("{notes:?}")).art; + let b = art.geometric_bounds().unwrap(); + let close = |r: vectorcraft_geom::Rect, e: [f64; 4]| [r.x0, r.y0, r.x1, r.y1].iter().zip(e).all(|(a, b)| (a - b).abs() < 1e-6); + assert!(close(b, [-10.0, -5.0, 10.0, 5.0]), "the art at the page's size: {b:?} {notes:?}"); + let mut widths = vec![]; + art.walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) { + widths.push(n.appearance.stroke_width()); + } + }); + assert_eq!(widths, [4.0], "{notes:?}"); + let instance = d.layers[1].children().unwrap().first().unwrap_or_else(|| panic!("{:#?} {notes:?}", d.layers)); + let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("an instance: {:?}", instance.kind) }; + let [a, b0, c, d0, ..] = xf.as_coeffs(); + assert!((a.hypot(b0) - 2.0).abs() < 1e-9 && (c.hypot(d0) - 2.0).abs() < 1e-9, "the instance's own scale: {xf:?}"); + let (x, y) = center; + assert!(close(xf.transform_rect_bbox(b), [x - 20.0, y - 10.0, x + 20.0, y + 10.0]), "{:?}", xf.transform_rect_bbox(b)); +} + +/// The symbols of a Large Canvas editing copy come in at the page's size, as its art does: their +/// art and its strokes ten times as large, and each instance with the scale the file gives it, +/// drawn where and as large as before. +#[test] +fn a_large_canvas_eps_keeps_its_symbols_at_the_pages_size() { + let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n2 0 0 -2 5 5 /RTransformMatrix\n;\n9 () XW"; + let square = "0 0 1 0 k\n5 5 m\n5.4 5 L\n5.4 5.4 L\n5 5.4 L\nf"; + let art = native_large_canvas(10.0, 10.0, &format!("{}{}{}", stroked_box_symbol(), layer("Art", true, square), layer("Box", false, instance))); + let r = import(&eps(&page_square(50, 50), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Box"], "{:?}", r.warnings); + box_symbol_at_ten_times(&r.document, (50.0, 50.0), &r.warnings); +} + +/// A large canvas `.ai` 100 units square at a scale of 10, with the symbol Box +/// ([`stroked_box_symbol`]) and an instance of it at twice its size about (50, 50), its header +/// carrying `creator` when given, read with its PDF part, on a page whose PDF part can't be read, +/// and without a page. +fn large_canvas_ai_box_reads(creator: Option<&str>) -> Vec<(&'static str, Document, Vec)> { + let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n2 0 0 -2 50 50 /RTransformMatrix\n;\n9 () XW"; + let layers = format!("{}{}{}", stroked_box_symbol(), layer("Art", true, &square(10, 10)), layer("Box", false, instance)); + let mut art = native_large_canvas(100.0, 10.0, &layers); + if let Some(creator) = creator { + art = art.replacen("%!PS-Adobe-3.0 \n", &format!("%!PS-Adobe-3.0 \n%%Creator: {creator}\n"), 1); + } + let data = private(&art); + let (d, notes, read) = crate::layered_ai(&data, in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + let on_page = crate::ai_alone_on_page(&data, (1000.0, 1000.0), 10.0, Default::default()).unwrap(); + let no_page = crate::ai_alone(&data).unwrap(); + vec![("PDF part", d, notes), ("on a page", on_page.0, on_page.1), ("no page", no_page.0, no_page.1)] +} + +/// A `.ai` whose editing data is read at its declared large canvas scale has its symbols scaled +/// with the art, as an EPS has, however the file opens. +#[test] +fn a_large_canvas_ai_keeps_its_symbols_at_the_pages_size() { + for (how, d, notes) in large_canvas_ai_box_reads(None) { + let notes: Vec = notes.into_iter().chain([how.to_string()]).collect(); + box_symbol_at_ten_times(&d, (500.0, 500.0), ¬es); + } +} + +/// A large canvas `.ai` that VectorCraft wrote (its `%%Creator`) has its symbols defined at their +/// own size, as its `.ai` save writes them: their art keeps that size, and the instance, scaled with +/// the layers, draws the same box, however the file opens. +#[test] +fn a_large_canvas_ai_vectorcraft_wrote_keeps_its_symbols_at_their_own_size() { + for (how, d, notes) in large_canvas_ai_box_reads(Some(crate::ai_editing::CREATOR_TEXT)) { + let art = &d.symbols.iter().find(|s| s.name == "Box").unwrap_or_else(|| panic!("{how}: {notes:?}")).art; + let b = art.geometric_bounds().unwrap(); + assert_eq!(b, vectorcraft_geom::Rect::new(-1.0, -0.5, 1.0, 0.5), "{how}: {notes:?}"); + let instance = d.layers[1].children().unwrap().first().unwrap_or_else(|| panic!("{how}: {:#?} {notes:?}", d.layers)); + let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("{how}: an instance: {:?}", instance.kind) }; + let drawn = xf.transform_rect_bbox(b); + let close = [drawn.x0 - 480.0, drawn.y0 - 490.0, drawn.x1 - 520.0, drawn.y1 - 510.0].iter().all(|v| v.abs() < 1e-6); + assert!(close, "{how}: {drawn:?}"); + } +} + #[test] fn type_with_a_listed_art_style_is_linked_to_its_graphic_style() { let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 515e0b12ec..15b9ca34c6 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -999,6 +999,8 @@ struct Mapped { cid: Option<(u32, Option)>, outer: Affine, vertical: bool, + /// The value of the character's code in the shown string (what `kshow` gives its procedure). + value: u32, } /// Type in a CIDFont only named, over a predefined CMap ([`Interp::named_cid_type`]): its codes @@ -1097,7 +1099,8 @@ impl Interp<'_> { if let Some(Obj::Dict(d)) = index.and_then(|i| fonts.get(i as usize)) && font_type(&d.borrow()) != Some(0.0) { - out.push(Mapped { font: d.clone(), pick, code, name: None, cid: None, outer, vertical: false }); + let value = u32::from(pick) * 256 + u32::from(code); + out.push(Mapped { font: d.clone(), pick, code, name: None, cid: None, outer, vertical: false, value }); } } Ok(Some(out)) @@ -1124,21 +1127,28 @@ impl Interp<'_> { .filter_map(|c| match (fonts.get(c.font), c.base) { // A base font's code, or its glyph name (at the code its encoding gives it, if any). (Some(Obj::Dict(d)), Some(Base::Code(code))) => { - Some(Mapped { font: d.clone(), pick: 0, code, name: None, cid: None, outer, vertical }) + Some(Mapped { font: d.clone(), pick: 0, code, name: None, cid: None, outer, vertical, value: c.value }) } (Some(Obj::Dict(d)), Some(Base::Name(name))) => { let code = encoded(&d.borrow(), &name); let name = code.is_none().then_some(name); - Some(Mapped { font: d.clone(), pick: 0, code: code.unwrap_or(0), name, cid: None, outer, vertical }) + Some(Mapped { font: d.clone(), pick: 0, code: code.unwrap_or(0), name, cid: None, outer, vertical, value: c.value }) } // A code without a mapping in a base font (not a CIDFont): its code 0, the // `.notdef` glyph CID 0 stands for in a CIDFont. (Some(Obj::Dict(d)), None) if !d.borrow().contains_key(&Key::name("CIDFontType")) => { - Some(Mapped { font: d.clone(), pick: 0, code: 0, name: None, cid: None, outer, vertical }) - } - (Some(Obj::Dict(d)), None) => { - Some(Mapped { font: d.clone(), pick: 0, code: (c.cid & 0xff) as u8, name: None, cid: Some((c.cid, c.notdef)), outer, vertical }) + Some(Mapped { font: d.clone(), pick: 0, code: 0, name: None, cid: None, outer, vertical, value: c.value }) } + (Some(Obj::Dict(d)), None) => Some(Mapped { + font: d.clone(), + pick: 0, + code: (c.cid & 0xff) as u8, + name: None, + cid: Some((c.cid, c.notdef)), + outer, + vertical, + value: c.value, + }), _ => None, }) .collect()) @@ -1346,6 +1356,12 @@ impl Interp<'_> { fn show_composite(&mut self, glyphs: &[Mapped], spacing: &Spacing) -> Res { let start = self.out.drawn.len(); let mut text = String::new(); + let kern: Vec = match spacing { + Spacing::Kern(_) => glyphs.iter().map(|g| g.value).collect(), + _ => Vec::new(), + }; + // Where in the objects drawn each run of `kshow`'s procedure put its art. + let mut kerned = Vec::new(); for (i, g) in glyphs.iter().enumerate() { let at = self.user_point()?; let w = self.composite_glyph(g, at, Draw::Fill)?; @@ -1353,6 +1369,12 @@ impl Interp<'_> { // times 256 plus its code (PLRM 3rd ed., `widthshow` in chapter 8). let d = spacing.advance(i, i64::from(g.pick) * 256 + i64::from(g.code), w); self.g.cur = Some(self.xf() * (at + d)); + // `kshow`'s procedure runs between each two characters with the values of their codes. + let before = self.out.drawn.len(); + self.kern(spacing, &kern, i, at + d)?; + if self.out.drawn.len() > before { + kerned.push(before..self.out.drawn.len()); + } // A CID stands for no character (its code is only the CID's low byte). let c = if g.cid.is_some() { '\u{2022}' } else { g.name.as_deref().map_or_else(|| code_char(&g.font.borrow(), g.code), glyph_char) }; if c != '\u{2022}' { @@ -1364,7 +1386,13 @@ impl Interp<'_> { { text = name.to_string(); } + // What `kshow`'s procedure draws goes after the type, outside its group. + let mut art = Vec::new(); + for run in kerned.into_iter().rev() { + art.splice(0..0, self.out.drawn.drain(run)); + } self.group_glyphs(start, &text); + self.out.drawn.extend(art); Ok(()) } diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 49b64f4bce..534778286a 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -230,7 +230,8 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb } // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); - let baked = vectorcraft_effects::bake_document(doc); + let baked = + vectorcraft_effects::bake_document_placing_symbols(doc, true, vectorcraft_effects::WrittenLayers { hidden: false, non_printing: true }); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); let mut setting_notes = vec![]; diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 3eaf476489..92971cee01 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -1654,6 +1654,23 @@ fn an_instance_whose_brush_art_reaches_the_region_is_written() { assert!(page.contains("1 0 0 rg"), "{page}"); } +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + // The symbol's square at (10, 40), which a Transform effect moves 100 pt right, is drawn from + // x = 110 and not from x = 10. + let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })]); + let mut art = path(&mut d, shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) }); + let page = page_of(&d, &opts(&d)); + let starts: Vec = page.lines().filter(|l| l.ends_with(" m")).filter_map(|l| l.split_whitespace().next()?.parse().ok()).collect(); + assert!(!starts.is_empty() && starts.iter().all(|x| (110.0..=120.0).contains(x)), "{starts:?}\n{page}"); +} + #[test] fn level_preview_and_overprint_ids_read_back() { for l in Level::ALL { diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 6529cf175d..beda6d8ade 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -490,6 +490,43 @@ fn trace_and_style_prompt_steps_run_in_order() { assert!(fills.len() > 1 && fills.contains("#00aa00") && !fills.contains(&from), "{fills:?}"); } +/// A window capture and an artboard preview scaled down keep the color of opaque pixels beside +/// clear ones: the clear black beside them does not darken them. +#[test] +fn scaled_down_pictures_keep_the_color_beside_clear_pixels() { + struct Striped; + impl Backend for Striped { + fn call(&mut self, method: &str, _: Value) -> Result { + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_fn(64, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let png = vectorcraft_format::base64_encode(png.get_ref()); + Ok(match method { + "document.inspect" => json!({"artboards": [{"width": 64, "height": 4}]}), + _ => json!({"pngBase64": png, "width": 64, "height": 4}), + }) + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "test".into() + } + } + + let mut s = Server::new(Box::new(Striped)); + let shots = [("screenshot", json!({"window": true, "max_side": 16})), ("render_preview", json!({"max_side": 16}))]; + for (id, (tool, args)) in (1..).zip(shots) { + let r = call(&mut s, id, tool, args); + let data = + r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").unwrap_or_else(|| panic!("{tool}: {r}"))["data"].as_str().unwrap(); + let img = image::load_from_memory(&vectorcraft_format::base64_decode(data).unwrap()).unwrap().into_rgba8(); + assert_eq!(img.dimensions(), (16, 1), "{tool}"); + assert!(img.pixels().all(|p| p.0 == [200, 30, 30, 128]), "{tool}: {:?}", img.get_pixel(0, 0)); + } +} + /// `screenshot` and `render_preview` with `traceViews: true` draw an Image Trace object in the /// view set with imageTrace.setView, as the app's window does; without it they draw the tracing /// result, as exports do. The image is a dark disc (r = 30 px) on blue, traced with Ignore White. diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index b1c5af70bb..8e514ac48d 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -4,6 +4,7 @@ use super::handlers::{ select_ids, transform, }; use super::{ARTBOARD_PARAMS, Backend, Map, ToolResult, Value, json, writable_format}; +use vectorcraft_doc::resample::{Resample, fitted}; /// The `menu` argument of the command listings. pub(super) const MENU_FILTER: &str = @@ -445,7 +446,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result { let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?; // The render was asked for at most 4096 a side. let img = super::handlers::decode_png(&png, 8192).map_err(|e| format!("read preview: {e}"))?; - let img = if img.width() > max || img.height() > max { img.thumbnail(max, max) } else { img }; + let img = if img.width() > max || img.height() > max { fitted(img, max, max, Resample::Average) } else { img }; let mut out = std::io::Cursor::new(Vec::new()); img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?; Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()}))) diff --git a/crates/mcp/src/tools/handlers.rs b/crates/mcp/src/tools/handlers.rs index 39d3f3f8e1..5aefa20d31 100644 --- a/crates/mcp/src/tools/handlers.rs +++ b/crates/mcp/src/tools/handlers.rs @@ -1,5 +1,6 @@ use super::dispatch::trace_views; use super::{Backend, Map, ToolResult, Value, json}; +use vectorcraft_doc::resample::{Resample, fitted}; // ---------- implementation ---------- @@ -521,7 +522,7 @@ fn fit_capture(png: Vec, max: Option, info: &mut Value) -> Result 1.0 { let nw = ((f64::from(w) / k).floor() as u32).max(1); let nh = ((f64::from(h) / k).floor() as u32).max(1); - image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle) + let how = Resample::Filter(image::imageops::FilterType::Triangle); + resized(image::DynamicImage::ImageRgba8(img), nw, nh, how).into_rgba8() } else { img }; @@ -447,4 +450,18 @@ mod tests { let f = Rgba::fitted(img); assert_eq!((f.width, f.height), (MAX_SIDE, 2)); } + + /// A large image scaled to fit keeps the color of its opaque pixels beside clear ones: the + /// clear black beside them does not darken them. + #[test] + fn large_images_keep_their_color_beside_clear_pixels() { + let img = + image::RgbaImage::from_fn(MAX_SIDE * 2, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }); + let f = Rgba::fitted(img); + assert_eq!((f.width, f.height), (MAX_SIDE, 2)); + let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8; + for p in f.pixels.as_chunks::<4>().0 { + assert!(p[3] > 0 && [200, 30, 30].iter().zip(p).all(|(&want, &c)| m(c, p[3]).abs_diff(m(want, p[3])) <= 1), "{p:?}"); + } + } } diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index eabba52d9e..6cf8677269 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -45,8 +45,15 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let set = &opts.settings; set.check()?; vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; - // Live geometry effects export as their result; raster effects are reported below. - let baked = vectorcraft_effects::bake_document(doc); + // Live geometry effects export as their result, in symbols' art too; raster effects are + // reported below. + // Only the layers this export writes place their instances' art (see `ex.non_printing` and + // `ex.layers` below). + let layers = vectorcraft_effects::WrittenLayers { + hidden: set.writes_layers() && !doc.page_knockout, + non_printing: set.include_non_printing || set.create_layers, + }; + let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true, layers); // Baking stops early when cancelled, leaving a half-baked copy that must not be written. vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let doc = baked.as_ref().unwrap_or(doc); diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index d1382449e5..d8b873364c 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -23,7 +23,8 @@ use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageRea use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; use vectorcraft_doc::ImageBlob; -use vectorcraft_doc::cmyk::jpeg_components; +use vectorcraft_doc::cmyk::{Inks, jpeg_components}; +use vectorcraft_doc::resample::{Resample, premultiply, resized, unpremultiply}; use crate::output::{CmykPixels, Pixels}; use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec}; @@ -277,15 +278,29 @@ fn jpeg_quality(r: &Rule, jpeg: bool) -> Option { } } -/// `img` resampled to `w` × `h` pixels as `r` says. +/// How `r` resamples an image. +fn resampling(r: &Rule) -> Resample { + match r.downsample { + Downsample::Average => Resample::Average, + Downsample::Subsample => Resample::Filter(FilterType::Nearest), + Downsample::Bicubic | Downsample::None => Resample::Filter(FilterType::CatmullRom), + } +} + +/// `img` resampled to `w` × `h` pixels as `r` says, the colors of an image with alpha weighted by +/// it ([`resized`]). fn resample(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage { + resized(img, w, h, resampling(r)) +} + +/// The four channels of `img` resampled to `w` × `h` pixels as `r` says, each on its own. +fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage { if (w, h) == (img.width(), img.height()) { return img; } - match r.downsample { - Downsample::Average => img.thumbnail_exact(w, h), - Downsample::Subsample => img.resize_exact(w, h, FilterType::Nearest), - Downsample::Bicubic | Downsample::None => img.resize_exact(w, h, FilterType::CatmullRom), + match resampling(r) { + Resample::Average => img.thumbnail_exact(w, h), + Resample::Filter(f) => img.resize_exact(w, h, f), } } @@ -403,17 +418,10 @@ pub(crate) fn recode_cmyk( let Some(inks) = blob.cmyk() else { return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }) }; }; - let dims = (inks.width, inks.height); - let (width, height) = sized(&rule, dims, size); - let alpha = match alpha.map(|a| image::GrayImage::from_raw(inks.width, inks.height, a)) { - Some(Some(a)) => Some(resample(DynamicImage::ImageLuma8(a), &rule, (width, height)).into_luma8().into_raw()), - Some(None) => return Recoded { image: None, warning: None }, - None => None, - }; - // The four inks resample as the four channels of an RGBA image do. - let inks = match image::RgbaImage::from_raw(inks.width, inks.height, inks.data) { - Some(img) => resample(DynamicImage::ImageRgba8(img), &rule, (width, height)).into_rgba8().into_raw(), - None => return Recoded { image: None, warning: Some(CMYK_RGB) }, + let (width, height) = sized(&rule, (inks.width, inks.height), size); + let (inks, alpha) = match resampled_inks(inks, alpha, &rule, (width, height)) { + Ok(r) => r, + Err(warning) => return Recoded { image: None, warning }, }; let (samples, space) = match convert { Some(p) => p.convert(&inks), @@ -425,6 +433,36 @@ pub(crate) fn recode_cmyk( Recoded { image, warning: rule.instead } } +/// Ink amounts (four bytes a pixel) and their alpha (one byte a pixel), if any. +type InksAndAlpha = (Vec, Option>); + +/// Ink amounts `inks` and their `alpha` (one sample a pixel) resampled to `to` as `r` says. The +/// inks under clear pixels don't count where neighbors blend: they are multiplied by the alpha +/// before resampling and divided by the resampled alpha after. `Err` with the warning when they +/// can't be read. +fn resampled_inks(inks: Inks, alpha: Option>, r: &Rule, to: (u32, u32)) -> Result> { + let dims = (inks.width, inks.height); + let weighted = to != dims && resampling(r) != Resample::Filter(FilterType::Nearest); + let mut data = inks.data; + let alpha = match alpha.map(|a| image::GrayImage::from_raw(dims.0, dims.1, a)) { + Some(Some(a)) => { + if weighted { + premultiply(&mut data, a.as_raw()); + } + Some(resample(DynamicImage::ImageLuma8(a), r, to).into_luma8().into_raw()) + } + Some(None) => return Err(None), + None => None, + }; + // The four inks resample as the four channels of an RGBA image do, each on its own. + let img = image::RgbaImage::from_raw(dims.0, dims.1, data).ok_or(Some(CMYK_RGB))?; + let mut inks = resample_channels(DynamicImage::ImageRgba8(img), r, to).into_rgba8().into_raw(); + if let Some(a) = alpha.as_deref().filter(|_| weighted) { + unpremultiply(&mut inks, a); + } + Ok((inks, alpha)) +} + /// The most one image read here may allocate (bytes), as the image reader allows by default. const MAX_DECODE: u64 = 512 << 20; @@ -482,3 +520,26 @@ fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, qual } Image::from_jpeg_with_icc(out.into(), icc.map(|i| i.to_vec().into()), interpolate).ok() } + +#[cfg(test)] +mod tests { + use super::*; + + /// The inks of a CMYK image with alpha, resampled, keep the amounts of the opaque pixels: the + /// inks under the clear pixels beside them don't thin them out. + #[test] + fn resampled_inks_keep_the_amounts_beside_clear_pixels() { + let data = [[40, 80, 120, 160], [0, 0, 0, 0]].repeat(8).concat(); + let alpha = [255, 0].repeat(8); + for method in ["average", "bicubic"] { + let c: CompressionSettings = serde_json::from_value(serde_json::json!({"color": {"downsample": method}})).unwrap(); + let inks = Inks::new(16, 1, data.clone()).unwrap(); + let (inks, alpha) = resampled_inks(inks, Some(alpha.clone()), &rule(&c, Kind::Color), (4, 1)).unwrap(); + let alpha = alpha.unwrap(); + let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8; + for (p, &a) in inks.as_chunks::<4>().0.iter().zip(&alpha) { + assert!(a > 0 && p.iter().zip([40, 80, 120, 160]).all(|(&c, want)| m(c, a).abs_diff(m(want, a)) <= 1), "{method}: {p:?} at {a}"); + } + } + } +} diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index 12c71756c8..2a3cbf98af 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -183,11 +183,14 @@ pub fn keep_layers(nodes: &mut Vec>, which: PrintLayers) { /// only the layers that print, overprints in composite output as [`PrintAdvanced::overprints`] /// says. pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> { - let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned); - let d = doc.to_mut(); - keep_layers(&mut d.layers, set.print_layers); - composite_overprints(d, set); - doc + // The layers that do not print are left out first, so the art placed for symbols is only + // the art that prints; the layers kept are shown. + let mut kept = doc.clone(); + keep_layers(&mut kept.layers, set.print_layers); + let layers = vectorcraft_effects::WrittenLayers { hidden: false, non_printing: true }; + let mut d = vectorcraft_effects::bake_document_placing_symbols(&kept, true, layers).unwrap_or(kept); + composite_overprints(&mut d, set); + Cow::Owned(d) } /// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index bee80238bd..5e9796b9b4 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -246,6 +246,26 @@ fn an_instance_whose_brush_art_reaches_the_page_is_written() { assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds()); } +/// The live effects in a symbol's art apply where the instance places the art, as on the canvas: +/// the symbol's square at (10, 40), which a Transform effect moves 100 pt right, is written at +/// (110, 40). +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + let mut d = doc(200.0, 100.0); + let mut square = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), Color::rgb(1.0, 0.0, 0.0)); + square.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(square) }); + add(&mut d, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 1); + assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(110.0, 40.0, 120.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds()); +} + /// An instance of a symbol with 9-slice guides, scaled to 200 × 150, is written as the canvas /// draws it: its 10 × 10 corner square stays 10 × 10 and its 10 × 10 center square becomes 30 × 20. #[test] @@ -336,6 +356,31 @@ fn a_stained_instance_keeps_its_stain() { } } +/// A stained instance of a symbol whose art has a live effect is written in its stained colors, +/// where the effect draws the art. +#[test] +fn a_stained_instance_keeps_its_stain_when_its_art_has_effects() { + let blue = Color::rgb8(30, 90, 220); + let square = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), blue); + let half = stained_instance("Moved", Affine::IDENTITY, 0.5, true); + let want = vectorcraft_brush::instance_art(&square, &half).appearance.fill_paint().color().unwrap().to_hex(); + assert_ne!(want, blue.to_hex(), "the stain changes the color"); + let mut art = square; + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + let mut d = doc(200.0, 100.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) }); + add(&mut d, half); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 1); + assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(110.0, 40.0, 120.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds()); + assert_eq!(leaf_fills(&out), [want]); +} + /// The placed art of all the 9-slice instances in one export holds at most `MAX_SYMBOL_NODES` /// objects, opacity-mask art included, as on the canvas: of two instances whose art placed through /// the guides holds about 9,009,000 objects each (a gradient square in all nine cells, each copy diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index c02f3f5e1c..076c3c08c9 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -73,6 +73,38 @@ fn images_above_the_threshold_are_resampled_to_the_target_resolution() { assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi"); } +/// An image with clear pixels, resampled, keeps the color of its opaque pixels: the clear black +/// beside them does not darken them. +#[test] +fn resampled_images_keep_their_color_beside_clear_pixels() { + let striped = image::RgbaImage::from_fn(600, 600, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }); + let d = doc(encoded(striped.into(), ImageFormat::Png), "image/png"); + for method in ["average", "bicubic"] { + let mut settings: PdfSettings = + serde_json::from_value(json!({"compression": {"color": {"downsample": method, "ppi": 150, "abovePpi": 225, "compression": "zip"}}})) + .unwrap(); + settings.compression.compress_text = false; + let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes; + let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let rgb = file + .objects() + .into_iter() + .find_map(|o| match o { + Object::Stream(s) + if s.dict().get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") && s.dict().contains_key(b"SMask") => + { + Some(s.decoded().unwrap().into_owned()) + } + _ => None, + }) + .unwrap_or_else(|| panic!("{method}: a color image with a soft mask")); + assert_eq!(rgb.len(), 150 * 150 * 3, "{method}"); + for p in rgb.as_chunks::<3>().0 { + assert!(p[0].abs_diff(200) <= 3 && p[1].abs_diff(30) <= 3 && p[2].abs_diff(30) <= 3, "{method}: {p:?}"); + } + } +} + /// The width and height of each image XObject in `pdf`. fn image_sizes(pdf: &[u8]) -> Vec<(u32, u32)> { let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap(); diff --git a/crates/pdf/src/tests/links.rs b/crates/pdf/src/tests/links.rs index c23854439f..f4792e7fa0 100644 --- a/crates/pdf/src/tests/links.rs +++ b/crates/pdf/src/tests/links.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use craft_pdf::syntax::Pdf; use craft_pdf::syntax::object::{Array, Dict, Name}; use vectorcraft_color::Color; -use vectorcraft_doc::{Document, Node, NodeId}; -use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, Rect, shapes}; use super::{add, doc, rect_node}; use crate::*; @@ -89,6 +89,27 @@ fn hidden_and_unprinted_objects_have_no_links_and_clipping_trims_them() { assert_eq!(links(&export(&d, &PdfOptions::default()).unwrap()), vec![Vec::<(String, [f32; 4])>::new()]); } +/// A linked symbol instance whose art has a live effect links over the art where the effect draws it. +#[test] +fn a_linked_instance_keeps_its_link_when_its_art_has_effects() { + let mut art = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), Color::rgb(0.0, 0.0, 1.0)); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + let mut d = doc(200.0, 100.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) }); + let mut instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY }); + instance.edit_attrs(|a| a.url = "https://example.com/symbol".into()); + add(&mut d, instance); + let pages = links(&export(&d, &PdfOptions::default()).unwrap()); + let [(uri, rect)] = pages[0].as_slice() else { panic!("one link: {pages:?}") }; + assert_eq!(uri, "https://example.com/symbol"); + // x 110…120 after the 100 pt move; y up: 100 − 50 to 100 − 40. + assert!(near(*rect, [110.0, 50.0, 120.0, 60.0]), "{rect:?}"); +} + #[test] fn each_page_links_its_own_artboard_objects() { let mut d = doc(200.0, 100.0); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 155912c124..2cb63e374a 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -451,6 +451,29 @@ fn a_symbol_instances_brush_art_keeps_its_artboard_printed() { assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{p:?}"); } +#[test] +fn symbol_art_effects_print_where_they_draw() { + // A red square and the symbol's blue square, each moved 100 pt right by a Transform effect, + // print at the same distance from the page's left edge. + let moved = |mut n: Node| { + n.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"moveH": 100.0}), visible: true }); + n + }; + let mut d = doc(200.0, 100.0, &[]); + let (layer, id) = (d.default_layer().unwrap(), d.alloc_id()); + let red = Node::path(id, shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0)); + d.insert(Some(layer), 0, moved(red)).unwrap(); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(rgb(0.0, 0.0, 1.0), Paint::None, 0.0)); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) }); + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Moved".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let p = pages(&job(&d, json!({})).bytes); + assert_eq!(p.len(), 1); + let left = |c: [u8; 3]| p[0].1.iter().find(|(_, paint)| rgb8(paint) == c).map(|(r, _)| r.x0); + let (red, blue) = (left([255, 0, 0]), left([0, 0, 255])); + assert!(red.zip(blue).is_some_and(|(r, b)| (r - b).abs() < 0.05), "{p:?}"); +} + #[test] fn copies_collate_and_reverse() { let mut d = diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 1d39ece10c..c130fbe346 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -1,6 +1,7 @@ //! Paint conversion (solid, gradients, patterns) and image decoding. use vectorcraft_color::{GradientKind, Paint}; +use vectorcraft_doc::resample::{Resample, resized}; use vectorcraft_geom::{Rect, Vec2}; use vello_cpu::RenderContext; use vello_cpu::peniko::{self, ColorStop}; @@ -446,7 +447,7 @@ fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage { } let s = max as f64 / w.max(h) as f64; let (nw, nh) = (((w as f64 * s).round() as u32).clamp(1, max), ((h as f64 * s).round() as u32).clamp(1, max)); - img.resize_exact(nw, nh, image::imageops::FilterType::Triangle) + resized(img, nw, nh, Resample::Filter(image::imageops::FilterType::Triangle)) } /// [`decode_pixmap_or_why`]'s pixels. @@ -504,7 +505,7 @@ pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option w as u32 || img.height() > h as u32 { - img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle) + resized(img, w as u32, h as u32, Resample::Filter(image::imageops::FilterType::Triangle)) } else { img }; @@ -587,4 +588,35 @@ mod tests { assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC + 1), Err(Undecoded::NoRoom)); assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC), Ok(())); } + + /// Opaque red beside clear black, every other column of a `w` × 1 image. + fn striped(w: u32) -> image::RgbaImage { + image::RgbaImage::from_fn(w, 1, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + } + + /// Does each pixel of `pm` hold red at its own coverage (premultiplied, within a step)? + fn red_at_its_coverage(pm: &vello_cpu::Pixmap) -> bool { + let m = |c: u8, a: u8| ((u16::from(c) * u16::from(a) + 127) / 255) as u8; + pm.data().iter().all(|p| p.r.abs_diff(m(200, p.a)) <= 1 && p.g.abs_diff(m(30, p.a)) <= 1 && p.b.abs_diff(m(30, p.a)) <= 1) + } + + /// A placed image drawn smaller keeps the color of its opaque pixels at its cut-out edges: the + /// clear pixels beside them do not darken them. + #[test] + fn an_image_drawn_smaller_keeps_its_color_beside_clear_pixels() { + let mut png = Vec::new(); + striped(8).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let pm = decode_pixmap_fit(&png, 2, 1).unwrap(); + assert_eq!((pm.width(), pm.height()), (2, 1)); + assert!(pm.data().iter().all(|p| p.a > 0)); + assert!(red_at_its_coverage(&pm), "{:?}", pm.data()); + } + + /// An image wider than a pixmap may be is shrunk to fit with its colors weighted by alpha the same way. + #[test] + fn an_image_shrunk_to_fit_a_pixmap_keeps_its_color_beside_clear_pixels() { + let pm = decoded_pixmap(image::DynamicImage::ImageRgba8(striped(u32::from(u16::MAX) + 4465))).unwrap(); + assert_eq!(pm.width(), u16::MAX); + assert!(red_at_its_coverage(&pm)); + } } diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index bbe72063e1..cbffbfc348 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -40,7 +40,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, ParaStyle, StrokeLayer, TextKind, TextObject, TextRun}; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect}; -use vectorcraft_text::{FontDb, TextLayout}; +use vectorcraft_text::{FontDb, PositionedGlyph, TextLayout, spacing_scale}; use super::css::{Styles, XNode}; use super::{DEFAULT_FONT_SIZE, PT_PER_IN, href, vector_effect}; @@ -1466,7 +1466,12 @@ impl Ctx { obj.xf = Affine::IDENTITY; } else { let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]); - let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, &pin, origin.x, &mut breaks); + let (justify, left, starts, unplaced) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, &pin, origin.x, &mut breaks); + if unplaced > 0 { + warnings.push(format!( + "{label}: {unplaced} absolutely placed character(s) between characters of the other direction keep their place in the line" + )); + } obj.para.justify = justify; obj.xf = Affine::translate((finite(left), finite(origin.y))); // Lines anchored differently or at different x (paragraphs aligned or indented @@ -1599,7 +1604,8 @@ fn leading_tolerance(spacing: f64) -> f64 { /// character, alignment (as for [`justify_of`]) and anchor x. `anchor_of` gives where the chunk /// starting at a character sits on its anchor, and `pin` sets the paragraph directions of the /// text ([`pin_direction`]). When characters are placed on their own, every line is left-aligned -/// and its anchor x is its left edge. +/// and its anchor x is its left edge. Also returns how many absolutely placed characters kerning +/// cannot put on their x. #[allow(clippy::too_many_arguments)] fn lines( cells: &mut [Cell], @@ -1611,7 +1617,7 @@ fn lines( pin: &dyn Fn(&mut TextObject), x0: f64, breaks: &mut Vec, -) -> (Justify, f64, Vec<(usize, f64, f64)>) { +) -> (Justify, f64, Vec<(usize, f64, f64)>, usize) { let n = cells.len(); let y0 = y[0].unwrap_or(0.0) + dy[0]; let (mut base, mut cur) = (y0, y0); @@ -1654,7 +1660,7 @@ fn lines( } if targets.is_empty() { let starts = lines.iter().map(|&(s, lx)| (s, anchor_of(s), lx)).collect(); - return (justify_of(anchor_of(0)), x0, starts); + return (justify_of(anchor_of(0)), x0, starts, 0); } // Absolutely placed characters: lay the text out left-aligned and kern each one onto its x. // SVG anchors every absolutely placed chunk on its own, so a chunk not anchored at its left @@ -1664,59 +1670,122 @@ fn lines( // Chunk starts in order: line starts and absolutely placed characters. let mut starts: Vec = lines.iter().map(|l| l.0).chain(targets.iter().map(|t| t.0)).collect(); starts.sort_unstable(); - // The left and right edges of the chunk starting at character `s`, from each character's pen and end. - let edges = |s: usize, pens: &[Option], ends: &[Option]| { + // The left and right edges of the chunk starting at character `s`, and the character drawn at its left. + let edges = |s: usize, pens: &[Option]| { let e = starts.get(starts.partition_point(|&c| c <= s)).copied().unwrap_or(n); - let left = (s..e).filter_map(|j| pens[j]).fold(f64::INFINITY, f64::min); - let right = (s..e).filter_map(|j| ends[j]).fold(f64::NEG_INFINITY, f64::max); - (left.is_finite() && right.is_finite()).then_some((left, right)) + let (mut left, mut right, mut at) = (f64::INFINITY, f64::NEG_INFINITY, s); + for (j, p) in pens.iter().enumerate().take(e).skip(s) { + let Some(p) = p else { continue }; + if p.start < left { + (left, at) = (p.start, j); + } + right = right.max(p.end); + } + (left.is_finite() && right.is_finite()).then_some((left, right, at)) }; let mut line_left: Vec = lines.iter().map(|l| l.1).collect(); + // Where each line's first chunk's left edge goes. + let mut first_left: Vec = vec![]; // Where each absolutely placed character's pen goes, from the left edge of its line's first chunk. let mut wants: Vec<(usize, f64)> = vec![]; + // Absolutely placed characters drawn right next to a placed neighbor with no kerning between + // them (a left-to-right character between right-to-left ones): they stay where the layout puts them. + let mut unplaced = 0; // Kerning changes shaping (pair kerning is off for manually kerned characters): repeat until // every character sits on its x. for round in 0..4 { let (runs, bytes) = assemble(cells, breaks); let mut text = with_runs(runs.0); pin(&mut text); - let (pens, ends) = pens(&measure(&text), &bytes); + let pens = pens(&measure(&text), &bytes, cells); // The edges of each line's first chunk, found once a round. - let first: Vec> = lines.iter().map(|&(s, _)| edges(s, &pens, &ends)).collect(); + let first: Vec> = lines.iter().map(|&(s, _)| edges(s, &pens)).collect(); if round == 0 { // The chunks as they lie before any is moved: each one's left edge on its anchor. - let left_at = |s: usize, x: f64, chunk: Option<(f64, f64)>| chunk.map_or(x, |(left, right)| x - anchor_of(s) * (right - left)); - let first_left: Vec = lines.iter().zip(&first).map(|(&(s, lx), &chunk)| left_at(s, lx, chunk)).collect(); + let left_at = |s: usize, x: f64, chunk: Option<(f64, f64, usize)>| chunk.map_or(x, |(left, right, _)| x - anchor_of(s) * (right - left)); + first_left = lines.iter().zip(&first).map(|(&(s, lx), &chunk)| left_at(s, lx, chunk)).collect(); wants = targets .iter() .map(|&(i, x)| { - let chunk = edges(i, &pens, &ends); + let chunk = edges(i, &pens); // The pen of a right-to-left chunk's first character is right of the chunk's left edge. - let pen = pens[i].zip(chunk).map_or(0.0, |(p, (left, _))| p - left); + let pen = pens[i].zip(chunk).map_or(0.0, |(p, (left, ..))| p.start - left); (i, left_at(i, x, chunk) + pen - first_left[line_of(i)]) }) .collect(); - // A line's layout starts at the line's left edge, and its first chunk's left edge is `left` from there. - line_left = first_left.iter().zip(&first).map(|(&x, chunk)| x - chunk.map_or(0.0, |(left, _)| left)).collect(); } - let (mut line, mut moved, mut worst) = (usize::MAX, 0.0, 0.0f64); - for &(i, want) in &wants { - let l = line_of(i); - if l != line { - (line, moved) = (l, 0.0); + // A line's layout starts at the line's left edge, and its first chunk's left edge is `left` + // from there (right-to-left kerning can move that chunk). + line_left = first_left.iter().zip(&first).map(|(&x, chunk)| x - chunk.map_or(0.0, |(left, ..)| left)).collect(); + // Kerning a character widens its advance to the right: the glyphs drawn right of it move + // right, and so does a right-to-left glyph's own left edge (its kerning is at its left). In + // drawing order the kerning before an absolutely placed character is a cut after the + // character before it (before that character when it runs right to left), and what is drawn + // after the cut moves. Places in drawing order are doubled (from 2) to put cuts between them. + // Each gap between two placed characters drawn one after the other takes the kerning of one + // cut in it, so placing a character moves none placed before it. Kerning is drawn at the + // character's horizontal scale, so each correction is divided by it. + let mut worst = 0.0f64; + let mut from = 0; + unplaced = 0; + while from < wants.len() { + let l = line_of(wants[from].0); + let to = from + wants[from..].partition_point(|&(i, _)| line_of(i) == l); + let on_line = &wants[from..to]; + from = to; + let Some((p0, _, at)) = first[l] else { continue }; + let Some(home) = pens[at].map(|p| 2 * p.rank + 2) else { continue }; + // A character drawn as part of the glyph before it (a mark) is kerned where that glyph is. + // The kerning stays on the character just before the placed one: a mark whose kerning + // differs from its base's is drawn as a glyph of its own, and kerning the base would put + // the space between the base and its mark. + // Each search stops at the nearest earlier character with a glyph, at the latest the + // placed character before it, so the searches of a line read each character once. + let mut cuts: Vec<(usize, usize)> = on_line + .iter() + .filter(|&&(i, _)| pens[i].is_some()) + .filter_map(|&(i, _)| (lines[l].0..i).rev().find_map(|j| pens[j]).map(|k| (2 * k.rank + if k.rtl { 1 } else { 3 }, i - 1))) + .collect(); + cuts.sort_unstable(); + let mut points: Vec<(usize, f64, f64)> = + on_line.iter().filter_map(|&(i, want)| pens[i].map(|p| (2 * p.rank + 2, p.start, want))).collect(); + points.sort_by_key(|p| p.0); + // The character kerned at the first cut between places `a` and `b`. + let cut = |a: usize, b: usize| cuts.get(cuts.partition_point(|c| c.0 <= a)).filter(|c| c.0 < b).map(|c| c.1); + let split = points.partition_point(|p| p.0 < home); + // Drawn right of the line's first chunk: kerning moves the character away from it. + let (mut prev, mut moved) = (home, 0.0); + for &(place, start, want) in &points[split..] { + if let Some(k) = cut(prev, place) { + let d = want - (start - p0 + moved); + cells[k].kern += d / spacing_scale(&cells[k].style, false); + moved += d; + worst = worst.max(d.abs()); + } else { + unplaced += 1; + } + prev = place; + } + // Drawn left of it: kerning moves the chunk (and what lies between) away from the character. + (prev, moved) = (home, 0.0); + for &(place, start, want) in points[..split].iter().rev() { + if let Some(k) = cut(place, prev) { + let d = start - p0 - moved - want; + cells[k].kern += d / spacing_scale(&cells[k].style, false); + moved += d; + worst = worst.max(d.abs()); + } else { + unplaced += 1; + } + prev = place; } - let (Some(p), Some((p0, _))) = (pens[i], first[l]) else { continue }; - let d = want - (p - p0 + moved); - cells[i - 1].kern += d; - moved += d; - worst = worst.max(d.abs()); } if worst < 0.01 { break; } } let starts = lines.iter().zip(&line_left).map(|(&(s, _), &lx)| (s, 0.0, lx)).collect(); - (Justify::Left, line_left[0], starts) + (Justify::Left, line_left[0], starts, unplaced) } /// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor` @@ -1875,13 +1944,40 @@ fn advance(t: &TextObject) -> f64 { measure(t).glyphs.iter().map(|g| g.advance).sum() } -/// Pen x (start) and end x of the glyph that starts at each character's byte offset. -fn pens(lay: &TextLayout, bytes: &[usize]) -> (Vec>, Vec>) { - let mut at: HashMap = HashMap::with_capacity(lay.glyphs.len()); - for g in &lay.glyphs { - at.entry(g.byte).or_insert((g.origin.x, g.origin.x + g.advance)); +/// Where the glyph that starts a character is drawn on a line of point type. +#[derive(Clone, Copy)] +struct Pen { + /// Its left edge. Manual kerning on a right-to-left glyph is at its left, and the glyph is + /// drawn right of it: its left edge is past that kerning. + start: f64, + /// The right edge of its advance. + end: f64, + /// Its place in the order its line is drawn in, from the left. + rank: usize, + rtl: bool, +} + +/// The [`Pen`] of the glyph that starts at each character's byte offset (`bytes`, parallel to `cells`). +fn pens(lay: &TextLayout, bytes: &[usize], cells: &[Cell]) -> Vec> { + // A line's glyphs are laid out in the order they are drawn. + let mut at: HashMap = HashMap::with_capacity(lay.glyphs.len()); + for (rank, g) in lay.glyphs.iter().enumerate() { + at.entry(g.byte).or_insert((rank, g)); } - bytes.iter().map(|b| at.get(b).map_or((None, None), |&(s, e)| (Some(s), Some(e)))).unzip() + bytes + .iter() + .zip(cells) + .map(|(b, c)| { + let &(rank, g) = at.get(b)?; + let mut start = g.origin.x; + if g.rtl { + let st = c.final_style(); + let kern = st.kerning.unwrap_or(0.0) / 1000.0 * st.size * spacing_scale(&st, false); + start += finite(kern); + } + Some(Pen { start, end: g.origin.x + g.advance, rank, rtl: g.rtl }) + }) + .collect() } type Key = (Option, Option); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index d55e8b930e..816e17f15d 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{GradientKind, Paint}; use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_svg::{import, import_with_report}; -use vectorcraft_text::{FontDb, layout}; +use vectorcraft_text::{FontDb, layout, spacing_scale}; fn svg(body: &str) -> String { format!(r#"{body}"#) @@ -808,6 +808,114 @@ fn absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edg } } +/// Document-space left and right edges of the natural advance of the characters in `bytes` (the +/// manual kerning that places the next chunk left out: it is at a glyph's right when the glyph runs +/// left to right and at its left when it runs right to left). +fn chunk_edges(t: &TextObject, bytes: std::ops::Range) -> (f64, f64) { + let lay = layout(FontDb::global(), t); + let mut starts = vec![]; + let mut at = 0; + for r in &t.runs { + starts.push((at, &r.style)); + at += r.text.len(); + } + let style = |byte: usize| starts.iter().rev().find(|(s, _)| *s <= byte).map(|(_, st)| *st).unwrap(); + let (mut left, mut right) = (f64::INFINITY, f64::NEG_INFINITY); + for g in lay.glyphs.iter().filter(|g| bytes.contains(&g.byte)) { + let st = style(g.byte); + let kern = st.kerning.unwrap_or(0.0) / 1000.0 * st.size * spacing_scale(st, false); + let x = (t.xf * g.origin).x; + let (l, r) = if g.rtl { (x + kern, x + g.advance) } else { (x, x + g.advance - kern) }; + (left, right) = (left.min(l), right.max(r)); + } + assert!(left.is_finite(), "no glyph in {bytes:?}"); + (left, right) +} + +/// SVG 1.1 §10.4 and SVG 2 §11.10.1.1 (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties): +/// every absolutely placed character starts a chunk anchored on its own x, also in right-to-left +/// text whose characters run right to left, on any line. +#[test] +fn absolutely_placed_right_to_left_characters_sit_on_their_own_x() { + // (markup, share of each chunk's advance left of its x, each chunk's bytes and x) + let two_lines = |anchor: &str| { + format!( + r#"abאב"# + ) + }; + type Chunks = Vec<(std::ops::Range, f64)>; + let cases: Vec<(String, f64, Chunks)> = vec![ + (two_lines("start"), 1.0, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]), + (two_lines("middle"), 0.5, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]), + (two_lines("end"), 0.0, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]), + (r#"אב"#.into(), 1.0, vec![(0..2, 100.0), (2..4, 160.0)]), + (r#"אב"#.into(), 0.0, vec![(0..2, 100.0), (2..4, 160.0)]), + // Three lines, the characters of the last out of order and overlapping. + ( + r#"אבגדהוז"#.into(), + 1.0, + vec![(0..4, 300.0), (5..7, 60.0), (7..9, 120.0), (10..12, 200.0), (12..14, 90.0), (14..16, 205.0)], + ), + // Left-to-right characters among right-to-left ones on the same line. + (r#"אbc"#.into(), 1.0, vec![(0..2, 100.0), (2..3, 160.0), (3..4, 220.0)]), + // Chunks of several characters. + ( + r#"abאבגד"#.into(), + 1.0, + vec![(0..2, 20.0), (3..7, 100.0), (7..11, 250.0)], + ), + ( + r#"abאבגד"#.into(), + 0.5, + vec![(0..2, 20.0), (3..7, 100.0), (7..11, 250.0)], + ), + // A placed character after a letter with a combining mark. + ( + r#"אָב"#.into(), + 1.0, + vec![(4..6, 160.0)], + ), + (r#"éb"#.into(), 0.0, vec![(3..4, 160.0)]), + // Characters stretched by `lengthAdjust`, whose kerning is drawn at their scale. + ( + r#"abאב"#.into(), + 1.0, + vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)], + ), + // Left to right, the right-to-left characters still each start at their own x. + ( + r#"abאב"#.into(), + 0.0, + vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)], + ), + ( + r#"abcd"#.into(), + 0.0, + vec![(0..2, 20.0), (3..4, 100.0), (4..5, 160.0)], + ), + ]; + let unplaced = |w: &[String]| w.iter().any(|w| w.contains("absolutely placed")); + for (body, share, chunks) in cases { + let (d, w) = import_with_report(&svg(&body)).unwrap(); + assert!(!unplaced(&w), "{body}: {w:?}"); + let t = only_text(&d); + if body.contains("lengthAdjust") { + assert!(t.runs.iter().any(|r| r.style.h_scale < 99.0), "{body}: {:?}", t.runs); + } + for (bytes, x) in chunks { + let (left, right) = chunk_edges(t, bytes.clone()); + let want = x - share * (right - left); + assert!(close(left, want, 0.05), "{body}: the chunk {bytes:?} spans {left}..{right}, so it does not start at {want}"); + } + } + // A left-to-right character between right-to-left ones is drawn right next to the one after + // it, with no kerning between them: that one keeps its place in the line, and a warning says so. + let (d, w) = import_with_report(&svg(r#"אbג"#)).unwrap(); + assert!(unplaced(&w), "{w:?}"); + let (left, right) = chunk_edges(only_text(&d), 2..3); + assert!(close(right, 160.0, 0.05), "b spans {left}..{right}"); +} + #[test] fn a_small_line_gap_in_tiny_type_keeps_its_leading() { // 0.0085 against an auto leading of 0.006 differs by less than 0.01 but is not a whole multiple of it. diff --git a/crates/text/src/colr.rs b/crates/text/src/colr.rs index 66281f70f0..281ef8a65f 100644 --- a/crates/text/src/colr.rs +++ b/crates/text/src/colr.rs @@ -148,6 +148,9 @@ struct Tree<'a> { painted: Option, /// It paints with the foreground colour (palette index 0xFFFF). foreground_used: bool, + /// A fill draws nothing (a radial gradient none of whose circles has a positive radius), so a + /// glyph with nothing else is colour art that draws nothing, not its outline. + drew_nothing: bool, } impl<'a> Tree<'a> { @@ -166,6 +169,7 @@ impl<'a> Tree<'a> { too_large: false, painted: None, foreground_used: false, + drew_nothing: false, } } @@ -254,7 +258,7 @@ impl<'a> Tree<'a> { self.close(); } let root = self.groups.pop()?; - if root.children.is_empty() { + if root.children.is_empty() && !self.drew_nothing { return None; } Some(Arc::new(Node::group(NO_ID, root.children))) @@ -308,22 +312,27 @@ impl<'a> Tree<'a> { } Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend } => { let spread = spread(*extend); - let (c0, c1, r0, r1) = (point(*c0), point(*c1), f64::from(*r0), f64::from(*r1)); + let line = stops(color_stops, self); + // Without a stop, the others would repeat over a different interval. + if !spread.is_pad() && line.len() < color_stops.len() { + self.unsupported.get_or_insert("a repeating or reflecting gradient with a stop missing from the palette"); + return None; + } + let Some((c0, r0, c1, r1, mut stops)) = non_negative_radii(point(*c0), f64::from(*r0), point(*c1), f64::from(*r1), line, *extend) + else { + self.drew_nothing = true; + return None; + }; let concentric = (c0 - c1).hypot() < 1e-6; if (r0 > 1e-6 && !concentric) || r1 <= r0 || (c0 - c1).hypot() >= r1 { self.unsupported.get_or_insert("a radial gradient from a circle"); return None; } - let mut stops = stops(color_stops, self); if stops.iter().any(|s| !(0.0..=1.0).contains(&s.offset)) { self.unsupported.get_or_insert("a radial gradient with stops past its circles"); return None; } if !spread.is_pad() { - if stops.len() < color_stops.len() { - self.unsupported.get_or_insert("a repeating or reflecting gradient with a stop missing from the palette"); - return None; - } // A run of the stops is the whole way from the center to the circle: one that // starts at a circle (a first stop past the center comes as one) repeats over // less of it. The normalized ends can be a rounding error off 0 and 1. @@ -383,6 +392,87 @@ fn normalized(mut stops: Vec) -> Option<(Vec, f32, f Some((stops, first, if span > 0.0 { last } else { first + 1.0 })) } +/// A radial gradient's circles (`c0`, `r0`) and (`c1`, `r1`) and color line `stops` (0 and 1 at +/// the circles) recast so that neither circle has a negative radius, as normalizing a color line +/// to 0..1 can give. Circles of a negative radius are not drawn, only those on the side of the +/// circle of radius 0 (the tip of the cone) where the radius is positive. With pad, the stops on +/// the other side become one stop at the tip with the color there; with repeat and reflect, the +/// circles move along by whole periods of the color line to the drawn side +/// (https://learn.microsoft.com/en-us/typography/opentype/spec/colr, Radial gradients). `None` +/// when no circle is drawn. +fn non_negative_radii( + c0: Point, + r0: f64, + c1: Point, + r1: f64, + mut stops: Vec, + extend: Extend, +) -> Option<(Point, f64, Point, f64, Vec)> { + if (r0 >= 0.0 && r1 >= 0.0) || stops.is_empty() { + return Some((c0, r0, c1, r1, stops)); + } + if r0 == r1 { + return None; + } + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + let tip = r0 / (r0 - r1); + let grows = r1 > r0; + let (from, to, stops) = match extend { + Extend::Repeat | Extend::Reflect => { + // A reflected color line repeats every 2: once forward, then back. + let (period, stops) = if extend == Extend::Reflect { + let forward = stops.iter().map(|s| GradientStop { offset: s.offset / 2.0, ..s.clone() }); + let back = stops.iter().rev().map(|s| GradientStop { offset: 1.0 - s.offset / 2.0, ..s.clone() }); + (2.0, forward.chain(back).collect()) + } else { + (1.0, stops) + }; + let from = if grows { (tip / period).ceil() * period } else { (tip / period).floor() * period - period }; + (from, from + period, stops) + } + _ => { + // The color line runs from 0 to 1, and further where its stops do. + let first = stops.first().map_or(0.0, |s| f64::from(s.offset).min(0.0)); + let last = stops.last().map_or(1.0, |s| f64::from(s.offset).max(1.0)); + let (from, to) = match (grows, tip) { + (true, t) if t < last => (t, last), + (true, t) => (t, t + 1.0), + (false, t) if t > first => (first, t), + (false, t) => (t - 1.0, t), + }; + let line = Gradient { stops, ..Gradient::default() }; + // The color at `w`, as a stop at `offset` of the new line. + let at = |w: f64, offset: f32| { + let (color, opacity) = line.sample(w as f32); + GradientStop { opacity, ..GradientStop::new(offset, color) } + }; + // Stops at the tip stay on the drawn side of its stop, so a sharp change of color there + // keeps the color on that side. + let mut kept: Vec = line + .stops + .iter() + .filter(|s| (from - 1e-6..=to + 1e-6).contains(&f64::from(s.offset))) + .map(|s| GradientStop { offset: ((f64::from(s.offset) - from) / (to - from)).clamp(0.0, 1.0) as f32, ..s.clone() }) + .collect(); + if grows { + kept.insert(0, at(tip, 0.0)); + if kept.last().is_none_or(|s| s.offset < 1.0) { + kept.push(at(to, 1.0)); + } + } else { + kept.push(at(tip, 1.0)); + if kept.first().is_none_or(|s| s.offset > 0.0) { + kept.insert(0, at(from, 0.0)); + } + } + (from, to, kept) + } + }; + let circle = |w: f64| (c0.lerp(c1, w), (r0 + w * (r1 - r0)).max(0.0)); + let ((c0, r0), (c1, r1)) = (circle(from), circle(to)); + Some((c0, r0, c1, r1, stops)) +} + impl ColorPainter for Tree<'_> { fn push_transform(&mut self, transform: Transform) { let t = self.transform() * affine(&transform); @@ -575,10 +665,24 @@ impl ColorPainter for Pixels<'_, '_> { Brush::LinearGradient { p0, p1, color_stops, extend: e } => { peniko::Gradient::new_linear(point(p0), point(p1)).with_stops(stops(color_stops).as_slice()).with_extend(extend(e)).into() } - Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => peniko::Gradient::new_two_point_radial(point(c0), r0, point(c1), r1) - .with_stops(stops(color_stops).as_slice()) - .with_extend(extend(e)) - .into(), + Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => { + let line = color_stops + .iter() + .map(|s| { + let (c, a) = colour(&self.palette, self.foreground, s.palette_index, s.alpha).unwrap_or((Color::BLACK, 0.0)); + GradientStop { opacity: a, ..GradientStop::new(s.offset, c) } + }) + .collect(); + let Some((c0, r0, c1, r1, line)) = non_negative_radii(point(c0), f64::from(r0), point(c1), f64::from(r1), line, e) else { return }; + let stops: Vec = line + .iter() + .map(|s| { + let [r, g, b, a] = s.color.to_rgba8(s.opacity); + peniko::ColorStop::from((s.offset, peniko::Color::from_rgba8(r, g, b, a))) + }) + .collect(); + peniko::Gradient::new_two_point_radial(c0, r0 as f32, c1, r1 as f32).with_stops(stops.as_slice()).with_extend(extend(e)).into() + } Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => { peniko::Gradient::new_sweep(point(c0), start_angle.to_radians(), end_angle.to_radians()) .with_stops(stops(color_stops).as_slice()) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 671ebee7a1..9e4cb10e36 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -256,6 +256,11 @@ pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_do /// back before bisecting the earlier ones. const MAX_DRAWN_CHECKS: usize = 256; +/// Most breaks, or hyphenation points, [`break_line`] measures as drawn one by one when a glyph of +/// the line has a negative advance: a later break can then be drawn narrower than an earlier one, +/// and bisecting can miss the latest break that fits. +const MAX_DRAWN_CHECKS_STEPPING_BACK: usize = 1024; + /// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make /// millions of outlines. const MAX_LEADER_COPIES: f64 = 4096.0; @@ -357,8 +362,9 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins /// from `i` ending before glyph `end` (with a hyphen when `hyphenated`) fits `width` as it is drawn: /// once the line holds a tab, whose width depends on what is drawn before it, the line reads on /// while the least it can be drawn at fits `width`, then ends at the latest legal break where it -/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, the earlier ones without a -/// hyphen are bisected); hyphenation points are measured the same way. +/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, or +/// [`MAX_DRAWN_CHECKS_STEPPING_BACK`] when a glyph of the line has a negative advance, the earlier +/// ones without a hyphen are bisected); hyphenation points are measured the same way. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -377,24 +383,26 @@ pub(super) fn break_line( fits: Option<&dyn Fn(usize, bool) -> bool>, ) -> (usize, bool) { // The line ending at `chosen`, once it holds a tab, when it fits as drawn; otherwise the latest - // of the legal `breaks` before it that fits as drawn, measuring at most MAX_DRAWN_CHECKS of - // them from the latest back and bisecting the earlier ones without a hyphen; the first of them - // when none of those measured fits. - let settle = |tabbed: bool, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) { + // of the legal `breaks` before it that fits as drawn, measuring at most `checks` of them from + // the latest back and bisecting the earlier ones without a hyphen; the first of them when none + // of those measured fits. + let settle = |tabbed: bool, checks: usize, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) { let Some(fits) = fits.filter(|_| tabbed) else { return chosen }; if fits(chosen.0, chosen.1) { return chosen; } let earlier = breaks.get(breaks.partition_point(|b| b.0 <= i)..breaks.partition_point(|b| b.0 < chosen.0)).unwrap_or_default(); - if let Some(&b) = earlier.iter().rev().take(MAX_DRAWN_CHECKS).find(|b| fits(b.0, b.1)) { + if let Some(&b) = earlier.iter().rev().take(checks).find(|b| fits(b.0, b.1)) { return b; } // Without a hyphen, a line that ends later is drawn no narrower, so those breaks bisect. - let rest = earlier.get(..earlier.len().saturating_sub(MAX_DRAWN_CHECKS)).unwrap_or_default(); + let rest = earlier.get(..earlier.len().saturating_sub(checks)).unwrap_or_default(); let plain: Vec<&(usize, bool)> = rest.iter().filter(|b| !b.1).collect(); let fitting = plain.partition_point(|b| fits(b.0, b.1)); fitting.checked_sub(1).and_then(|k| plain.get(k).copied()).or(earlier.first()).copied().unwrap_or(chosen) }; + // How many breaks a line measures as drawn one by one. + let checks = |back: f64| if back > 0.0 { MAX_DRAWN_CHECKS_STEPPING_BACK } else { MAX_DRAWN_CHECKS }; let mut breaks: Vec<(usize, bool)> = Vec::new(); let mut tabbed = false; if !width.is_finite() { @@ -462,7 +470,7 @@ pub(super) fn break_line( let cluster_ink = if is_tab { 0.0 } else { cluster_width }; let cluster_back: f64 = if is_tab { 0.0 } else { g[j..cluster_end].iter().map(|glyph| (-glyph.adv).max(0.0)).sum() }; if gl.ch == '\u{2028}' { - return settle(tabbed, (cluster_end, false), &breaks); + return settle(tabbed, checks(back), (cluster_end, false), &breaks); } // Measured as the line is set when it ends with this cluster: only the cluster's last glyph // is narrowed, and only where the line may end. @@ -509,7 +517,7 @@ pub(super) fn break_line( j = cluster_end; } if j >= g.len() { - return settle(tabbed, (g.len(), false), &breaks); + return settle(tabbed, checks(back), (g.len(), false), &breaks); } // Hyphenate the word that overflows. A list marker starting the line is not part of a word. let word_start = last_break.map_or(first, |b| b.0); @@ -534,18 +542,19 @@ pub(super) fn break_line( .filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) .collect(); // The points the line may end at, the latest first. Measured as drawn, at most - // MAX_DRAWN_CHECKS of them are measured one by one, and the earlier ones are bisected: + // `checks(back)` of them are measured one by one, and the earlier ones are bisected: // each ends with a hyphen, so a later one is drawn no narrower. let points: Vec = pts.iter().rev().copied().filter(|&k| k <= j && k > i).collect(); let drawn = fits.filter(|_| tabbed); - for &k in points.iter().take(if drawn.is_some() { MAX_DRAWN_CHECKS } else { usize::MAX }) { + let one_by_one = checks(back); + for &k in points.iter().take(if drawn.is_some() { one_by_one } else { usize::MAX }) { let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::() + hyphen_glyph(&g[k - 1]).adv; if drawn.map_or(w <= width + EPS, |fits| fits(k, true)) { return (k, true); } } if let Some(fits) = drawn { - let earlier: Vec = points.iter().skip(MAX_DRAWN_CHECKS).rev().copied().collect(); + let earlier: Vec = points.iter().skip(one_by_one).rev().copied().collect(); let fitting = earlier.partition_point(|&k| fits(k, true)); if let Some(&k) = fitting.checked_sub(1).and_then(|n| earlier.get(n)) { return (k, true); @@ -555,7 +564,7 @@ pub(super) fn break_line( // Break candidates and greedy overflow are cluster boundaries; hyphenation also returns only // cluster starts, so the resulting end always keeps each source cluster together. match last_break { - Some(b) if b.0 > i => settle(tabbed, b, &breaks), + Some(b) if b.0 > i => settle(tabbed, checks(back), b, &breaks), _ => (j, false), } } diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index e7753c8c04..0d4ec2834e 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -24,6 +24,10 @@ fn pushes_in(para: &ParaStyle) -> bool { /// Paragraphs longer than this (glyphs) are always broken line by line. const MAX_COMPOSE_GLYPHS: usize = 200_000; +/// The widths tried, halving the difference each time, for the longest break of a line placed again +/// at its own metrics in more room than it was broken in ([`place_flow_line`]). +const REFIT_STEPS: usize = 24; + /// Whether every line of area type in `regions` is as wide however tall it is and wherever it is set: /// rectangular cells without text wrap. fn constant_width(regions: Option<&[Region]>) -> bool { @@ -415,25 +419,31 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC let word = word_width(sg, *i, line_end, para.kinsoku); // A composed line in a frame whose lines are not all as wide is placed at its own tallest // metrics, as the composer placed it (`shaped::Shaped`): its room is the room across its height. - let est = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + let composed_line = composed.as_ref().and_then(|lines| lines.get(*para_line)).is_some_and(|&(end, _)| end > *i); + let mut est = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { Some(&(end, _)) if end > *i && !constant_width(pen.regions) => sg.get(*i..end).and_then(Metrics::max).unwrap_or(est), _ => est, }; + // A line broken here in such a frame is placed again when it turns out taller than it was + // placed: its room is the room across its own height. + let refit = !composed_line && pen.regions.is_some() && !constant_width(pen.regions); // Place, break, then settle the baseline on the line's real metrics (moving on to the // next row/column if it no longer fits). loop { + let before = refit.then(|| pen.clone()); let first_in_region = pen.prev.is_none(); - let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { + let Some((mut baseline, mut x0, mut x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { cx.out.overflow = cx.text.len() > if *i < n { sg[*i].byte } else { pr.start }; return None; }; - let width = x1 - x0 - ind_l - ind_r; let hyphenate = (para.hyphenate && (*hyphen_limit == 0 || *hyphen_run < *hyphen_limit)).then_some((¶.hyphenation, *zone)); - let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + let glyphs: &[SGlyph] = sg; + // The line broken in room `width` wide: its end, whether it ends with a hyphen, and whether + // it pushed characters in. + let break_in = |width: f64| match composed.as_ref().and_then(|lines| lines.get(*para_line)) { Some(&(end, hyph)) if end > *i => (end, hyph, false), _ if *i < n => { let start = *i; - let glyphs: &[SGlyph] = sg; // A horizontal line with right-to-left text is drawn in another order than its // text's, and its tabs take their stops in the order they are drawn: whether the line // ending before glyph `end` fits is measured as it is drawn ([`drawn_width`]). @@ -478,8 +488,56 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC } _ => (n, false, false), }; - let m = Metrics::max(&sg[*i..end]).unwrap_or(*pm); - baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { + let (mut end, mut hyph, mut pushed_in) = break_in(x1 - x0 - ind_l - ind_r); + let metrics = |end: usize| glyphs.get(*i..end).and_then(Metrics::max).unwrap_or(*pm); + let m = metrics(end); + // The baseline moves from where `est` placed it to where the line's metrics set it, unless + // the line was placed at metrics of its own (below). + let mut settle = true; + if let Some(before) = before + && m != est + { + if m.union(est) != est { + // Taller than it was placed: place it again at metrics that hold both, and break it + // again in the room there. + *pen = before; + est = est.union(m); + continue; + } + // Broken again in the room of a taller line, it no longer holds what made that line + // taller. It goes where its own metrics place it when the room there holds the room it + // was broken in, and otherwise stays where it was placed, inside the room measured. + settle = false; + let mut own = before; + if let Some((b, a, z, _)) = own.place(m, ind_l, ind_r, word) + && a <= x0 + EPS + && z >= x1 - EPS + { + // The room there may hold more of the line, up to what would make it taller than + // `m`: the longest break that does not is between the two widths (a line breaks no + // earlier in more room). + let (mut lo, mut hi) = (x1 - x0 - ind_l - ind_r, z - a - ind_l - ind_r); + let wider = break_in(hi); + if metrics(wider.0).union(m) == m { + (end, hyph, pushed_in) = wider; + } else { + for _ in 0..REFIT_STEPS { + let mid = (lo + hi) / 2.0; + let line = break_in(mid); + if metrics(line.0).union(m) == m { + (end, hyph, pushed_in) = line; + lo = mid; + } else { + hi = mid; + } + } + } + *pen = own; + (baseline, x0, x1) = (b, a, z); + } + } + let m = metrics(end); + baseline += if !settle || same_baseline || (pen.regions.is_none() && first_in_region) { 0.0 } else if pen.model == LeadingModel::EmBoxTop { // The em box top is fixed (the frame's top, or the line above's): the baseline diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index b8bad9a246..e80d890cef 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -386,7 +386,7 @@ fn slant_boundaries(slants: &mut [Slant]) -> (Vec<(f64, f64)>, bool, Vec) } /// The most pieces a line's band is cut into to keep it inside an Invert Wrap shape grown by its -/// offset. +/// offset, when [`grown_band`] gives up, or out of a wrap shape shrunk by its offset. const INVERT_WRAP_PIECES: f64 = 64.0; /// The edges of `polys` that reach into the band `top..bottom` (`top <= bottom`), leaving out those @@ -582,6 +582,297 @@ fn eroded_cover(polys: &[Vec], top: f64, bottom: f64, d: f64) -> Vec<(f64 union_intervals(out) } +/// The most steps [`grown_band`] takes for one band and one wrap shape: edges, pairs of edges and +/// pairs of grown pieces looked at, and pieces looked at for each x it tests. +const MAX_INVERT_WRAP_WORK: usize = 1 << 20; + +#[cfg(test)] +thread_local! { + /// How many steps [`grown_band`] took on this thread. + static GROWN_BAND_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Adds `n` to `work`; false when that passes [`MAX_INVERT_WRAP_WORK`]. +fn spend(work: &mut usize, n: usize) -> bool { + *work = work.saturating_add(n); + #[cfg(test)] + GROWN_BAND_WORK.set(GROWN_BAND_WORK.get().saturating_add(n)); + *work <= MAX_INVERT_WRAP_WORK +} + +/// The x of the edge `p`–`q` (not horizontal) at `y`: the x of its end at that end's height. +fn edge_x(p: Point, q: Point, y: f64) -> f64 { + if y == p.y { + p.x + } else if y == q.y { + q.x + } else { + line_x(p, q, y) + } +} + +/// A trapezoid with a horizontal top and bottom: its top and bottom heights, then the x of its left +/// edge at each of them, then the x of its right edge at each of them. +type Trapezoid = (f64, f64, f64, f64, f64, f64); + +/// The shape `polys` (even-odd) between the heights `y0..y1` (`y0 < y1`) as trapezoids with +/// horizontal tops and bottoms, cut at each anchor's height and wherever two edges cross, whose union +/// (each trapezoid with its outline) is the shape with its outline there. Edges that lie along one +/// another give trapezoids that touch, and contours that cancel each other give none. `None` when +/// [`MAX_INVERT_WRAP_WORK`] runs out. +fn trapezoids(polys: &[Vec], y0: f64, y1: f64, work: &mut usize) -> Option> { + let mut edges: Vec<(Point, Point)> = polys + .iter() + .flat_map(|p| (0..p.len()).map(move |i| (p[i], p[(i + 1) % p.len()]))) + .filter(|(a, b)| a.y != b.y && a.y.max(b.y) > y0 && a.y.min(b.y) < y1) + .map(|(a, b)| if a.y < b.y { (a, b) } else { (b, a) }) + .collect(); + if !spend(work, edges.len()) { + return None; + } + edges.sort_by(|u, v| u.0.y.total_cmp(&v.0.y)); + let mut ys: Vec = edges.iter().flat_map(|(p, q)| [p.y, q.y]).filter(|&y| y > y0 && y < y1).chain([y0, y1]).collect(); + ys.sort_by(f64::total_cmp); + ys.dedup(); + let mut out = Vec::new(); + let (mut next, mut active) = (0, Vec::new()); + for w in ys.windows(2) { + let (a, b) = (w[0], w[1]); + // The edges that span the slab `a..b`: every anchor's height is a cut, so an edge that + // starts at or above `a` and ends below it reaches `b`. + while let Some(&e) = edges.get(next).filter(|e| e.0.y <= a) { + active.push(e); + next += 1; + } + active.retain(|e: &(Point, Point)| e.1.y >= b); + if !spend(work, active.len()) { + return None; + } + // The heights within the slab at which two edges cross: their order left to right differs + // at its top and bottom. + let mut cuts = vec![a, b]; + let ends: Vec<(f64, f64)> = active.iter().map(|&(p, q)| (edge_x(p, q, a), edge_x(p, q, b))).collect(); + let mut order: Vec = (0..ends.len()).collect(); + order.sort_by(|&i, &j| ends[i].0.total_cmp(&ends[j].0).then(ends[i].1.total_cmp(&ends[j].1))); + if order.windows(2).any(|w| ends[w[0]].1 > ends[w[1]].1) { + if !spend(work, ends.len() * ends.len()) { + return None; + } + for (i, &(sa, sb)) in ends.iter().enumerate() { + for &(ta, tb) in &ends[i + 1..] { + let (da, db) = (sa - ta, sb - tb); + if da * db < 0.0 { + let y = a + (b - a) * (da / (da - db)); + if y > a && y < b { + cuts.push(y); + } + } + } + } + cuts.sort_by(f64::total_cmp); + cuts.dedup(); + } + for c in cuts.windows(2) { + let (c, d) = (c[0], c[1]); + // No two edges cross within `c..d`, so their order at its middle holds throughout, and + // the shape (even-odd) lies between the first and second, the third and fourth, and so on. + let mid = (c + d) * 0.5; + let mut xs: Vec<(f64, f64, f64)> = active.iter().map(|&(p, q)| (line_x(p, q, mid), edge_x(p, q, c), edge_x(p, q, d))).collect(); + if !spend(work, xs.len()) { + return None; + } + xs.sort_by(|u, v| u.0.total_cmp(&v.0)); + for pair in xs.as_chunks::<2>().0 { + let ((_, l0, l1), (_, r0, r1)) = (pair[0], pair[1]); + if r0 > l0 || r1 > l1 { + out.push((c, d, l0, l1, r0, r1)); + } + } + } + } + Some(out) +} + +/// A convex polygon as its lower and upper outlines (the least and the greatest y at each x), each +/// left to right from its least x to its greatest, with its x range and y range. +struct Convex { + lower: Vec, + upper: Vec, + xs: (f64, f64), + ys: (f64, f64), +} + +impl Convex { + /// The convex hull of `pts`. + fn hull(mut pts: Vec) -> Convex { + let cross = |o: Point, a: Point, b: Point| (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); + let chain = |pts: &[Point], turns: fn(f64) -> bool| { + let mut out: Vec = Vec::with_capacity(pts.len()); + for &p in pts { + while let [.., o, a] = out[..] { + if turns(cross(o, a, p)) { + break; + } + out.pop(); + } + out.push(p); + } + // The points at the greatest x after the first are on the other outline. + while let [.., a, b] = out[..] { + if a.x != b.x { + break; + } + out.pop(); + } + out + }; + pts.sort_by(|p, q| p.x.total_cmp(&q.x).then(p.y.total_cmp(&q.y))); + let lower = chain(&pts, |c| c > 0.0); + pts.sort_by(|p, q| p.x.total_cmp(&q.x).then(q.y.total_cmp(&p.y))); + let upper = chain(&pts, |c| c < 0.0); + let xs = (lower.first().map_or(f64::NAN, |p| p.x), lower.last().map_or(f64::NAN, |p| p.x)); + let ys = (lower.iter().fold(f64::INFINITY, |m, p| m.min(p.y)), upper.iter().fold(f64::NEG_INFINITY, |m, p| m.max(p.y))); + Convex { lower, upper, xs, ys } + } + + /// The y range of the polygon at `x`, within its x range. + fn at(&self, x: f64) -> (f64, f64) { + (chain_y(&self.lower, x), chain_y(&self.upper, x)) + } +} + +/// The y of the outline `chain` (left to right, no two points at one x) at `x`, within its x range. +fn chain_y(chain: &[Point], x: f64) -> f64 { + let i = chain.partition_point(|p| p.x < x); + match (i.checked_sub(1).and_then(|h| chain.get(h)), chain.get(i)) { + (Some(p), Some(q)) => p.y + (x - p.x) / (q.x - p.x) * (q.y - p.y), + (None, Some(q)) => q.y, + (Some(p), None) => p.y, + (None, None) => f64::NAN, + } +} + +/// The x at which some segment of `a` and some segment of `b` (outlines left to right) are at the +/// same y, strictly between the ends of the x range they share. +fn chain_crossings(a: &[Point], b: &[Point], out: &mut Vec, work: &mut usize) -> bool { + for s in a.windows(2) { + let first = b.partition_point(|q| q.x <= s[0].x).saturating_sub(1); + for t in b[first..].windows(2).take_while(|t| t[0].x < s[1].x) { + if !spend(work, 1) { + return false; + } + let (x0, x1) = (s[0].x.max(t[0].x), s[1].x.min(t[1].x)); + if x1 <= x0 { + continue; + } + let (d0, d1) = (chain_y(s, x0) - chain_y(t, x0), chain_y(s, x1) - chain_y(t, x1)); + if d0 * d1 < 0.0 { + out.push(x0 + (x1 - x0) * (d0 / (d0 - d1))); + } + } + } + true +} + +/// The x within `lo..hi` at which the shape `polys` (even-odd) grown by `off` (> 0) across and +/// along holds every height of the band `top..bottom` (`top <= bottom`): the x whose vertical line +/// across the band has each of its points within `off`, across and along, of the shape or its +/// outline. The shape from `off` above the band to `off` below it, and on to the nearest anchors +/// past those heights, is cut into [`trapezoids`]; each +/// grown by `off` is a convex polygon, and the line at an x lies in their union when the y ranges +/// they give at that x join up across the band. Those ranges change only at the polygons' corners +/// and where their outlines cross each other or the band's top or bottom, so the line is tested +/// once between each two of those x. `None` when that takes more than [`MAX_INVERT_WRAP_WORK`] +/// steps. +fn grown_band(polys: &[Vec], off: f64, top: f64, bottom: f64, (lo, hi): (f64, f64)) -> Option> { + let mut work = 0; + // The trapezoids reach on to the nearest anchor past `off` above and below the band, so that + // the shape's outline exactly `off` from the band counts. + let (y0, y1) = (top - off, bottom + off); + let ys = || polys.iter().flatten().map(|p| p.y); + let below = ys().filter(|&y| y < y0).fold(f64::NEG_INFINITY, f64::max); + let above = ys().filter(|&y| y > y1).fold(f64::INFINITY, f64::min); + let traps = trapezoids(polys, if below.is_finite() { below } else { y0 }, if above.is_finite() { above } else { y1 }, &mut work)?; + let mut grown: Vec = traps + .into_iter() + .filter(|&(_, _, l0, l1, r0, r1)| l0.min(l1) - off < hi && r0.max(r1) + off > lo) + .map(|(y0, y1, l0, l1, r0, r1)| { + let corners = [(l0, y0), (r0, y0), (l1, y1), (r1, y1)]; + let pts = corners + .into_iter() + .flat_map(|(x, y)| [(-off, -off), (off, -off), (-off, off), (off, off)].map(|(dx, dy)| Point::new(x + dx, y + dy))); + Convex::hull(pts.collect()) + }) + .collect(); + grown.sort_by(|p, q| p.xs.0.total_cmp(&q.xs.0)); + // The x at which the line's place among the polygons can change. + let mut xs = vec![lo, hi]; + for (j, p) in grown.iter().enumerate() { + xs.extend(p.lower.iter().chain(&p.upper).map(|v| v.x)); + for chain in [&p.lower, &p.upper] { + for s in chain.windows(2) { + for y in [top, bottom] { + let (d0, d1) = (s[0].y - y, s[1].y - y); + if d0 * d1 < 0.0 { + xs.push(s[0].x + (s[1].x - s[0].x) * (d0 / (d0 - d1))); + } + } + } + } + let ((_, x1), (y0, y1)) = (p.xs, p.ys); + for q in grown[j + 1..].iter().take_while(|q| q.xs.0 < x1) { + if !spend(&mut work, 1) { + return None; + } + let (v0, v1) = q.ys; + if v0 <= y1 + && y0 <= v1 + && !(chain_crossings(&p.upper, &q.lower, &mut xs, &mut work) && chain_crossings(&q.upper, &p.lower, &mut xs, &mut work)) + { + return None; + } + } + } + xs.retain(|&x| x >= lo && x <= hi); + xs.sort_by(f64::total_cmp); + xs.dedup(); + // Test the line between each two of those x, walking left to right with the polygons whose x + // range holds it. + let mut out: Vec<(f64, f64)> = Vec::new(); + let (mut next, mut active, mut ranges): (usize, Vec<&Convex>, Vec<(f64, f64)>) = (0, Vec::new(), Vec::new()); + for w in xs.windows(2) { + let x = (w[0] + w[1]) * 0.5; + while let Some(p) = grown.get(next).filter(|p| p.xs.0 <= x) { + active.push(p); + next += 1; + } + active.retain(|p| p.xs.1 >= x); + if !spend(&mut work, active.len()) { + return None; + } + ranges.clear(); + ranges.extend(active.iter().map(|p| p.at(x))); + ranges.sort_by(|u, v| u.0.total_cmp(&v.0)); + // The line is held from the band's top to `reach`, up to the first range that starts past + // it; a band of no height is held when a range reaches it. + let (mut reach, mut met) = (top, false); + for &(a, b) in &ranges { + if a > reach { + break; + } + reach = reach.max(b); + met |= b >= top; + } + if met && reach >= bottom { + match out.last_mut() { + Some(l) if l.1 >= w[0] => l.1 = w[1], + _ => out.push((w[0], w[1])), + } + } + } + Some(out) +} + impl Region { pub(super) fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec { // A frame out of range has no cells, and a wrap shape out of range is left out. @@ -654,10 +945,20 @@ impl Region { } else { subtract_intervals(&iv, &pad(eroded_cover(polys, top, bottom, -off))) } + } else if let Some(held) = (*invert && *off > 0.0) + .then(|| iv.first().zip(iv.last()).and_then(|(f, l)| grown_band(polys, *off, top, bottom, (f.0 - inset, l.1 + inset)))) + .flatten() + { + // The x at which the shape grown by the offset holds the whole band. + intersect_intervals(&iv, &shrink(held)) } else if *invert { - // The band is cut into pieces. Each height of a piece `y0..y1` is within the offset - // of some height from `y0` plus the offset to `y1` less the offset, so an x the - // shape holds at all of those heights is inside the grown shape over the piece. In + // With no offset, or when measuring the grown shape exactly takes more than + // [`MAX_INVERT_WRAP_WORK`] steps, the band is cut into pieces, which can leave a line + // shorter than the grown shape allows, never longer. + // + // Each height of a piece `y0..y1` is within the offset of some height from `y0` + // plus the offset to `y1` less the offset, so an x the shape holds at all of those + // heights is inside the grown shape over the piece. In // a piece less than twice the offset tall, each height of the piece is within the // offset of every height from `y1` less the offset to `y0` plus the offset, so an x // the shape holds at any of them is. Text within the offset of such an x, for every @@ -1255,8 +1556,8 @@ fn intersecting_intervals_looks_at_each_interval_once() { } /// A line in area type with an Invert Wrap comb of 500 teeth and an offset keeps a span inside -/// each tooth, left to right, through every piece of its band, and intersecting the spans with each -/// piece looks at each span once. +/// each tooth, left to right, and measuring the grown comb takes a few dozen steps for each tooth, +/// not one for each pair of teeth. #[cfg(test)] #[test] fn a_line_inside_an_invert_wrap_comb_keeps_a_span_in_each_tooth() { @@ -1268,11 +1569,92 @@ fn a_line_inside_an_invert_wrap_comb_keeps_a_span_in_each_tooth() { let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 0.25, invert: true } }; let cells = Region::cells(&frame, &LayoutOptions::default(), &[wrap]); INTERSECT_PAIRS.set(0); + GROWN_BAND_WORK.set(0); let got = cells[0].spans(40.0, 60.0); let want: Vec<(f64, f64)> = (0..500).map(|k| f64::from(k) * 2.0).map(|x| ((x - 0.25).max(0.0), x + 1.25)).collect(); assert_spans(&got, &want); - // At most 64 pieces, each looking at no more than the spans so far and the tooth spans. - assert!(INTERSECT_PAIRS.get() <= 64 * (500 + 500), "{} pairs", INTERSECT_PAIRS.get()); + // The line's one span is intersected once with the 500 spans the grown comb holds. + assert!(INTERSECT_PAIRS.get() <= 1 + 500, "{} pairs", INTERSECT_PAIRS.get()); + assert!(GROWN_BAND_WORK.get() <= 40 * 500, "{} steps", GROWN_BAND_WORK.get()); +} + +/// The spans of a 120 pt wide, 100 pt tall frame over the band `top..bottom` with one Invert Wrap +/// object through `contour` and `offset`. +#[cfg(test)] +fn invert_wrap_spans(contour: &[(f64, f64)], offset: f64, top: f64, bottom: f64) -> Vec<(f64, f64)> { + let frame = Rect::new(0.0, 0.0, 120.0, 100.0).to_path(0.1); + let path = PathData::from_bezpath(&closed(&[contour])); + let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset, invert: true } }; + Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(top, bottom) +} + +/// The 100 pt square whose right edge steps in to x = 80 from `y0` to `y1`, with 79 more anchors +/// along its left edge, which leave its shape as it is. +#[cfg(test)] +fn notched_square(y0: f64, y1: f64) -> Vec<(f64, f64)> { + let mut contour = vec![(0.0, 0.0), (100.0, 0.0), (100.0, y0), (80.0, y0), (80.0, y1), (100.0, y1), (100.0, 100.0), (0.0, 100.0)]; + contour.extend((1..80).rev().map(|i| (0.0, 1.25 * f64::from(i)))); + contour +} + +/// With Invert Wrap and an offset, a line beside a notch the offset fills reaches as far as the +/// grown shape does at every height of its band, however tall the band and however many anchors +/// the shape has: a notch 0.5625 pt tall with an offset of 1, and one 0.4 pt tall with an offset +/// of 0.25 over a band of 100 pt. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_lets_a_line_beside_a_notch_reach_the_grown_shape() { + assert_spans(&invert_wrap_spans(¬ched_square(50.5, 51.0625), 1.0, 0.0, 100.0), &[(0.0, 101.0)]); + assert_spans(&invert_wrap_spans(¬ched_square(50.6, 51.0), 0.25, 0.0, 100.0), &[(0.0, 100.25)]); + // A band of no height in the notch, and one in a notch the offset does not fill. + assert_spans(&invert_wrap_spans(¬ched_square(50.5, 51.0625), 1.0, 50.7, 50.7), &[(0.0, 101.0)]); + assert_spans(&invert_wrap_spans(¬ched_square(50.0, 53.0), 1.0, 0.0, 100.0), &[(0.0, 81.0)]); + assert_spans(&invert_wrap_spans(¬ched_square(50.0, 53.0), 1.0, 51.5, 51.5), &[(0.0, 81.0)]); +} + +/// With Invert Wrap and an offset, a line beside a notch with sloped sides reaches as far as the +/// grown shape does at the notch's tip: 1 pt above and below the tip, each side is 8 pt from it. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_lets_a_line_beside_a_sloped_notch_reach_the_grown_shape() { + let notch = [(0.0, 0.0), (100.0, 0.0), (100.0, 45.15), (60.0, 50.15), (100.0, 55.15), (100.0, 100.0), (0.0, 100.0)]; + assert_spans(&invert_wrap_spans(¬ch, 1.0, 40.0, 60.0), &[(0.0, 69.0)]); + assert_spans(&invert_wrap_spans(¬ch, 5.0, 40.0, 60.0), &[(0.0, 105.0)]); +} + +/// With Invert Wrap and an offset, a band of no height exactly the offset past a wrap shape's +/// bottom edge or its tip still holds a line as wide as the grown edge or tip. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_holds_a_band_exactly_the_offset_past_the_shape() { + let bar = [(20.0, 0.0), (60.0, 0.0), (60.0, 10.0), (20.0, 10.0)]; + assert_spans(&invert_wrap_spans(&bar, 1.0, 11.0, 11.0), &[(19.0, 61.0)]); + assert!(invert_wrap_spans(&bar, 1.0, 11.5, 11.5).is_empty()); + let tip = [(20.0, 0.0), (80.0, 0.0), (50.0, 10.0)]; + assert_spans(&invert_wrap_spans(&tip, 1.0, 11.0, 11.0), &[(49.0, 51.0)]); + let tip = [(20.0, 20.0), (50.0, 10.0), (80.0, 20.0)]; + assert_spans(&invert_wrap_spans(&tip, 1.0, 9.0, 9.0), &[(49.0, 51.0)]); +} + +/// A wrap shape whose edges cross one another tens of thousands of times takes no more than +/// [`MAX_INVERT_WRAP_WORK`] steps to measure exactly; it is then cut into pieces, and the line +/// still stays inside the shape grown by the offset. An offset that is not a number is 0. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_bounds_its_work_on_a_tangled_shape() { + let star: Vec = (0..301) + .map(|k| f64::from(k * 150) * std::f64::consts::TAU / 301.0) + .map(|a| Point::new(50.0 + 45.0 * a.cos(), 50.0 + 45.0 * a.sin())) + .collect(); + GROWN_BAND_WORK.set(0); + assert!(grown_band(std::slice::from_ref(&star), 1.0, 10.0, 90.0, (0.0, 120.0)).is_none()); + assert!(GROWN_BAND_WORK.get() <= 2 * MAX_INVERT_WRAP_WORK, "{} steps", GROWN_BAND_WORK.get()); + let contour: Vec<(f64, f64)> = star.iter().map(|p| (p.x, p.y)).collect(); + let spans = invert_wrap_spans(&contour, 1.0, 10.0, 90.0); + assert!(spans.iter().all(|&(a, b)| a >= 4.0 && b <= 96.0), "{spans:?}"); + let square = [(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)]; + assert_spans(&invert_wrap_spans(&square, f64::NAN, 45.0, 55.0), &invert_wrap_spans(&square, 0.0, 45.0, 55.0)); + assert_spans(&invert_wrap_spans(&square, 0.0, 45.0, 55.0), &[(40.0, 60.0)]); } /// Along the line, text keeps the inset beside a wrap object, around it and, with Invert Wrap, diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 3a83a5673b..1878ce962d 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -646,6 +646,55 @@ fn a_negative_advance_after_a_tab_keeps_a_fitting_line_whole() { assert_eq!(ended, (5, false)); } +/// A long tabbed line with a glyph that steps back ends at the latest break that fits as drawn, even +/// where a later break than one that does not fit fits again. +#[test] +fn a_long_tabbed_line_that_steps_back_ends_at_the_latest_break_that_fits_as_drawn() { + // a (0), tab (1), then b and a space for each u: breaks before 4 + 2u. + let text = format!("a\t{}", "b ".repeat(400)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if byte == 2 { -0.5 } else { 1.0 }, ch)).collect(); + let glyphs = synthetic_glyphs(&specs); + let drawn_fits = |end: usize, _hyphenated: bool| end <= 10 || end == 200; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(&text, &glyphs, 0, 1e6, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (200, false)); +} + +/// A tabbed line with a glyph that steps back, ending in a word with more hyphenation points than +/// are measured one by one, ends at the latest point that fits as drawn. +#[test] +fn a_tabbed_line_that_steps_back_ends_at_the_latest_hyphenation_point_that_fits_as_drawn() { + let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() }; + // a (0), tab (1), then a word of 400 syllables of two glyphs from glyph 2: points before 4, 6, ... + let text = format!("a\t{}", "कि".repeat(400)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if byte == 2 { -1.0 } else { 1.0 }, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + for glyph in &mut glyphs { + glyph.hyphen_language = crate::hyphen::Language::from_tag(Some("hi")); + glyph.sx = 0.001; + } + let drawn_fits = |end: usize, _hyphenated: bool| end <= 20 || end == 50; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = break_line( + &text, + &glyphs, + 0, + 600.0, + &[], + 0.0, + Some((&opts, 0.0)), + Burasagari::None, + Kinsoku::None, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + fits, + ); + assert_eq!(ended, (50, true)); +} + /// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed /// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last /// glyph of the last cluster counts, and only where the line may end. diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index bdb818d3f3..7abae3f0eb 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -1939,6 +1939,12 @@ pub fn colr_variable_font() -> Option> { with_colr(&variable_font()?, family_names(COLR_VARIABLE_FAMILY, &axis_names)?, &colr) } +/// [`variable_font`] (one weight axis, 400 to 700) as `family` with `colr` and [`colr_font`]'s +/// palette. `None` when it can't be built. +pub fn colr_variable_font_with(family: &str, colr: &write_fonts::tables::colr::Colr) -> Option> { + with_colr(&variable_font()?, family_names(family, &[])?, colr) +} + /// A `name` table for Regular of `family`, with `more` records. fn family_names(family: &str, more: &[(u16, &str)]) -> Option> { let ps = family.replace(' ', ""); diff --git a/crates/text/src/tests/colr.rs b/crates/text/src/tests/colr.rs index c3a0499af6..c6c4696f16 100644 --- a/crates/text/src/tests/colr.rs +++ b/crates/text/src/tests/colr.rs @@ -25,7 +25,7 @@ use vectorcraft_doc::color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_doc::{Node, NodeKind}; use crate::colr::{ColourContent, colour_glyph}; -use crate::test_fonts::{COLR_GREEN, COLR_RED}; +use crate::test_fonts::{COLR_BLUE, COLR_GREEN, COLR_RED}; const FOREGROUND: Color = Color::Rgb { r: 0.25, g: 0.5, b: 0.75 }; @@ -358,6 +358,147 @@ fn a_radial_gradient_from_a_circle_around_its_centre_is_vector_art() { assert_eq!(offsets, [0.25, 1.0], "the stops start a quarter of the way out"); } +/// A padded radial gradient whose color line starts before 0 starts at the circle of radius 0: +/// circles of a negative radius are not drawn, so the stops before that circle become one stop +/// there with the color between them +/// (https://learn.microsoft.com/en-us/typography/opentype/spec/colr, Radial gradients). With its +/// centers apart, the gradient is focused on the tip of its cone; with its centers together, it +/// is centered there with no stop below 0. +#[test] +fn a_radial_gradient_with_a_stop_before_0_starts_at_its_circle_of_radius_0() { + use vectorcraft_doc::color::{Gradient, GradientStop}; + use write_fonts::tables::colr::{ColorLine, ColorStop, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let one = F2Dot14::from_f32(1.0); + let half = F2Dot14::from_f32(0.5); + let line = ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(-0.5), 0, half), ColorStop::new(one, 1, one)]); + let w = FWord::new; + // Half-opaque red at -0.5 and blue at 1: at 0, a third of the way from red to blue, in color + // and opacity. + let red = GradientStop { opacity: 0.5, ..GradientStop::new(0.0, rgb(COLR_RED)) }; + let tip = Gradient { stops: vec![red, GradientStop::new(1.0, rgb(COLR_BLUE))], ..Gradient::default() }.sample(1.0 / 3.0); + assert!((tip.1 - 2.0 / 3.0).abs() < 1e-6, "{tip:?}"); + for (family, c1_x, focal) in [("Colrtest Tip", 250, Some(300.0)), ("Colrtest Dot", 300, None)] { + let font = v1_font(family, COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(300), w(300), UfWord::new(0), w(c1_x), w(300), UfWord::new(400)), o) + }); + let root = art(&font, COLOUR_CHAR); + let Paint::Gradient(g) = fill(&children(&root)[0]) else { panic!("{family}: a gradient") }; + let offsets: Vec = g.gradient.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 1.0], "{family}"); + let first = &g.gradient.stops[0]; + assert_eq!((first.color, first.opacity), tip, "{family}: the color at the circle of radius 0"); + assert_eq!(g.gradient.stops[1].color, rgb(COLR_BLUE), "{family}"); + let geom = g.geom.unwrap(); + assert!((geom.start.x - f64::from(c1_x)).abs() < 1e-3 && (geom.start.y + 300.0).abs() < 1e-3, "{family}: {geom:?}"); + assert!(((geom.end - geom.start).hypot() - 400.0).abs() < 1e-3, "{family}: {geom:?}"); + assert_eq!(geom.focal.map(|f| ((f.x * 1e3).round() / 1e3, (f.y * 1e3).round() / 1e3)), focal.map(|x| (x, -300.0)), "{family}"); + } +} + +/// A sharp change of color at the circle of radius 0 of a gradient whose color line starts +/// before 0 keeps the color beyond it: red up to 0, then green fading to blue. +#[test] +fn a_sharp_change_of_color_at_the_circle_of_radius_0_keeps_the_color_beyond_it() { + use write_fonts::tables::colr::{ColorLine, ColorStop, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let (one, zero) = (F2Dot14::from_f32(1.0), F2Dot14::from_f32(0.0)); + let stops = vec![ + ColorStop::new(F2Dot14::from_f32(-1.0), 0, one), + ColorStop::new(zero, 0, one), + ColorStop::new(zero, 2, one), + ColorStop::new(one, 1, one), + ]; + let line = ColorLine::new(Extend::Pad, 4, stops); + let w = FWord::new; + let font = v1_font("Colrtest Edge", COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(300), w(300), UfWord::new(0), w(250), w(300), UfWord::new(400)), o) + }); + let root = art(&font, COLOUR_CHAR); + let Paint::Gradient(g) = fill(&children(&root)[0]) else { panic!("a gradient") }; + let stops = &g.gradient.stops; + let colors: Vec = stops.iter().map(|s| s.color).collect(); + assert_eq!(colors, [rgb(COLR_RED), rgb(COLR_RED), rgb(COLR_GREEN), rgb(COLR_BLUE)], "{stops:?}"); + assert!(stops[..3].iter().all(|s| s.offset.abs() < 1e-5) && stops[3].offset == 1.0, "{stops:?}"); +} + +/// A glyph drawn into an image draws a radial gradient with a circle of a negative radius from the +/// circle of radius 0, as vector art does: with pad, the color at the tip of the cone and the +/// colors on its drawn side, not one solid color. A composite that keeps only its backdrop makes +/// the glyph an image. Red runs to blue; the circles are around the `O`'s middle and larger than it. +#[test] +fn an_image_of_a_radial_gradient_with_a_stop_past_its_circle_of_radius_0_draws_from_that_circle() { + use write_fonts::tables::colr::{ColorLine, ColorStop, CompositeMode, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let one = F2Dot14::from_f32(1.0); + let w = FWord::new; + type Check = fn([u8; 3]) -> bool; + // Red at -0.5 (a start circle of radius -250 once normalized): the colors run from a third of + // the way to blue at the tip to blue. Blue at 1.5 (an end circle of radius -250): they run + // from red at the outer circle, which lies outside the `O`, to two thirds of the way to blue + // at the tip. Either way every opaque pixel holds some blue. + let cases: [(&str, f32, f32, u16, u16, Check); 2] = + [("Colrtest Tip Image", -0.5, 1.0, 0, 500, |[r, _, b]| b > 20 && r < 240), ("Colrtest Rim Image", 0.0, 1.5, 500, 0, |[_, _, b]| b > 20)]; + for (family, first, last, r0, r1, check) in cases { + let line = + ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(first), 0, one), ColorStop::new(F2Dot14::from_f32(last), 1, one)]); + let font = v1_font(family, COLOUR_CHAR, |o| { + let radial = ColrPaint::radial_gradient(line.clone(), w(290), w(330), UfWord::new(r0), w(290), w(330), UfWord::new(r1)); + ColrPaint::composite(ColrPaint::glyph(solid(2), o), CompositeMode::Dest, ColrPaint::glyph(radial, o)) + }); + let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("{family}: an image") }; + let pixmap = vello_cpu::Pixmap::from_png(std::io::Cursor::new(image.blob.bytes.as_slice())).unwrap(); + let pixels: Vec<[u8; 3]> = pixmap.take_unpremultiplied().into_iter().filter(|p| p.a == 255).map(|p| [p.r, p.g, p.b]).collect(); + assert!(pixels.len() > 100, "{family}: the `O` is drawn"); + let wrong: Vec<_> = pixels.iter().filter(|p| !check(**p)).take(5).collect(); + assert!(wrong.is_empty(), "{family}: {wrong:?}"); + } + // Repeat and reflect draw their bands, not one color. + for (family, extend) in [("Colrtest Repeat Image", Extend::Repeat), ("Colrtest Reflect Image", Extend::Reflect)] { + let line = ColorLine::new(extend, 2, vec![ColorStop::new(F2Dot14::from_f32(-0.5), 0, one), ColorStop::new(one, 1, one)]); + let font = v1_font(family, COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(290), w(330), UfWord::new(0), w(290), w(330), UfWord::new(100)), o) + }); + let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("{family}: an image") }; + let pixmap = vello_cpu::Pixmap::from_png(std::io::Cursor::new(image.blob.bytes.as_slice())).unwrap(); + let colors: std::collections::HashSet<[u8; 3]> = + pixmap.take_unpremultiplied().into_iter().filter(|p| p.a == 255).map(|p| [p.r, p.g, p.b]).collect(); + assert!(colors.len() > 10, "{family}: {colors:?}"); + } +} + +/// A radial gradient whose circles both have the same negative radius at a variable font's +/// instance draws no circle, so a glyph painted with only that gradient is colour art that draws +/// nothing, not its outline. The radii are 10 at the default weight and -10 at 700. +#[test] +fn a_radial_gradient_with_no_circle_drawn_leaves_the_glyph_empty() { + use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, Extend, VarColorLine, VarColorStop}; + use write_fonts::tables::variations::{ItemVariationData, ItemVariationStore, RegionAxisCoordinates, VariationRegion, VariationRegionList}; + use write_fonts::types::{F2Dot14, FWord, GlyphId16, UfWord}; + let bytes = colr_font(false).unwrap(); + let read = skrifa::FontRef::new(&bytes).unwrap(); + let gid = |ch| GlyphId16::new(u16::try_from(read.charmap().map(ch).unwrap().to_u32()).unwrap()); + let (one, fixed) = (F2Dot14::from_f32(1.0), 0xFFFF_FFFF); + let line = + VarColorLine::new(Extend::Pad, 2, vec![VarColorStop::new(F2Dot14::from_f32(0.0), 0, one, fixed), VarColorStop::new(one, 1, one, fixed)]); + let w = FWord::new; + let radial = ColrPaint::var_radial_gradient(line, w(290), w(330), UfWord::new(10), w(290), w(330), UfWord::new(10), 0); + let mut colr = Colr::new(0, None, None, 0); + colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(gid(COLOUR_CHAR), ColrPaint::glyph(radial, gid('O')))])).into(); + // One 16-bit delta for each of x0, y0, r0, x1, y1 and r1, at full weight. + let deltas: Vec = [0i16, 0, -20, 0, 0, -20].iter().flat_map(|d| d.to_be_bytes()).collect(); + let region = VariationRegion::new(vec![RegionAxisCoordinates::new(F2Dot14::from_f32(0.0), one, one)]); + let data = ItemVariationData::new(6, 1, vec![0], deltas); + colr.item_variation_store = Some(ItemVariationStore::new(VariationRegionList::new(1, vec![region]), vec![Some(data)])).into(); + let font = crate::test_fonts::colr_variable_font_with("Colrtest No Circle", &colr).unwrap(); + let font = skrifa::FontRef::new(&font).unwrap(); + let bold = font.axes().location([("wght", 700.0)]); + let Some(ColourContent::Art(root)) = colour_glyph(&font, (&bold).into(), 1000.0, u32::from(gid(COLOUR_CHAR).to_u16()), FOREGROUND).0 else { + panic!("colour art") + }; + assert!(children(&root).is_empty(), "{:?}", children(&root).iter().map(|c| c.kind_label()).collect::>()); +} + /// A COLRv1 color line through `stops` (offset, palette index) that extends by `extend`. fn color_line(extend: write_fonts::tables::colr::Extend, stops: &[(f32, u16)]) -> write_fonts::tables::colr::ColorLine { use write_fonts::tables::colr::{ColorLine, ColorStop}; diff --git a/crates/text/src/tests/combos.rs b/crates/text/src/tests/combos.rs index 4f57aa6972..cdb5a31fee 100644 --- a/crates/text/src/tests/combos.rs +++ b/crates/text/src/tests/combos.rs @@ -378,8 +378,8 @@ fn x_width() -> f64 { fn composer_x_mixed_sizes_in_a_stepped_frame() { // A frame as wide as the first line down to 40 pt and half as wide below. A 36 pt word makes // the line it is on reach below the step, where the frame is narrow: the every-line composer - // breaks the paragraph with each line's measure where its height sets it, not as the - // single-line composer does, and every line keeps within its room. + // breaks the paragraph with each line's measure where its height sets it, and every line keeps + // within its room. let w = x_width(); let set = |x: CharStyle, composer: Composer| { let mut t = stepped(w, w * 0.5, 40.0, 300.0); @@ -394,8 +394,10 @@ fn composer_x_mixed_sizes_in_a_stepped_frame() { // The 36 pt line reaches below the step and is set at the narrow measure. let tall = &l.lines[1]; assert!(tall.ascent > 30.0 && tall.baseline + tall.descent > 40.0 && tall.avail.1 < w * 0.5 + 0.01, "{:?}", l.lines); + // The single-line composer measures each line across its height too, and breaks this + // paragraph the same way. let (greedy, _) = set(sans(36.0), Composer::SingleLine); - assert_ne!(every, greedy); + assert_eq!(every, greedy); // Uniform sizes keep the breaks they had. let (uniform, _) = set(sans(12.0), Composer::EveryLine); assert_eq!(uniform, ["Destroy all creatures with toughness X or", "less. Then draw a card for each creature", "destroyed this way."]); @@ -435,8 +437,10 @@ fn composer_x_mixed_sizes_around_text_wrap() { across && !text[g.byte..].starts_with(char::is_whitespace) && g.origin.x < object.x1 && g.origin.x + g.advance > object.x0 }); assert_eq!(hits.count(), 0, "{:?}", l.lines); + // The single-line composer measures each line across its height too, and breaks this + // paragraph the same way. let (greedy, _, _) = set(Composer::SingleLine); - assert_ne!(every, greedy); + assert_eq!(every, greedy); } #[test] @@ -463,13 +467,42 @@ fn composer_x_mixed_sizes_in_a_frame_that_widens() { assert_eq!(greedy, ["Destroy all", "creatures", "with toughness X or less. Then draw", "a card for each creature destroyed this", "way."]); } +#[test] +fn single_line_composer_measures_a_line_across_its_tallest_glyph() { + // A frame wide enough for "Destroy all creatures X" down to 20 pt and as wide as "Destroy all" + // below, and a rectangle as wide with an object (6 pt wrap offset) beside that part. The first + // line starts with 12 pt words and takes in a 36 pt word, which sets it lower and reaches below + // 20 pt: the single-line composer measures its room across that height, every line that + // reaches the narrow part is set in it, and the first line keeps the 12 pt words that fit + // above it. + let w = natural_width("Destroy all creatures X", 12.0) + 30.0; + let narrow = natural_width("Destroy all", 12.0) + 2.0; + let runs = vec![run("Destroy all creatures ", sans(12.0)), run("X", sans(36.0)), run(" or less. Then draw a card.", sans(12.0))]; + let object = Rect::new(narrow + 6.0, 26.0, w + 10.0, 300.0); + let mut beside = area(runs.clone(), Rect::new(0.0, 0.0, w, 300.0)); + beside.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&object.to_path(0.1)), wrap: Default::default() }]; + let mut shaped = stepped(w, narrow, 20.0, 300.0); + shaped.runs = runs; + for (name, mut t) in [("stepped frame", shaped), ("text wrap", beside)] { + t.para.composer = Composer::SingleLine; + let l = layout(db(), &t); + assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{name}: {:?}", l.lines); + let low = l.lines.iter().filter(|li| li.baseline + li.descent > 20.01); + assert!(low.clone().all(|li| li.avail.1 <= narrow + 0.01), "{name}: {:?}", l.lines); + // The 36 pt word is set, on a line in the narrow part. + assert!(low.clone().any(|li| li.ascent > 30.0), "{name}: {:?}", l.lines); + assert_eq!(line_texts(&t, &l)[0], "Destroy all creatures", "{name}: {:?}", l.lines); + assert!(l.lines[0].baseline < 20.0 && l.lines[0].avail.1 > w - 0.01, "{name}: {:?}", l.lines); + } +} + #[test] fn composer_x_mixed_sizes_overflow_a_shaped_frame() { // A stepped frame too short for the paragraph: the every-line composer still breaks the lines // that fit as one paragraph, with the lines past the frame's end measured as wide as the last // line the frame holds. Here that is the narrow part, so the lines that fit break as in a frame - // tall enough for the whole paragraph, and not as the single-line composer breaks them. They - // are set in order, each once and within its room, and the frame overflows. + // tall enough for the whole paragraph. They are set in order, each once and within its room, + // and the frame overflows. let w = x_width(); let set = |bottom: f64, composer: Composer| { let mut t = stepped(w, w * 0.5, 40.0, bottom); @@ -487,8 +520,10 @@ fn composer_x_mixed_sizes_overflow_a_shaped_frame() { assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]); let (whole, ..) = set(300.0, Composer::EveryLine); assert_eq!(every, whole[..every.len()]); + // The single-line composer measures each line across its height too, and breaks the lines + // that fit the same way. let (greedy, ..) = set(75.0, Composer::SingleLine); - assert_ne!(every, greedy); + assert_eq!(every, greedy); // A frame with no room for a line sets none. for composer in [Composer::EveryLine, Composer::SingleLine] { let mut t = stepped(w, w * 0.5, 4.0, 8.0); @@ -529,8 +564,10 @@ fn composer_x_mixed_sizes_overflow_around_text_wrap() { assert_eq!(hits.count(), 0, "{:?}", l.lines); // The lines that fit break as in `composer_x_mixed_sizes_around_text_wrap`'s taller frame. assert_eq!(every, ["Destroy all creatures with", "toughness\u{a0}X", "or less. Then", "draw a card for", "each creature"]); + // The single-line composer measures each line across its height too, and breaks the lines + // that fit the same way. let (greedy, ..) = set(Composer::SingleLine); - assert_ne!(every, greedy); + assert_eq!(every, greedy); } #[test] diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index adbf3b2c70..4ea7696aae 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -415,8 +415,7 @@ fn stepped_frame(w: f64, narrow: f64, y: f64, bottom: f64) -> TextObject { /// A paragraph of mixed type sizes that a stepped frame cannot hold goes on in the next frame. The /// every-line composer breaks the lines the first frame holds as one paragraph, as in a frame tall -/// enough for all of it and not as the single-line composer breaks them, and the first frame -/// takes the text of those lines. The next frame sets the rest at its own width, without the +/// enough for all of it, and the first frame takes the text of those lines. The next frame sets the rest at its own width, without the /// first-line indent, and no character is lost or repeated. The frames take the same shares as /// when each is measured with the whole rest of the story. #[test] @@ -460,8 +459,10 @@ fn a_mixed_size_paragraph_continued_from_a_shaped_frame_is_composed_in_both() { assert_eq!(every, whole[..every.len()], "indent {indent}"); if indent == 0.0 { assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]); + // The single-line composer measures each line across its height too, and breaks the + // first frame's lines the same way. let (greedy, ..) = shares(Composer::SingleLine, indent); - assert_eq!(greedy, ["Destroy all creatures with toughness X", "or less. Then draw a", "card for each", "creature destroyed"]); + assert_eq!(greedy, every); } // The next frame starts at its left edge, and its lines keep within it. assert!((line_start(&lays[1], 0) - 200.0).abs() < 1e-6, "{}", line_start(&lays[1], 0)); diff --git a/crates/three-d/src/shadow.rs b/crates/three-d/src/shadow.rs index 66842abad2..2d32850423 100644 --- a/crates/three-d/src/shadow.rs +++ b/crates/three-d/src/shadow.rs @@ -172,8 +172,8 @@ impl ShadowCaster { /// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each /// shape whose entry in `casts` is true: the eye is placed for every shape, as for the solids - /// drawn, but the faces of the others are not kept (their range is empty). A shape that does - /// not inflate is left out, as its solid is; the result says which, in order. + /// drawn, but the solids of the others are not built (their range is empty). A casting shape + /// that does not inflate is left out, as its solid is; the result says which, in order. pub fn inflate( &mut self, shapes: &[(&PathData, FillRule)], @@ -182,11 +182,15 @@ impl ShadowCaster { options: Inflate, view: View, ) -> Vec, String>> { - inflate_faces(shapes, bounds, options, view, Keep::Faces) - .enumerate() - .map(|(i, r)| { - let (_, faces, projection) = r?; - if casts.get(i).copied().unwrap_or(true) { self.add(faces?, projection) } else { Ok(self.faces.len()..self.faces.len()) } + let mut solids = inflate_faces(shapes, bounds, options, view, Keep::Faces); + (0..shapes.len()) + .map(|i| { + if !casts.get(i).copied().unwrap_or(true) { + solids.pass(); + return Ok(self.faces.len()..self.faces.len()); + } + let (_, faces, projection) = solids.next().unwrap_or_else(|| Ok((vec![], Ok(vec![]), None)))?; + self.add(faces?, projection) }) .collect() } diff --git a/crates/three-d/src/shadow/tests.rs b/crates/three-d/src/shadow/tests.rs index 3fb3767177..00aedc3a53 100644 --- a/crates/three-d/src/shadow/tests.rs +++ b/crates/three-d/src/shadow/tests.rs @@ -185,3 +185,35 @@ fn every_effect_casts_a_shadow() { // The surface turns about the square's left edge, reaching 100 to either side of it. assert!(b.x1 > 100.0 && b.x0 < 100.0 + 1e-6, "{b:?}"); } + +/// An Inflate shape that casts nothing still places the eye with its puff, but its solid is not +/// built (nor, with no volume, its puff solved); the shapes that cast keep the very faces and +/// projection they have when every shape casts. +#[test] +fn an_inflate_shape_that_casts_nothing_builds_no_solid() { + let (a, b) = (square(100.0, 100.0, 100.0), square(150.0, 150.0, 30.0)); + let shapes = [(&a, FillRule::NonZero), (&b, FillRule::NonZero)]; + let bounds = Rect::new(100.0, 100.0, 200.0, 200.0); + let view = View { perspective: 60.0, rotation_y: 30.0, ..front() }; + let built = || crate::solid::INFLATED.with(std::cell::Cell::get); + let puffed = || crate::solid::PUFFED.with(std::cell::Cell::get); + for o in [Inflate::default(), Inflate { volume: 0.0, depth: 20.0, ..Inflate::default() }] { + let mut all = ShadowCaster::new(); + let every = all.inflate(&shapes, &[true, true], bounds, o, view); + let (start, solved) = (built(), puffed()); + let mut some = ShadowCaster::new(); + let kept = some.inflate(&shapes, &[false, true], bounds, o, view); + assert_eq!(built(), start + 1, "{o:?}: only the casting shape's solid is built"); + if o.volume == 0.0 { + assert_eq!(puffed(), solved + 1, "{o:?}: only the casting shape's puff is solved"); + } + let (Ok(first), Ok(second)) = (&every[0], &every[1]) else { panic!("{every:?}") }; + assert!(!first.is_empty() && !second.is_empty()); + assert_eq!(kept, vec![Ok(0..0), Ok(0..second.len())]); + assert_eq!(format!("{:?}", some.faces), format!("{:?}", &all.faces[second.clone()])); + assert_eq!(some.projection, all.projection); + let mut none = ShadowCaster::new(); + assert_eq!(none.inflate(&shapes, &[false, false], bounds, o, view), vec![Ok(0..0), Ok(0..0)]); + assert!(none.is_empty() && none.projection.is_none()); + } +} diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 55d270f97d..6badd8048a 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -29,9 +29,9 @@ pub use build::{extrude, plane}; pub use face_map::{FaceMap, extrude_face_map, plane_face_map}; #[cfg(test)] use inflate::GRID_WORK; -#[cfg(test)] -pub(crate) use inflate::INFLATED; pub(crate) use inflate::inflate_faces; +#[cfg(test)] +pub(crate) use inflate::{INFLATED, PUFFED, SOLVE_FIRST}; pub use inflate::{inflate, inflate_each, inflate_together}; #[cfg(test)] use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles}; diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 677e9cc8bc..5edf57a894 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -21,12 +21,35 @@ struct Puff { height: f64, } -/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. -fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { +/// The pieces each curve of `path` is sampled in for Inflate's solid ([`curve_pieces`]); an error +/// when its outline does not fit the solid's limits. +fn outline_pieces(path: &PathData, bounds: Rect, o: &Inflate) -> Result { // The sides take one face for each edge of the outline in each slice; the grid's faces are // counted as they are built. + curve_pieces(path, bounds.width().max(bounds.height()), if o.depth > 0.0 { slices(o) } else { 0 }, 0) +} + +/// Whether `path` has few enough anchors that [`curve_pieces`] might take it: fewer than +/// [`MAX_SOLID_POINTS`] contours, and at most three times as many anchors (a contour of one anchor +/// adds one, and every other takes at most two anchors for each segment it adds). Counts the +/// contours' anchors without reading them. +pub(super) fn might_fit(path: &PathData) -> bool { + let mut anchors = 0usize; + for (contours, sp) in path.subpaths.iter().enumerate() { + anchors = anchors.saturating_add(sp.anchors.len()); + if contours >= MAX_SOLID_POINTS || anchors > MAX_SOLID_POINTS.saturating_mul(3) { + return false; + } + } + true +} + +/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. +fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { + #[cfg(test)] + PUFFED.with(|n| n.set(n.get() + 1)); let size = bounds.width().max(bounds.height()); - let most = curve_pieces(path, size, if o.depth > 0.0 { slices(o) } else { 0 }, 0)?; + let most = outline_pieces(path, bounds, o)?; let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?; if rings.is_empty() { return Ok(None); @@ -66,7 +89,7 @@ pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: } /// [`inflate_together`], each solid built only as the iterator reaches it: a caller that stops early -/// builds none of the solids after. +/// builds none of the solids after (and with no volume, solves none of their puffs). pub fn inflate_each( shapes: &[(&PathData, FillRule)], bounds: Rect, @@ -77,28 +100,98 @@ pub fn inflate_each( } /// [`inflate_together`], keeping each solid's facets, its faces before projection, or both, as -/// `keep` asks. Every shape's membrane is solved at once (the eye depends on the highest puff); -/// each solid is built as the iterator reaches it. -pub(crate) fn inflate_faces( - shapes: &[(&PathData, FillRule)], - bounds: Rect, - options: Inflate, - view: View, - keep: Keep, -) -> impl Iterator> + use<> { +/// `keep` asks. Every shape's membrane is solved at once (the eye depends on the highest puff), +/// except with no volume: nothing puffs then, and each shape's membrane is solved when its solid +/// is taken. Each solid is built as the iterator reaches it. +pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, keep: Keep) -> Solids { let o = options.sanitized(); + let later = o.volume == 0.0; + #[cfg(test)] + let later = later && !SOLVE_FIRST.with(std::cell::Cell::get); + if later { + // A shape is copied to be solved later only when its anchors are few enough for the + // solid's point limit; a larger one is refused now, with the error its puff gives. + let shapes: Vec> = shapes + .iter() + .map(|(path, rule)| { + check_bounds(bounds)?; + if !might_fit(path) { + outline_pieces(path, bounds, &o)?; + } + Ok(((*path).clone(), *rule)) + }) + .collect(); + return Solids { pending: Pending::Later(shapes.into_iter()), bounds, o, view, reach: 0.0, keep }; + } let puffs: Vec, String>> = shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); - puffs.into_iter().map(move |p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach, keep), - Ok(None) => Ok((vec![], Ok(vec![]), None)), - Err(e) => Err(e), - }) + Solids { pending: Pending::Solved(puffs.into_iter()), bounds, o, view, reach, keep } +} + +/// The shapes' puffs still to take: every one solved when the shapes were laid out, or each +/// shape's path and fill rule (or the error its bounds or outline already gives), its puff solved +/// when its solid is taken. +enum Pending { + Solved(std::vec::IntoIter, String>>), + Later(std::vec::IntoIter>), +} + +/// The solids of [`inflate_faces`], one result for each shape in order, each built as it is taken. +pub(crate) struct Solids { + pending: Pending, + bounds: Rect, + o: Inflate, + view: View, + /// The highest puff of the shapes drawn together. + reach: f64, + keep: Keep, +} + +impl Solids { + /// Pass over the next shape without building its solid, or solving its puff if that waits. + pub(crate) fn pass(&mut self) { + match &mut self.pending { + Pending::Solved(puffs) => { + puffs.next(); + } + Pending::Later(shapes) => { + shapes.next(); + } + } + } +} + +impl Iterator for Solids { + type Item = Result; + + fn next(&mut self) -> Option { + let p = match &mut self.pending { + Pending::Solved(puffs) => puffs.next()?, + Pending::Later(shapes) => shapes.next()?.and_then(|(path, rule)| puff(&path, rule, self.bounds, &self.o)), + }; + Some(match p { + Ok(Some(p)) => inflated(p, self.bounds, self.o, self.view, self.reach, self.keep), + Ok(None) => Ok((vec![], Ok(vec![]), None)), + Err(e) => Err(e), + }) + } + + fn size_hint(&self) -> (usize, Option) { + match &self.pending { + Pending::Solved(puffs) => puffs.size_hint(), + Pending::Later(shapes) => shapes.size_hint(), + } + } } #[cfg(test)] thread_local! { + /// How many puffs this thread has solved, for the tests. + pub(crate) static PUFFED: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Solve every shape's puff before the first solid is taken even with no volume, as with + /// volume, for the tests that compare the two. + pub(crate) static SOLVE_FIRST: std::cell::Cell = const { std::cell::Cell::new(false) }; /// How many inflated solids this thread has built, for the tests. pub(crate) static INFLATED: std::cell::Cell = const { std::cell::Cell::new(0) }; /// The grid work of the last solid this thread inflated in full, for the tests. diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 7afce495ad..a75821e696 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -1102,6 +1102,105 @@ fn a_flat_twisted_inflate_matches_the_extrusion() { } } +/// With no volume nothing puffs, so each shape's membrane is solved only when its solid is taken: +/// a caller that stops early (past the object's face limit) solves none after. With volume, the +/// eye waits on the highest puff, so every membrane is solved first. +#[test] +fn a_flat_inflate_solves_each_puff_as_its_solid_is_taken() { + let shapes: Vec = (0..3).map(|i| shape(&square(120.0 * i as f64, 0.0, 100.0)).0).collect(); + let shapes: Vec<(&PathData, FillRule)> = shapes.iter().map(|p| (p, FillRule::NonZero)).collect(); + let b = Rect::new(0.0, 0.0, 340.0, 100.0); + let puffed = || PUFFED.with(std::cell::Cell::get); + let start = puffed(); + let mut each = inflate_each(&shapes, b, Inflate { depth: 20.0, volume: 0.0, ..Inflate::default() }, front()); + assert_eq!(puffed(), start, "no puff is solved before the first solid is taken"); + assert!(each.next().is_some_and(|f| f.is_ok_and(|f| !f.is_empty()))); + assert_eq!(puffed(), start + 1, "the later puffs wait until their solids are taken"); + drop(each); + assert_eq!(puffed(), start + 1); + let start = puffed(); + let each = inflate_each(&shapes, b, Inflate { depth: 20.0, ..Inflate::default() }, front()); + assert_eq!(puffed(), start + 3, "with volume, every puff places the eye"); + assert_eq!(each.count(), 3); +} + +/// With no volume, solving each puff as its solid is taken gives the very solids that solving +/// every puff first gives: their facets, their faces before projection and their projection, bit +/// for bit, with an empty shape and one that cannot be inflated among them. +#[test] +fn a_flat_inflate_solved_as_taken_matches_one_solved_first() { + let mut frame = square(-10.0, -10.0, 260.0); + frame.extend(square(-8.0, -8.0, 256.0)); + let mut circle = BezPath::new(); + circle.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((180.0, 60.0), 50.0), 0.01)); + let mut bad = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true); + bad.anchors[1].h_out = Point::new(f64::NAN, 0.0); + let paths = [shape(&square(0.0, 0.0, 100.0)).0, shape(&frame).0, PathData::new(vec![]), PathData::new(vec![bad]), shape(&circle).0]; + let shapes: Vec<(&PathData, FillRule)> = paths.iter().map(|p| (p, FillRule::EvenOdd)).collect(); + let b = Rect::new(-10.0, -10.0, 250.0, 250.0); + let turned = View { rotation_x: -20.0, rotation_y: 30.0, ..front() }; + let options = [ + Inflate { volume: 0.0, ..Inflate::default() }, + Inflate { volume: 0.0, depth: 40.0, twist: 90.0, taper: 0.5, ..Inflate::default() }, + Inflate { volume: 0.0, depth: 10.0, both_sides: true, cap: false, ..Inflate::default() }, + ]; + for view in [front(), turned, View { perspective: 60.0, ..turned }, View { perspective: 100.0, rotation_y: 150.0, ..turned }] { + for o in options { + for (keep, facets, mesh) in [(build::Keep::Facets, true, false), (build::Keep::Faces, false, true), (build::Keep::Both, true, true)] { + let solids = |first: bool| { + SOLVE_FIRST.with(|s| s.set(first)); + let out: Vec> = inflate_faces(&shapes, b, o, view, keep).collect(); + SOLVE_FIRST.with(|s| s.set(false)); + out + }; + let (taken, first) = (solids(false), solids(true)); + assert_eq!(taken.len(), 5); + // The solid keeps its facets, its faces before projection, or both, as asked. + let built = |r: &Result| { + r.as_ref().is_ok_and(|(f, m, _)| (!facets || !f.is_empty()) && (!mesh || m.as_ref().is_ok_and(|m| !m.is_empty()))) + }; + assert!(built(&taken[0]) && built(&taken[1]) && built(&taken[4]), "{o:?} {view:?} facets {facets} mesh {mesh}"); + assert!( + taken[2].as_ref().is_ok_and(|(f, m, p)| f.is_empty() && m.as_ref().is_ok_and(Vec::is_empty) && p.is_none()) && taken[3].is_err() + ); + assert_eq!(format!("{taken:?}"), format!("{first:?}"), "{o:?} {view:?} facets {facets} mesh {mesh}"); + } + } + } +} + +/// With no volume, a shape with too many anchors or contours for the solid is refused before it +/// is kept for later, with the error that solving every puff first gives it. +#[test] +fn a_flat_inflate_refuses_a_shape_past_the_point_limit_as_one_solved_first() { + use super::inflate::might_fit; + let many = MAX_SOLID_POINTS * 3 + 10; + let anchors: Vec = (0..many).map(|i| Point::new((i % 200) as f64, (i / 200) as f64 / 10.0)).collect(); + let long = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&anchors, true)]); + let mut nan = anchors.clone(); + if let Some(last) = nan.last_mut() { + *last = Point::new(f64::NAN, 0.0); + } + let invalid = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&nan, true)]); + let dots = PathData::new((0..=MAX_SOLID_POINTS).map(|i| vectorcraft_geom::SubPath::polyline(&[Point::new(i as f64, 0.0)], false)).collect()); + let square = shape(&square(0.0, 0.0, 100.0)).0; + assert!(might_fit(&square) && !might_fit(&long) && !might_fit(&invalid) && !might_fit(&dots)); + let paths = [square, long, invalid, dots]; + let shapes: Vec<(&PathData, FillRule)> = paths.iter().map(|p| (p, FillRule::EvenOdd)).collect(); + let b = Rect::new(-10.0, -10.0, 250.0, 250.0); + let o = Inflate { volume: 0.0, ..Inflate::default() }; + let solids = |first: bool| { + SOLVE_FIRST.with(|s| s.set(first)); + let out: Vec> = inflate_faces(&shapes, b, o, front(), build::Keep::Facets).collect(); + SOLVE_FIRST.with(|s| s.set(false)); + out + }; + let (taken, first) = (solids(false), solids(true)); + assert!(taken[0].as_ref().is_ok_and(|(f, ..)| !f.is_empty())); + assert!(taken[1..].iter().all(Result::is_err), "{:?}", taken[1..].iter().map(|r| r.as_ref().err()).collect::>()); + assert_eq!(format!("{taken:?}"), format!("{first:?}")); +} + /// #1724: under the nonzero rule a contour inside another that runs the same way cuts no hole /// (the middle is filled, as the art is drawn); one running the other way does, and so does /// either under the even-odd rule. diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index f5be7ac4c5..2ad8ade0e4 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -41,9 +41,9 @@ pub use live_paint::{ }; use live_paint::{check_live_paint_options, lp_color, lp_rgb_color}; pub use lookup::{ - EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces, - is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, - sources, visible_edge_near, visible_face_at, + EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, LP_JOIN_TOL, SOURCES_NAME, builder_members, connected_edges, drawn_outlines, + edge_near, edges, face_at, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, + path_contains, shape_builder_shapes, shapes_for, sources, visible_edge_near, visible_face_at, }; pub use map::{ BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs, diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index e5f696a5a7..b5472659d5 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -1,5 +1,7 @@ use super::cache::RegionCache; -use super::lookup::{is_live_paint, live_paint_at, live_paint_at_where, visible_edge_near, visible_face_at}; +use super::lookup::{ + connected_edges, flood_faces, is_live_paint, live_paint_at, live_paint_at_where, lp_stroke_paint, visible_edge_near, visible_face_at, +}; use super::map::{HIGHLIGHT_RED, sorted_roots}; use super::{ Action, Arc, BezPath, CLICK_TOL_PX, Cursor, Document, EDGE_NAME, FACE_NAME, FillRule, Mods, Node, NodeId, Overlay, Point, PointerEvent, @@ -540,7 +542,11 @@ pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); /// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. A drag draws /// a marquee, and on release the faces and edges it touches ([`lp_parts_in`]) become the selection; /// with Shift held at the press, each of them leaves the selection if it is selected and joins it -/// otherwise. A click and a marquee change the selection on release, so Escape between the press +/// otherwise. A double-click on a face selects it and the faces of its group that are not parted +/// from it by a stroked edge; on an edge, it selects the edge and the edges joined to it end to end +/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). With Shift held, each of them +/// leaves the selection if it is selected and joins it otherwise. +/// A click and a marquee change the selection on release, so Escape between the press /// and the release (which removes the marquee) leaves the selection as it was. The tool does not /// highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer /// or on a locked artboard, or in isolation mode outside the isolated art. Its Tool Options @@ -627,6 +633,18 @@ impl LivePaintSelectionTool { }; (hit, cursor) } + + /// What a double-click at `p` selects, Alt held or not: on the face a click there picks + /// ([`LivePaintSelectionTool::picks_edge`]), that face and the faces of its group a flood from it + /// reaches across edges that are hidden or draw no stroke ([`flood_faces`]); on the edge a click + /// picks, that edge and the edges of its group joined to it end to end through edges that draw + /// with the stroke paint it draws with ([`connected_edges`], [`lp_stroke_paint`]). Empty where a click selects no face or edge. + fn reach_at(&self, cx: &ToolContext, p: Point, alt: bool) -> Vec { + let Some(edge) = self.picks_edge(alt) else { return vec![] }; + let Some((gid, Some(part))) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, &|id| lp_selectable(cx, id)) else { return vec![] }; + let Some(group) = cx.doc.node(gid) else { return vec![] }; + if edge { connected_edges(group, part.id, &lp_stroke_paint(part)) } else { flood_faces(group, part.id) } + } } /// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and @@ -748,7 +766,16 @@ impl Tool for LivePaintSelectionTool { (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], } } - _ => vec![], + PointerKind::DoubleClick => { + // The two clicks before it already selected the part under the pointer (or, with + // Shift, toggled it twice); the double-click selects the part with what it reaches. + let ids = self.reach_at(cx, p, ev.mods.alt); + match (ids.is_empty(), ev.mods.shift) { + (true, _) => vec![], + (false, true) => vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))], + (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], + } + } } } diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs index 5e85d9339d..f577732ade 100644 --- a/crates/tools/src/builder/lookup.rs +++ b/crates/tools/src/builder/lookup.rs @@ -104,6 +104,349 @@ fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, to best.map(|b| b.1) } +/// The greatest distance (pt) between the ends of two Live Paint edges that the Live Paint tools take +/// as one point. Every edge comes from one planar map, so their ends meet this closely. +pub const LP_JOIN_TOL: f64 = 1e-4; + +/// The greatest distance (pt) between the middle of a Live Paint face's outline segment and an edge +/// it runs along. The planar map refits outlines to [`vectorcraft_pathops::DEFAULT_PRECISION`], +/// keeping each refit segment's ends, and with Gap Detection on, the faces and the edges come from +/// two maps, so an outline can lie that far off its edges between its ends. +const LP_FACE_EDGE_TOL: f64 = vectorcraft_pathops::DEFAULT_PRECISION; + +/// The cell [`LP_JOIN_TOL`] wide that holds `p`, for finding the points near it; two points within +/// [`LP_JOIN_TOL`] of each other lie in the same or neighboring cells. The cast saturates, so a +/// coordinate too large or not finite does not overflow. +fn lp_cell(p: Point) -> (i64, i64) { + ((p.x / LP_JOIN_TOL).round() as i64, (p.y / LP_JOIN_TOL).round() as i64) +} + +/// The cell that holds `p` ([`lp_cell`]) and the eight cells around it. +fn lp_cells_around(p: Point) -> impl Iterator { + let (x, y) = lp_cell(p); + (-1..=1).flat_map(move |dx: i64| (-1..=1).map(move |dy: i64| (x.saturating_add(dx), y.saturating_add(dy)))) +} + +/// The stroke paint Live Paint edge `edge` draws with: the paint of its topmost stroke that is shown +/// with a paint, a weight above 0 and an opacity above 0, or [`Paint::None`] when the edge is fully +/// transparent or has no such stroke. +pub(super) fn lp_stroke_paint(edge: &Node) -> Paint { + if edge.opacity <= 0.0 { + return Paint::None; + } + edge.appearance + .items + .iter() + .rev() + .find_map(|i| match i { + vectorcraft_doc::appearance::AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0 && s.opacity > 0.0 => { + Some(s.paint.clone()) + } + _ => None, + }) + .unwrap_or(Paint::None) +} + +/// Whether Live Paint edge `edge` draws no stroke ([`lp_stroke_paint`] is [`Paint::None`]). +fn unstroked(edge: &Node) -> bool { + lp_stroke_paint(edge).is_none() +} + +/// The segments of `pd`. +fn lp_segments(pd: &PathData) -> Vec { + pd.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect() +} + +/// The faces of Live Paint group `group` that a flood from face `start` reaches, `start` first: +/// `start`, and each face that shares with a face already reached an edge that is hidden or draws +/// no stroke. Hidden faces are not reached and do not pass the flood on. A face runs along the parts +/// of the edge segments its outline segments lie on ([`lp_lies_on`]) or overlap ([`lp_overlap`]). +/// Two faces share an edge where those parts overlap along more than [`LP_FACE_EDGE_TOL`], or, where +/// both outlines lie within [`LP_JOIN_TOL`] of the edge, along more than half the shorter part. So +/// two faces that a Gap Detection gap parts on the same side of an edge, whose parts only touch, do +/// not share it, and two faces whose shared edge is shorter than [`LP_FACE_EDGE_TOL`] do. Empty when +/// `start` is not a face of `group` that is not hidden. +pub fn flood_faces(group: &Node, start: NodeId) -> Vec { + use kurbo::{ParamCurve, ParamCurveArclen}; + use std::collections::{HashMap, HashSet}; + let shown: Vec<&Arc> = faces(group).into_iter().filter(|f| f.visible).collect(); + if !shown.iter().any(|f| f.id == start) { + return vec![]; + } + // Each edge segment, and whether a flood crosses it. + let edge_segs: Vec<(kurbo::CubicBez, bool)> = edges(group) + .into_iter() + .flat_map(|e| { + let cross = !e.visible || unstroked(e); + e.path_data().map(lp_segments).unwrap_or_default().into_iter().map(move |s| (s, cross)) + }) + .collect(); + // The faces along each edge segment a flood crosses, each with the part of the segment it runs + // along (from and to, in the segment's own parameter). A face outline segment usually lies on + // one edge segment, stroked or not ([`lp_lies_on`]); one that does not (a piece of an edge + // segment where a Gap Detection line meets it, the line itself, or an outline segment that spans + // several edge segments) is measured against the edge segments a flood crosses that its bounds + // meet ([`lp_overlap`]). Each part also records whether its outline segment lies within + // [`LP_JOIN_TOL`] of it ([`lp_close`]). + let all: Vec<(kurbo::CubicBez, bool)> = edge_segs.iter().map(|&(s, _)| (s, true)).collect(); + let all_grid = LpEdgeGrid::new(&all); + let mut along: HashMap> = HashMap::new(); + let mut pieces: Vec<(kurbo::CubicBez, NodeId)> = vec![]; + for f in &shown { + for piece in f.path_data().map(lp_segments).unwrap_or_default() { + let m = piece.eval(0.5); + let bounds = kurbo::ParamCurveExtrema::bounding_box(&piece).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL); + // The edge segment the outline segment lies on whose middle is nearest its own. + let on = all_grid + .near_box(bounds) + .into_iter() + .filter_map(|k| edge_segs.get(k).and_then(|(s, cross)| lp_lies_on(s, &piece).map(|part| (k, s, *cross, part)))) + .min_by(|x, y| (x.1.eval(0.5) - m).hypot().total_cmp(&(y.1.eval(0.5) - m).hypot())); + match on { + Some((k, s, cross, (from, to))) => { + if cross { + along.entry(k).or_default().push((f.id, from, to, lp_close(s, from, to, &piece))); + } + } + None => pieces.push((piece, f.id)), + } + } + } + if !pieces.is_empty() { + let grid = LpEdgeGrid::new(&edge_segs); + for (piece, id) in pieces { + let bounds = kurbo::ParamCurveExtrema::bounding_box(&piece).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL); + for k in grid.near_box(bounds) { + if let Some((s, true)) = edge_segs.get(k) + && let Some((a, c)) = lp_overlap(s, &piece) + { + along.entry(k).or_default().push((id, a, c, lp_close(s, a, c, &piece))); + } + } + } + } + let mut next: HashMap> = HashMap::new(); + // Sorted by where each part starts, a part is compared only with the parts after it that start + // before it ends. Two parts that overlap along [`LP_FACE_EDGE_TOL`] or less join only when both + // lie within [`LP_JOIN_TOL`] of the edge and the overlap is more than half the shorter part: an + // edge that short is shared, while an outline that only crosses it lies off it. + for (k, on) in &mut along { + let Some((s, _)) = edge_segs.get(*k) else { continue }; + on.sort_by(|x, y| x.1.total_cmp(&y.1)); + for (i, &(a, a0, a1, a_close)) in on.iter().enumerate() { + for &(c, c0, c1, c_close) in on.iter().skip(i + 1).take_while(|x| x.1 < a1) { + let (from, to) = (a0.max(c0), a1.min(c1)); + let len = |x: f64, y: f64| s.subsegment(x..y).arclen(1e-6); + let shared = if a != c && from < to { len(from, to) } else { 0.0 }; + let short_shared = a_close && c_close && shared > 0.5 * len(a0, a1).min(len(c0, c1)); + if shared > 0.0 && (shared > LP_FACE_EDGE_TOL || short_shared) { + next.entry(a).or_default().push(c); + next.entry(c).or_default().push(a); + } + } + } + } + let mut reached = vec![start]; + let mut seen = HashSet::from([start]); + let mut i = 0; + while let Some(&f) = reached.get(i) { + i += 1; + for &n in next.get(&f).map(Vec::as_slice).unwrap_or_default() { + if seen.insert(n) { + reached.push(n); + } + } + } + reached +} + +/// The part of edge segment `s` that outline segment `piece` runs along, as (from, to) in `s`'s +/// own parameter, or None when they share no length. The part runs between the ends of `s` that lie +/// within [`LP_FACE_EDGE_TOL`] of `piece` and the ends of `piece` that lie within [`LP_JOIN_TOL`] of +/// `s` (the map keeps the ends of an outline segment it refits), and its middle lies within +/// [`LP_FACE_EDGE_TOL`] of `piece`, where the two run along each other ([`lp_runs_along`]). When no +/// end of `piece` lies on `s`, `piece` must lie within [`LP_JOIN_TOL`] of the part ([`lp_close`]). So +/// two segments that only cross or touch, or that only lie near each other, share nothing. +fn lp_overlap(s: &kurbo::CubicBez, piece: &kurbo::CubicBez) -> Option<(f64, f64)> { + use kurbo::{ParamCurve, ParamCurveNearest}; + let on_piece = |p: Point| piece.nearest(p, 1e-9).distance_sq.sqrt() <= LP_FACE_EDGE_TOL; + let mut ts: Vec = [(s.p0, 0.0), (s.p3, 1.0)].into_iter().filter(|(p, _)| on_piece(*p)).map(|(_, t)| t).collect(); + let mut piece_end_on = false; + for p in [piece.p0, piece.p3] { + let n = s.nearest(p, 1e-9); + if n.distance_sq.sqrt() <= LP_JOIN_TOL { + ts.push(n.t); + piece_end_on = true; + } + } + let from = ts.iter().copied().fold(f64::INFINITY, f64::min); + let to = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max); + if from >= to { + return None; + } + let mid = (from + to) / 2.0; + let n = piece.nearest(s.eval(mid), 1e-9); + let along = if piece_end_on { lp_runs_along(s, from, to, piece, mid, n.t) } else { lp_close(s, from, to, piece) }; + (n.distance_sq.sqrt() <= LP_FACE_EDGE_TOL && along).then_some((from, to)) +} + +/// The part of edge segment `s` that outline segment `piece` lies on as a whole, as (from, to) in +/// `s`'s own parameter: both ends of `piece` lie within [`LP_JOIN_TOL`] of `s`, its middle lies +/// within [`LP_FACE_EDGE_TOL`] of `s`, and the two run along each other ([`lp_runs_along`]). +fn lp_lies_on(s: &kurbo::CubicBez, piece: &kurbo::CubicBez) -> Option<(f64, f64)> { + use kurbo::{ParamCurve, ParamCurveNearest}; + let (a, c, m) = (s.nearest(piece.p0, 1e-9), s.nearest(piece.p3, 1e-9), s.nearest(piece.eval(0.5), 1e-9)); + let (from, to) = (a.t.min(c.t), a.t.max(c.t)); + let near = a.distance_sq.sqrt() <= LP_JOIN_TOL && c.distance_sq.sqrt() <= LP_JOIN_TOL && m.distance_sq.sqrt() <= LP_FACE_EDGE_TOL; + (near && lp_runs_along(s, from, to, piece, m.t, 0.5)).then_some((from, to)) +} + +/// Whether outline segment `piece` runs along the part of edge segment `s` from `from` to `to` +/// rather than crossing it: it lies within [`LP_JOIN_TOL`] of that part ([`lp_close`]), or the two +/// run the same way or opposite ways at `ts` on `s` and `tp` on `piece` ([`lp_parallel`]). +fn lp_runs_along(s: &kurbo::CubicBez, from: f64, to: f64, piece: &kurbo::CubicBez, ts: f64, tp: f64) -> bool { + lp_close(s, from, to, piece) || lp_parallel(s, ts, piece, tp) +} + +/// Whether outline segment `piece` lies within [`LP_JOIN_TOL`] of edge segment `s` at the ends and +/// the middle of the part of `s` from `from` to `to`. +fn lp_close(s: &kurbo::CubicBez, from: f64, to: f64, piece: &kurbo::CubicBez) -> bool { + use kurbo::{ParamCurve, ParamCurveNearest}; + [from, (from + to) / 2.0, to].into_iter().all(|t| piece.nearest(s.eval(t), 1e-9).distance_sq.sqrt() <= LP_JOIN_TOL) +} + +/// Whether segment `s` at parameter `ts` and segment `piece` at parameter `tp` run the same way or +/// opposite ways: the sine of the angle between their directions there is at most 0.2. False where +/// either has no direction. +fn lp_parallel(s: &kurbo::CubicBez, ts: f64, piece: &kurbo::CubicBez, tp: f64) -> bool { + use kurbo::{ParamCurve, ParamCurveDeriv}; + let (u, v) = (s.deriv().eval(ts).to_vec2(), piece.deriv().eval(tp).to_vec2()); + let n = u.hypot() * v.hypot(); + n > 0.0 && u.cross(v).abs() <= 0.2 * n +} + +/// Most grid cells one edge segment is listed in by [`LpEdgeGrid`]; a longer one is tried for +/// every point. +const LP_GRID_SPAN: i64 = 1024; + +/// The farthest cell from the grid's origin, on each axis, that [`LpEdgeGrid`] numbers a point in. +const LP_GRID_LIMIT: f64 = 1_048_576.0; + +/// The edge segments a flood crosses, by the cells of a square grid (about one cell per segment) +/// that their bounds, widened by [`LP_FACE_EDGE_TOL`], cover. +struct LpEdgeGrid { + origin: Point, + cell: f64, + /// Segment indices by cell. + cells: std::collections::HashMap<(i64, i64), Vec>, + /// Segments whose bounds cover more than [`LP_GRID_SPAN`] cells, near every point. + wide: Vec, +} + +impl LpEdgeGrid { + /// The grid of the segments of `edges` that a flood crosses (those marked true). + fn new(edges: &[(kurbo::CubicBez, bool)]) -> Self { + let boxes: Vec<(usize, Rect)> = edges + .iter() + .enumerate() + .filter(|(_, e)| e.1) + .map(|(k, (s, _))| (k, kurbo::ParamCurveExtrema::bounding_box(s).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL))) + .collect(); + let all = boxes.iter().map(|b| b.1).reduce(|a, c| a.union(c)).unwrap_or_default(); + let side = (boxes.len() as f64).sqrt().ceil().max(1.0); + let cell = all.width().max(all.height()) / side; + let cell = if cell.is_finite() && cell > LP_FACE_EDGE_TOL { cell } else { LP_FACE_EDGE_TOL }; + let mut grid = Self { origin: all.origin(), cell, cells: std::collections::HashMap::new(), wide: vec![] }; + for (k, b) in boxes { + let ((x0, y0), (x1, y1)) = (grid.cell_of(Point::new(b.x0, b.y0)), grid.cell_of(Point::new(b.x1, b.y1))); + if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > LP_GRID_SPAN { + grid.wide.push(k); + continue; + } + for x in x0..=x1 { + for y in y0..=y1 { + grid.cells.entry((x, y)).or_default().push(k); + } + } + } + grid + } + + /// The cell `p` is in, counted from the origin (at most [`LP_GRID_LIMIT`] cells away on each + /// axis). + fn cell_of(&self, p: Point) -> (i64, i64) { + let at = |v: f64, o: f64| ((v - o) / self.cell).floor().clamp(-LP_GRID_LIMIT, LP_GRID_LIMIT) as i64; + (at(p.x, self.origin.x), at(p.y, self.origin.y)) + } + + /// The segments whose widened bounds may meet `r`, each once, in ascending order. When `r` + /// covers more than [`LP_GRID_SPAN`] cells, every listed segment is returned. + fn near_box(&self, r: Rect) -> Vec { + let ((x0, y0), (x1, y1)) = (self.cell_of(Point::new(r.x0, r.y0)), self.cell_of(Point::new(r.x1, r.y1))); + let mut out: Vec = self.wide.clone(); + if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > LP_GRID_SPAN { + out.extend(self.cells.values().flatten()); + } else { + for x in x0..=x1 { + for y in y0..=y1 { + out.extend(self.cells.get(&(x, y)).into_iter().flatten()); + } + } + } + out.sort_unstable(); + out.dedup(); + out + } +} + +/// The edges of Live Paint group `group` that are not hidden and are joined to edge `start` through +/// edges that draw with stroke paint `paint` ([`lp_stroke_paint`]), `start` first: `start`, and each +/// edge that draws with that stroke paint and has an end (the first or last point of one of its subpaths) within [`LP_JOIN_TOL`] of an +/// end of an edge already reached. +/// Empty when `start` is not an edge of `group` that is not hidden. +pub fn connected_edges(group: &Node, start: NodeId, paint: &Paint) -> Vec { + use std::collections::{HashMap, HashSet}; + let es: Vec<&Arc> = edges(group).into_iter().filter(|e| e.visible && (e.id == start || lp_stroke_paint(e) == *paint)).collect(); + // The first and last points of each subpath of each edge. + let ends: Vec> = es + .iter() + .map(|e| { + let Some(pd) = e.path_data() else { return vec![] }; + pd.subpaths + .iter() + .filter_map(|sp| { + let n = sp.segment_count(); + (n > 0).then(|| [sp.segment(0).p0, sp.segment(n - 1).p3]) + }) + .flatten() + .collect() + }) + .collect(); + let mut at: HashMap<(i64, i64), Vec<(Point, usize)>> = HashMap::new(); + for (i, pts) in ends.iter().enumerate() { + for &p in pts { + at.entry(lp_cell(p)).or_default().push((p, i)); + } + } + let Some(first) = es.iter().position(|e| e.id == start) else { return vec![] }; + let mut reached = vec![first]; + let mut seen = HashSet::from([first]); + let mut k = 0; + while let Some(&i) = reached.get(k) { + k += 1; + for &p in ends.get(i).map(Vec::as_slice).unwrap_or_default() { + for c in lp_cells_around(p) { + for &(q, j) in at.get(&c).map(Vec::as_slice).unwrap_or_default() { + if (q - p).hypot() <= LP_JOIN_TOL && seen.insert(j) { + reached.push(j); + } + } + } + } + } + reached.into_iter().filter_map(|i| es.get(i).map(|e| e.id)).collect() +} + /// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order). pub fn live_paint_at(doc: &Document, p: Point) -> Option { live_paint_at_where(doc, p, |id| doc.is_visible(id)) diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 90e4832771..4c9da58425 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1733,6 +1733,583 @@ fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights() assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); } +/// A Live Paint group of three faces in a row, A (0..50), B (50..100) and C (100..150), 50 pt tall, +/// with these edges: AB between A and B, with no stroke; BC between B and C, stroked red; the tops +/// of A and B stroked black; the top of C stroked red; and one bottom edge under all three, +/// stroked black. Returns the group, the faces [A, B, C] and the edges [AB, BC, top A, top B, +/// top C, bottom]. +fn live_strip(d: &mut Document) -> (NodeId, [NodeId; 3], [NodeId; 6]) { + let l = d.layers[0].id; + let black = Paint::solid(vectorcraft_color::Color::BLACK); + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let mut kids = vec![empty_sources(d)]; + let mut fs = [NodeId(0); 3]; + for (k, x) in [0.0, 50.0, 100.0].into_iter().enumerate() { + fs[k] = d.alloc_id(); + let mut face = Node::path(fs[k], shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + } + let pt = Point::new; + let lines = [ + (pt(50.0, 0.0), pt(50.0, 50.0), None), + (pt(100.0, 0.0), pt(100.0, 50.0), Some(red.clone())), + (pt(0.0, 0.0), pt(50.0, 0.0), Some(black.clone())), + (pt(50.0, 0.0), pt(100.0, 0.0), Some(black.clone())), + (pt(100.0, 0.0), pt(150.0, 0.0), Some(red)), + (pt(0.0, 50.0), pt(150.0, 50.0), Some(black)), + ]; + let mut es = [NodeId(0); 6]; + for (k, (from, to, stroke)) in lines.into_iter().enumerate() { + es[k] = d.alloc_id(); + let mut a = Appearance::default(); + if let Some(s) = stroke { + a.set_stroke(s); + } + let mut edge = Node::path(es[k], shapes::line(from, to), a); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + let at = d.layers[0].children().map_or(0, |c| c.len()); + d.insert(Some(l), at, g).unwrap(); + (gid, fs, es) +} + +#[test] +fn live_paint_flood_crosses_unstroked_edges_and_stops_at_stroked_ones() { + let mut d = Document::new(500.0, 500.0); + let (g, [a, b, c], [ab, bc, ..]) = live_strip(&mut d); + let group = |d: &Document| d.node(g).unwrap().clone(); + assert_eq!(flood_faces(&group(&d), a), vec![a, b]); + assert_eq!(flood_faces(&group(&d), b), vec![b, a]); + assert_eq!(flood_faces(&group(&d), c), vec![c]); + // A hidden edge does not part the faces either; a hidden face is not reached and does not start + // a flood; an id that is not a face of the group reaches nothing. + let mut hidden = d.clone(); + hidden.node_mut(bc).unwrap().visible = false; + assert_eq!(flood_faces(&group(&hidden), a), vec![a, b, c]); + hidden.node_mut(b).unwrap().visible = false; + assert_eq!(flood_faces(&group(&hidden), a), vec![a]); + assert!(flood_faces(&group(&hidden), b).is_empty()); + assert!(flood_faces(&group(&d), ab).is_empty()); + // An edge whose stroke is hidden, 0 pt wide or fully transparent, or that is itself fully + // transparent, draws no stroke, so the flood crosses it. + use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer}; + let base = d.node(bc).unwrap().appearance.stroke().unwrap().clone(); + let changes: [fn(&mut StrokeLayer); 3] = [|s| s.visible = false, |s| s.width = 0.0, |s| s.opacity = 0.0]; + for change in changes { + let mut e = d.clone(); + let mut s = base.clone(); + change(&mut s); + e.node_mut(bc).unwrap().appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_))); + e.node_mut(bc).unwrap().appearance.items.push(AppearanceItem::Stroke(s)); + assert_eq!(flood_faces(&group(&e), a), vec![a, b, c]); + } + let mut e = d.clone(); + e.node_mut(bc).unwrap().opacity = 0.0; + assert_eq!(flood_faces(&group(&e), a), vec![a, b, c]); +} + +/// Faces P (0..50) and Q (50..100) above one edge from (0, 0) to (100, 0) and face R (0..100) below +/// it, with no edge between P and Q (as where a Gap Detection line parts them): P and Q each run +/// along part of the edge, and R along all of it. +#[test] +fn live_paint_flood_measures_faces_along_part_of_an_edge() { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let mut kids = vec![empty_sources(&mut d)]; + let mut ids = [NodeId(0); 3]; + for (k, r) in [Rect::new(0.0, -50.0, 50.0, 0.0), Rect::new(50.0, -50.0, 100.0, 0.0), Rect::new(0.0, 0.0, 100.0, 50.0)].into_iter().enumerate() { + ids[k] = d.alloc_id(); + let mut face = Node::path(ids[k], shapes::rectangle(r), Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + } + let [p, q, r] = ids; + let mut edge = Node::path(d.alloc_id(), shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)), Appearance::default()); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + let group = d.node(gid).unwrap().clone(); + // P reaches R across the edge, and Q through R; P and Q only touch at (50, 0). + assert_eq!(flood_faces(&group, p), vec![p, r, q]); + let mut without_r = d.clone(); + without_r.node_mut(r).unwrap().visible = false; + assert_eq!(flood_faces(without_r.node(gid).unwrap(), p), vec![p]); +} + +/// Face P (0..100) above unstroked edges along y = 0 split at `splits`, whose outline runs along +/// all of them with one segment, and below them one face between each two splits, the faces parted +/// from each other by stroked edges. A flood from any face below reaches P and, through it, the +/// others; this holds when the outline's middle is no edge's middle (30), is the end shared by two +/// edges (50), and is the middle of an edge shorter than the outline (25 and 75). +#[test] +fn live_paint_flood_crosses_an_outline_segment_that_spans_several_edges() { + for splits in [vec![30.0], vec![50.0], vec![25.0, 75.0]] { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let mut kids = vec![empty_sources(&mut d)]; + let xs: Vec = std::iter::once(0.0).chain(splits.iter().copied()).chain(std::iter::once(100.0)).collect(); + let add_face = |d: &mut Document, kids: &mut Vec>, r: Rect| { + let id = d.alloc_id(); + let mut face = Node::path(id, shapes::rectangle(r), Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + id + }; + let p = add_face(&mut d, &mut kids, Rect::new(0.0, -50.0, 100.0, 0.0)); + let below: Vec = xs.windows(2).map(|w| add_face(&mut d, &mut kids, Rect::new(w[0], 0.0, w[1], 50.0))).collect(); + let pt = Point::new; + let lines = xs + .windows(2) + .map(|w| (pt(w[0], 0.0), pt(w[1], 0.0), false)) + .chain(splits.iter().map(|&x| (pt(x, 0.0), pt(x, 50.0), true))) + .collect::>(); + for (from, to, stroked) in lines { + let mut a = Appearance::default(); + if stroked { + a.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + } + let mut edge = Node::path(d.alloc_id(), shapes::line(from, to), a); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + let group = d.node(gid).unwrap().clone(); + let mut all = below.clone(); + all.push(p); + all.sort(); + for &f in &below { + let mut got = flood_faces(&group, f); + assert_eq!(got.first(), Some(&f), "{splits:?}"); + got.sort(); + assert_eq!(got, all, "{splits:?} from {f:?}"); + } + } +} + +/// A Live Paint group built from `paths` as `livePaint.make` builds one, with Gap Detection closing +/// gaps up to `gap` pt when given, and with no stroke on any edge. Returns the group and its faces. +fn live_from_paths(paths: Vec, gap: Option) -> (Node, Vec) { + let mut d = Document::new(500.0, 500.0); + let shapes: Vec = paths.into_iter().enumerate().map(|(k, p)| po::Shape::new(p, FillRule::NonZero, k as u64)).collect(); + let (regions, lines) = match gap { + Some(g) => { + let (r, e, _) = po::live_paint_with_gaps(&shapes, g); + (r, e) + } + None => po::live_paint(&shapes), + }; + let mut kids = vec![empty_sources(&mut d)]; + let mut ids = vec![]; + for r in regions { + let id = d.alloc_id(); + let mut face = Node::path(id, r.path, Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + ids.push(id); + } + for e in lines { + let mut edge = Node::path(d.alloc_id(), e.path, Appearance::default()); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let mut g = Node::group(d.alloc_id(), kids); + g.name = Some(LIVE_PAINT_NAME.into()); + (g, ids) +} + +/// Live Paint groups built from curves, one with a T-junction where lines end inside an ellipse and +/// one with Gap Detection closing the gap between a curve's end and a circle: with no stroke on any +/// edge, a flood from any face reaches every face. +#[test] +fn live_paint_flood_crosses_unstroked_curves_in_built_groups() { + let pt = Point::new; + let frame = shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)); + let t_junction = vec![ + frame, + shapes::ellipse(Rect::new(50.0, 60.0, 150.0, 140.0)), + shapes::line(pt(113.0, 0.0), pt(113.0, 80.0)), + shapes::line(pt(113.0, 200.0), pt(113.0, 120.0)), + ]; + let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Smooth }; + let (start, end) = (pt(102.7, 20.0), pt(109.0, 100.0 + (2500.0f64 - 81.0).sqrt() - 2.0)); + let curve = PathData::single(SubPath::new( + vec![a(start, start, start + kurbo::Vec2::new(40.0, 40.0)), a(end, end + kurbo::Vec2::new(-30.0, -40.0), end)], + false, + )); + let gap = vec![shapes::ellipse(Rect::new(50.0, 50.0, 150.0, 150.0)), curve]; + for (name, paths, max_gap, count) in [("T-junction", t_junction, None, 3), ("Gap Detection", gap, Some(3.0), 2)] { + let (g, fs) = live_from_paths(paths, max_gap); + assert_eq!(fs.len(), count, "{name}"); + for &f in &fs { + let mut got = flood_faces(&g, f); + got.sort(); + assert_eq!(got, fs, "{name} from {f:?}"); + } + } +} + +/// A closed outline: `curve`, then straight lines through `rest` and back to the curve's start. +fn curve_then_lines(curve: kurbo::CubicBez, rest: &[Point]) -> PathData { + let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner }; + let mut anchors = vec![a(curve.p0, curve.p0, curve.p1), a(curve.p3, curve.p2, curve.p3)]; + anchors.extend(rest.iter().map(|&p| a(p, p, p))); + PathData::single(SubPath::new(anchors, true)) +} + +/// `c` with its two handles moved 0.006 pt along y: its ends stay put and its middle moves 0.0045 pt, +/// as a face outline refit within the planar map's precision can lie off its edge. +fn refit(c: kurbo::CubicBez) -> kurbo::CubicBez { + let up = kurbo::Vec2::new(0.0, 0.006); + kurbo::CubicBez::new(c.p0, c.p1 + up, c.p2 + up, c.p3) +} + +/// A Live Paint group of `faces` and of `lines`, each line with or without a stroke. Returns the +/// group and the ids of the faces. +fn live_of(faces: Vec, lines: Vec<(PathData, bool)>) -> (Node, Vec) { + let mut d = Document::new(500.0, 500.0); + let mut kids = vec![empty_sources(&mut d)]; + let mut ids = vec![]; + for pd in faces { + let id = d.alloc_id(); + let mut face = Node::path(id, pd, Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + ids.push(id); + } + for (pd, stroked) in lines { + let mut a = Appearance::default(); + if stroked { + a.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + } + let mut edge = Node::path(d.alloc_id(), pd, a); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let mut g = Node::group(d.alloc_id(), kids); + g.name = Some(LIVE_PAINT_NAME.into()); + (g, ids) +} + +/// Faces whose outlines along an unstroked curved edge lie up to 0.0045 pt off it, as the planar map +/// can leave them when it refits an outline across a T-junction or, with Gap Detection on, builds the +/// faces and the edges from two maps: the flood still crosses the edge. Two faces that a gap parts +/// on the same side of the edge, which only touch, are still not joined. +#[test] +fn live_paint_flood_crosses_curved_edges_that_outlines_lie_slightly_off() { + use kurbo::ParamCurve; + let c = kurbo::CubicBez::new(Point::new(0.0, 0.0), Point::new(30.0, 40.0), Point::new(70.0, 40.0), Point::new(100.0, 0.0)); + let edge = |s: kurbo::CubicBez| { + let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner }; + PathData::single(SubPath::new(vec![a(s.p0, s.p0, s.p1), a(s.p3, s.p2, s.p3)], false)) + }; + // T-junction: a stroked line ends on the curve at t = 0.4 and parts the faces below it (L and R); + // the curve is two edges, and the face above (U) runs along both with one refit segment. + let j = c.eval(0.4); + let (left, right) = (c.subsegment(0.0..0.4), c.subsegment(0.4..1.0)); + let (g, ids) = live_of( + vec![ + curve_then_lines(left, &[Point::new(j.x, 80.0), Point::new(0.0, 80.0)]), + curve_then_lines(right, &[Point::new(100.0, 80.0), Point::new(j.x, 80.0)]), + curve_then_lines(refit(c), &[Point::new(100.0, -50.0), Point::new(0.0, -50.0)]), + ], + vec![(edge(left), false), (edge(right), false), (shapes::line(j, Point::new(j.x, 80.0)), true)], + ); + let [l, r, u] = [ids[0], ids[1], ids[2]]; + assert_eq!(flood_faces(&g, l), vec![l, u, r]); + let mut from_u = flood_faces(&g, u); + assert_eq!(from_u.first(), Some(&u)); + from_u.sort(); + assert_eq!(from_u, ids); + // Gap Detection: a gap line, which is no edge, parts P and Q above the curve at t = 0.5; the curve + // is one edge, and Q and the face below (B) run along it with refit segments. + let m = c.eval(0.5); + let (g, ids) = live_of( + vec![ + curve_then_lines(c.subsegment(0.0..0.5), &[Point::new(m.x, -50.0), Point::new(0.0, -50.0)]), + curve_then_lines(refit(c.subsegment(0.5..1.0)), &[Point::new(100.0, -50.0), Point::new(m.x, -50.0)]), + curve_then_lines(refit(c), &[Point::new(100.0, 80.0), Point::new(0.0, 80.0)]), + ], + vec![(edge(c), false)], + ); + let [p, q, b] = [ids[0], ids[1], ids[2]]; + assert_eq!(flood_faces(&g, p), vec![p, b, q]); + let mut without_b = g.clone(); + if let Some(kids) = without_b.children_mut() + && let Some(n) = kids.iter_mut().find(|n| n.id == b) + { + Arc::make_mut(n).visible = false; + } + assert_eq!(flood_faces(&without_b, p), vec![p]); +} + +/// Two large faces whose only shared edge, with no stroke, is 0.005 pt long: the flood crosses it. +#[test] +fn live_paint_flood_crosses_a_shared_edge_shorter_than_the_precision() { + let pt = Point::new; + let (a, b) = (pt(50.0, 0.0), pt(50.0, 0.005)); + let (g, ids) = live_of( + vec![ + PathData::single(SubPath::polyline(&[pt(0.0, -50.0), a, b, pt(0.0, 50.0)], true)), + PathData::single(SubPath::polyline(&[pt(100.0, -50.0), pt(100.0, 50.0), b, a], true)), + ], + vec![(shapes::line(a, b), false)], + ); + assert_eq!(flood_faces(&g, ids[0]), ids); +} + +/// A stroked edge along y = 0 and an edge with no stroke 0.008 pt below it, with a face above the +/// first whose outline ends on it and bows 0.005 pt off it in the middle, nearer the second, and a +/// face below the second: the stroked edge parts the two faces, so the flood does not join them. +#[test] +fn live_paint_flood_stops_where_a_stroked_edge_runs_along_an_unstroked_one() { + let pt = Point::new; + let bow = kurbo::CubicBez::new(pt(100.0, 0.0), pt(70.0, 0.02 / 3.0), pt(30.0, 0.02 / 3.0), pt(0.0, 0.0)); + let (g, ids) = live_of( + vec![ + curve_then_lines(bow, &[pt(0.0, -50.0), pt(100.0, -50.0)]), + PathData::single(SubPath::polyline(&[pt(0.0, 0.008), pt(0.0, 50.0), pt(100.0, 50.0), pt(100.0, 0.008)], true)), + ], + vec![(shapes::line(pt(0.0, 0.0), pt(100.0, 0.0)), true), (shapes::line(pt(0.0, 0.008), pt(100.0, 0.008)), false)], + ); + assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]]); + assert_eq!(flood_faces(&g, ids[1]), vec![ids[1]]); +} + +/// An edge with no stroke from (10, 0) to (90, 0), and two faces that meet on a line 0.005 pt below +/// it from (0, 0.005) to (100, 0.005) (as a Gap Detection line): neither face runs along the edge, so +/// the flood does not join them. +#[test] +fn live_paint_flood_does_not_cross_an_edge_beside_a_line_two_faces_meet_on() { + let pt = Point::new; + let (a, b) = (pt(0.0, 0.005), pt(100.0, 0.005)); + let (g, ids) = live_of( + vec![ + PathData::single(SubPath::polyline(&[a, b, pt(100.0, -50.0), pt(0.0, -50.0)], true)), + PathData::single(SubPath::polyline(&[b, a, pt(0.0, 50.0), pt(100.0, 50.0)], true)), + ], + vec![(shapes::line(pt(10.0, 0.0), pt(90.0, 0.0)), false)], + ); + assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]]); +} + +/// A stroked edge and an edge with no stroke that leave one point at a slope of 0.01, so they lie +/// within 0.01 pt of each other for their first point or so: the face between them and the face +/// beyond the edge with no stroke share that edge, so the flood joins them. +#[test] +fn live_paint_flood_crosses_an_unstroked_edge_beside_a_stroked_one() { + let pt = Point::new; + let (o, a, b) = (pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 1.0)); + let (g, ids) = live_of( + vec![ + PathData::single(SubPath::polyline(&[o, a, b], true)), + PathData::single(SubPath::polyline(&[o, b, pt(100.0, 50.0), pt(0.0, 50.0)], true)), + ], + vec![(shapes::line(o, a), true), (shapes::line(o, b), false), (shapes::line(a, b), true)], + ); + assert_eq!(flood_faces(&g, ids[0]), ids); +} + +/// Two faces on either side of an edge with no stroke whose curve has a cusp at its middle (no +/// direction there): the flood crosses it. +#[test] +fn live_paint_flood_crosses_an_edge_with_a_cusp_at_its_middle() { + let c = kurbo::CubicBez::new(Point::new(0.0, 0.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0), Point::new(100.0, 0.0)); + let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner }; + let edge = PathData::single(SubPath::new(vec![a(c.p0, c.p0, c.p1), a(c.p3, c.p2, c.p3)], false)); + let (g, ids) = live_of( + vec![ + curve_then_lines(c, &[Point::new(100.0, -50.0), Point::new(0.0, -50.0)]), + curve_then_lines(c, &[Point::new(100.0, 100.0), Point::new(0.0, 100.0)]), + ], + vec![(edge, false)], + ); + assert_eq!(flood_faces(&g, ids[0]), ids); +} + +/// Two faces parted by a line that is no edge (as a Gap Detection line), which crosses an edge with +/// no stroke: one 0.006 pt long, upright or at a slope of 0.1, and one 1 pt long at a slope of 0.09. +/// The faces only cross the edge and do not run along it, so the flood does not join them. +#[test] +fn live_paint_flood_does_not_cross_an_edge_an_outline_only_crosses() { + let pt = Point::new; + let short = pt(0.006, 0.0); + for (a, b, end) in + [(pt(0.003, -0.004), pt(0.003, 0.02), short), (pt(-0.037, -0.004), pt(0.043, 0.004), short), (pt(0.1, -0.009), pt(0.3, 0.009), pt(1.0, 0.0))] + { + let (g, ids) = live_of( + vec![ + PathData::single(SubPath::polyline(&[a, b, pt(-50.0, 50.0), pt(-50.0, -50.0)], true)), + PathData::single(SubPath::polyline(&[b, a, pt(50.0, -50.0), pt(50.0, 50.0)], true)), + ], + vec![(shapes::line(pt(0.0, 0.0), end), false)], + ); + assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]], "{a:?} to {b:?}"); + } +} + +#[test] +fn live_paint_connected_edges_follow_the_same_stroke_paint() { + let mut d = Document::new(500.0, 500.0); + let (g, [a, ..], [ab, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d); + let group = d.node(g).unwrap().clone(); + let black = Paint::solid(vectorcraft_color::Color::BLACK); + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + // Top A and top B join end to end; top C joins them but is red, and the black bottom does not + // join them. + assert_eq!(connected_edges(&group, top_a, &black), vec![top_a, top_b]); + // BC and top C, both red, join at (100, 0). + assert_eq!(connected_edges(&group, bc, &red), vec![bc, top_c]); + assert_eq!(connected_edges(&group, bottom, &black), vec![bottom]); + // The edge started from is taken whatever its paint. + assert_eq!(connected_edges(&group, ab, &Paint::None), vec![ab]); + assert!(connected_edges(&group, a, &black).is_empty()); +} + +/// An edge from (0, 0) through (50, 0) to (100, 0) and a second edge with the same stroke from +/// (50, 0) to (50, 50): the second one ends at an anchor in the middle of the first, not at one of +/// its ends, so the two are not joined end to end. A third edge from (100, 0) is. +#[test] +fn live_paint_connected_edges_join_only_at_path_ends() { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let black = Paint::solid(vectorcraft_color::Color::BLACK); + let mut kids = vec![empty_sources(&mut d)]; + let pt = Point::new; + let paths = [ + PathData::single(SubPath::polyline(&[pt(0.0, 0.0), pt(50.0, 0.0), pt(100.0, 0.0)], false)), + shapes::line(pt(50.0, 0.0), pt(50.0, 50.0)), + shapes::line(pt(100.0, 0.0), pt(100.0, 50.0)), + ]; + let mut ids = vec![]; + for pd in paths { + let mut a = Appearance::default(); + a.set_stroke(black.clone()); + let id = d.alloc_id(); + let mut edge = Node::path(id, pd, a); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + ids.push(id); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + let group = d.node(gid).unwrap().clone(); + assert_eq!(connected_edges(&group, ids[0], &black), vec![ids[0], ids[2]]); + assert_eq!(connected_edges(&group, ids[1], &black), vec![ids[1]]); +} + +/// Three edges end to end along (0, 0) to (150, 0) under one face: a red one, one drawn red with a +/// hidden blue stroke over it, and one drawn blue with a hidden red stroke over it. Edges join by the +/// paint they draw with, not by a hidden stroke's paint, for `connected_edges` and a double-click. +#[test] +fn live_paint_connected_edges_follow_the_stroke_paint_they_draw_with() { + use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer}; + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + let mut kids = vec![empty_sources(&mut d)]; + let fid = d.alloc_id(); + let mut face = Node::path(fid, shapes::rectangle(Rect::new(0.0, 0.0, 150.0, 50.0)), Appearance::default()); + face.name = Some(FACE_NAME.into()); + kids.push(Arc::new(face)); + let pt = Point::new; + let hidden = |p: &Paint| AppearanceItem::Stroke(StrokeLayer { visible: false, ..StrokeLayer::new(p.clone(), 1.0) }); + let shown = |p: &Paint| AppearanceItem::Stroke(StrokeLayer::new(p.clone(), 1.0)); + let edges = [(0.0, vec![shown(&red)]), (50.0, vec![shown(&red), hidden(&blue)]), (100.0, vec![shown(&blue), hidden(&red)])]; + let mut ids = vec![]; + for (x, items) in edges { + let id = d.alloc_id(); + let mut edge = Node::path(id, shapes::line(pt(x, 0.0), pt(x + 50.0, 0.0)), Appearance { items, ..Appearance::default() }); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + ids.push(id); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + d.insert(Some(l), 0, g).unwrap(); + let group = d.node(gid).unwrap().clone(); + assert_eq!(connected_edges(&group, ids[0], &red), vec![ids[0], ids[1]]); + assert_eq!(connected_edges(&group, ids[2], &blue), vec![ids[2]]); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let alt = Mods { alt: true, ..Default::default() }; + let mut t = LivePaintSelectionTool::default(); + let mut dbl = |x: f64| t.pointer(&cx, &ev(PointerKind::DoubleClick, x, 1.0).with_mods(alt)); + assert_eq!(dbl(75.0), set(&[ids[1], ids[0]])); + assert_eq!(dbl(125.0), set(&[ids[2]])); +} + +#[test] +fn live_paint_selection_double_click_selects_connected_faces_or_edges() { + let mut d = Document::new(500.0, 500.0); + let (_, [a, b, c], [_, _, top_a, top_b, ..]) = live_strip(&mut d); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let toggle = |ids: &[NodeId]| vec![Action::Exec("select.toggle".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + let dbl = |t: &mut LivePaintSelectionTool, x: f64, y: f64, m: Mods| t.pointer(&cx, &ev(PointerKind::DoubleClick, x, y).with_mods(m)); + let mut t = LivePaintSelectionTool::default(); + // A face: the faces not parted from it by a stroked edge. + assert_eq!(dbl(&mut t, 25.0, 25.0, Mods::default()), set(&[a, b])); + assert_eq!(dbl(&mut t, 125.0, 25.0, Mods::default()), set(&[c])); + // An edge (Alt): the edges joined to it end to end with its stroke paint. + assert_eq!(dbl(&mut t, 25.0, 1.0, alt), set(&[top_a, top_b])); + // With Shift held, the parts reached leave the selection or join it. + assert_eq!(dbl(&mut t, 75.0, 25.0, shift), toggle(&[b, a])); + assert_eq!(dbl(&mut t, 75.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }), toggle(&[top_b, top_a])); + // Away from the group, a double-click adds nothing to what its clicks did. + assert!(dbl(&mut t, 300.0, 300.0, Mods::default()).is_empty()); + // Select Fills off: the double-click picks the edge nearest the pointer as a click does. + t.set_option("selectFills", &json!(false)); + assert_eq!(dbl(&mut t, 25.0, 1.0, Mods::default()), set(&[top_a, top_b])); + assert!(dbl(&mut t, 25.0, 25.0, Mods::default()).is_empty()); + // Select Strokes off: Alt picks the face. + t.set_option("selectFills", &json!(true)); + t.set_option("selectStrokes", &json!(false)); + assert_eq!(dbl(&mut t, 25.0, 1.0, alt), set(&[a, b])); + // Both off: nothing. + t.set_option("selectFills", &json!(false)); + assert!(dbl(&mut t, 25.0, 25.0, Mods::default()).is_empty()); + assert!(dbl(&mut t, 25.0, 1.0, alt).is_empty()); +} + +#[test] +fn live_paint_selection_double_click_skips_locked_groups_and_art_outside_isolation() { + let mut d = Document::new(500.0, 500.0); + let (g, _, _) = live_strip(&mut d); + let (other, _) = live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let dbl = |d: &Document, iso: Option, x: f64| { + let cx = ToolContext { isolation: iso, ..cx(d, &s, &p) }; + LivePaintSelectionTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, x, 25.0)) + }; + assert!(!dbl(&d, None, 25.0).is_empty()); + assert!(dbl(&d, Some(other), 25.0).is_empty(), "outside the isolated art"); + d.node_mut(g).unwrap().locked = true; + assert!(dbl(&d, None, 25.0).is_empty(), "a locked group"); + assert!(!dbl(&d, None, 250.0).is_empty()); +} + /// The Live Paint Selection pointer shows what a click at the point selects, from the point and /// the modifier keys alone (no move first): a face, an edge, or nothing. Groups the tool does not /// take (locked, hidden, outside isolation) show nothing to select. A press keeps its look until diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index 584149da8f..85b9cf258e 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -110,14 +110,16 @@ pub enum Cursor { /// The Pencil tool with Alt pressed after the drag starts and Alt key toggles to Smooth Tool off: /// releasing the mouse button with Alt still held closes the path. A drag started with Shift or /// Alt held draws an open straight line instead. Also while a freehand stroke is drawn back near - /// its start, where releasing the mouse button closes the path. A crosshair with a ring. + /// its start, or a stroke continuing a selected path near that path's other end, where + /// releasing the mouse button closes the path. A crosshair with a ring. PencilClose, /// The Pencil tool before a press that draws a straight line (Shift or Alt held). A crosshair with a - /// short straight line. + /// short level line. PencilStraight, - /// The Pencil tool before a press that draws a straight line (Shift or Alt held), over an end of - /// a selected open path: the line continues the path from that end. A crosshair with a short - /// straight line leaving a square end point. + /// The Pencil tool before a press over an end of a selected open path: the stroke, freehand or a + /// straight line, continues the path from that end. Also while a continuing stroke ends near an + /// open end of another path selected at the press, where releasing the mouse button connects the + /// two. A crosshair with a short line rising to the right. PencilContinue, /// The Paintbrush tool. Paintbrush, diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 674ecebf60..51756bf0fd 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -629,7 +629,9 @@ hotspot = "The lead tip." [[cursor]] id = "PencilNew" group = "Drawing and editing tools" -shown_when = ["The Pencil tool before a press that draws a new freehand path: the pointer is not within Edit Selected Paths of a selected path that the press would continue from an end or redraw. Over such a path, and while a stroke is drawn, the Pencil cursor has no star."] +shown_when = [ + "The Pencil tool before a press that draws a new freehand path: the pointer is not within Edit Selected Paths of a selected path that the press would continue from an end or redraw. Over such a path, and while a stroke is drawn, the Pencil cursor has no star.", +] hotspot = "The lead tip." look = "The Pencil cursor with a small star badge, where PencilClose has its ring." @@ -639,6 +641,7 @@ group = "Drawing and editing tools" shown_when = [ "The Pencil tool with Alt pressed after the drag starts, when Alt key toggles to Smooth Tool is off: releasing the mouse button with Alt still held closes the path. A drag started with Shift or Alt held draws an open straight line and does not show this cursor.", "The Pencil tool while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start: releasing the mouse button there closes the path.", + "The Pencil tool while a stroke continuing a selected open path, freehand or a straight line, is drawn to within Close Paths When Ends Are Within of that path's other end: releasing the mouse button there closes the path.", ] hotspot = "The lead tip." look = "The Pencil cursor with a ring badge (closing the loop), like PenClose." @@ -646,16 +649,21 @@ look = "The Pencil cursor with a ring badge (closing the loop), like PenClose." [[cursor]] id = "PencilStraight" group = "Drawing and editing tools" -shown_when = ["The Pencil tool with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line, at 45° steps from the Constrain Angle when Shift is held and at any angle with Alt alone."] +shown_when = [ + "The Pencil tool with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line, at 45° steps from the Constrain Angle when Shift is held and at any angle with Alt alone.", +] hotspot = "The lead tip." -look = "The Pencil cursor with a short straight-line badge, where PencilClose has its ring." +look = "The Pencil cursor with a short level-line badge, where PencilClose has its ring." [[cursor]] id = "PencilContinue" group = "Drawing and editing tools" -shown_when = ["The Pencil tool over an end of a selected open path, within Edit Selected Paths, with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line that continues the path from that end."] +shown_when = [ + "The Pencil tool over an end of a selected open path, within Edit Selected Paths, before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press continues the path from that end, freehand, or with a straight line when Shift or Alt is held.", + "The Pencil tool while a stroke continuing a selected path ends within Edit Selected Paths of an open end of another path selected at the press: releasing the mouse button connects the two.", +] hotspot = "The lead tip." -look = "PencilStraight's badge with a small filled square at the line's lower end (the end point the line continues from)." +look = "The Pencil cursor with a short badge line rising to the right, where PencilClose has its ring (the stroke carries on from the path)." [[cursor]] id = "Paintbrush" diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index ff99091153..3f8da166e4 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -240,12 +240,18 @@ fn points_source(cx: &ToolContext, (a, ra): (Point, &Option), (b, rb): impl DimensionTool { /// Where a Linear pick at `p` lands: on the nearest anchor of any path, within reach (kept as /// a link to its path); else where Smart Guides put it (another object's anchor or centre, in - /// line with one, or for the second point on a construction guide through the first), its - /// guides showing; else on the closest point of the line under the pointer; else `p` itself. + /// line with one, or for the second point square to a segment or grazing a curve from the + /// first with Snap to Perpendicular or Snap to Tangent on, or on a construction guide through + /// a first point picked on an anchor), its guides showing; else on the closest point of the + /// line under the pointer; else `p` itself. A first point not on an anchor has no + /// construction guides through it. /// A point the guides leave where the pointer is (right on a guide line) stays there, and with /// Snap to Pixel or Snap to Grid on a point stays where they place it. fn snap_linear(&mut self, cx: &ToolContext, p: Point) -> (Point, Option) { - let leave = self.points.last().map(|(first, _)| crate::guides::Leave::segment(cx, *first, false)); + let leave = self.points.last().map(|(first, anchor)| match anchor { + Some(_) => crate::guides::Leave::segment(cx, *first, false), + None => crate::guides::Leave::contact(*first), + }); let guided = self.snap.hover(cx, p, &[], leave.as_ref()); if let Some((point, reference)) = anchor_under(cx, p) { self.snap.clear(); @@ -902,10 +908,11 @@ mod tests { } } - /// A point that Smart Guides leave where the pointer already is (the pointer right on a - /// construction guide or in line with a target) stays on the guide the overlays show. It - /// does not move to the nearest point of a segment within the pick's reach. Off the guides, - /// a point near a segment still lands on it. + /// A point that Smart Guides leave where the pointer already is (the pointer right in line + /// with a target) stays on the guide the overlays show. It does not move to the nearest point + /// of a segment within the pick's reach. Off the guides, a point near a segment still lands + /// on it, also straight below the first point: the second point has no construction guide + /// through a first point that is not on an anchor. #[test] fn a_point_on_a_guide_stays_on_the_guide() { let (d, _) = doc_with_rect(); @@ -923,17 +930,22 @@ mod tests { // Off the guides, beside the edge: the closest point of the edge. let (on_edge, reference) = t.snap_linear(&c, Point::new(206.0, 170.5)); assert!(on_edge.distance(Point::new(200.0, 170.5)) < 1e-4 && reference.is_none(), "{on_edge:?}"); - // The first point, above the rectangle, then straight down along the vertical - // construction guide through it, past the right edge. + // The first point, above the rectangle, then straight down past the right edge: no + // vertical guide through the first point holds the second, so beside the edge it lands + // on the edge, and in line with the center it stays in line. click(&mut t, 206.0, 50.0); assert_eq!(t.points, vec![(Point::new(206.0, 50.0), None)]); - for y in [150.0, 170.5, 190.0] { - assert_eq!(t.snap_linear(&c, Point::new(206.0, y)), (Point::new(206.0, y), None), "on the construction guide at y = {y}"); - assert!(!t.snap.guides().is_empty()); + for y in [170.5, 190.0] { + let (q, reference) = t.snap_linear(&c, Point::new(206.0, y)); + assert!(q.distance(Point::new(200.0, y)) < 1e-4 && reference.is_none(), "beside the edge at y = {y}: {q:?}"); } - click(&mut t, 206.0, 170.5); + assert_eq!(t.snap_linear(&c, Point::new(207.0, 300.0)), (Point::new(207.0, 300.0), None), "away from the art"); + assert!(t.snap.guides().is_empty()); + assert_eq!(t.snap_linear(&c, Point::new(206.0, 150.0)), (Point::new(206.0, 150.0), None)); + assert!(!t.snap.guides().is_empty()); + click(&mut t, 206.0, 150.0); let dim = t.pending.as_ref().expect("both points were picked"); - assert_eq!((dim.a, dim.b), (Point::new(206.0, 50.0), Point::new(206.0, 170.5))); + assert_eq!((dim.a, dim.b), (Point::new(206.0, 50.0), Point::new(206.0, 150.0))); // The second point, off the edge, is held against the rectangle's anchors. let Some(DimensionSource::Points { free_b: Some(free), .. }) = &dim.source else { panic!("{:?}", dim.source) }; assert!(matches!(free, FreePoint::Anchors { .. }), "{free:?}"); @@ -950,6 +962,82 @@ mod tests { assert_eq!(DimensionTool::default().snap_linear(&pixel, Point::new(206.0, 170.0)), (Point::new(206.0, 170.0), None)); } + /// After a first point that is not on an anchor, the second point still lands where the line + /// from the first point meets a slanted segment square or grazes a curve, with the + /// "perpendicular" or "tangent" label, at places no construction guide through the first + /// point reaches. With Snap to Perpendicular and Snap to Tangent off it stays at the pointer. + #[test] + fn the_second_point_lands_square_to_a_segment_or_grazing_a_curve() { + use vectorcraft_doc::Appearance; + use vectorcraft_geom::{Rect, shapes}; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let (line, circle) = (d.alloc_id(), d.alloc_id()); + let slanted = shapes::line(Point::new(300.0, 100.0), Point::new(400.0, 150.0)); + d.insert(Some(l), 0, Node::path(line, slanted, Appearance::default_art())).unwrap(); + let round = shapes::ellipse(Rect::new(420.0, 370.0, 480.0, 430.0)); + d.insert(Some(l), 0, Node::path(circle, round, Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let first = Point::new(300.0, 250.0); + let picked = |c: &ToolContext| { + let mut t = DimensionTool::default(); + for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] { + t.pointer(c, &PointerEvent::new(kind, first.x, first.y)); + } + assert_eq!(t.points, vec![(first, None)]); + t + }; + let labeled = |t: &DimensionTool, label: &str| t.snap.guides().iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == label)); + // The foot of the perpendicular from (300, 250) to the line is (360, 130), along no + // construction guide angle from the first point. + let mut t = picked(&c); + let (q, reference) = t.snap_linear(&c, Point::new(361.0, 132.0)); + assert!(q.distance(Point::new(360.0, 130.0)) < 1e-4 && reference.is_none(), "{q:?}"); + assert!(labeled(&t, "perpendicular"), "{:?}", t.snap.guides()); + // Where a line from the first point grazes the circle about (450, 400), radius 30. + let (center, radius) = (Point::new(450.0, 400.0), 30.0); + let away = first - center; + let angle = away.y.atan2(away.x) + (radius / away.hypot()).acos(); + let touch = center + Vec2::new(angle.cos(), angle.sin()) * radius; + let (q, _) = t.snap_linear(&c, touch + Vec2::new(0.5, 0.5)); + assert!((q.distance(center) - radius).abs() < 0.05, "{q:?} is on the circle"); + assert!(((q - first).normalize().dot((q - center).normalize())).abs() < 0.01, "{q:?} grazes the circle"); + assert!(labeled(&t, "tangent"), "{:?}", t.snap.guides()); + // With both options off, the pointer near the foot lands on the closest point of the line + // instead. + let off = ToolContext { snap_perpendicular: false, snap_tangent: false, ..cx(&d, &s, &p) }; + let mut t = picked(&off); + let (q, _) = t.snap_linear(&off, Point::new(361.0, 132.0)); + assert!(q.distance(Point::new(360.0, 130.0)) > 0.5 && !labeled(&t, "perpendicular"), "{q:?}"); + } + + /// After a first point picked on an anchor, the second point still lands on the construction + /// guides through it. + #[test] + fn the_second_point_after_an_anchor_lands_on_construction_guides() { + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = DimensionTool::default(); + for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] { + t.pointer(&c, &PointerEvent::new(kind, 200.0, 200.0)); + } + assert_eq!(t.points, vec![(Point::new(200.0, 200.0), Some(PointRef { node: id, subpath: 0, anchor: 2 }))]); + // 1.5 units right and below the 45° guide up and to the right of the anchor. + let (q, reference) = t.snap_linear(&c, Point::new(331.0, 72.0)); + assert!(q.distance(Point::new(329.5, 70.5)) < 1e-4 && reference.is_none(), "{q:?}"); + assert!(!t.snap.guides().is_empty()); + // The same offset from a first point off the anchors stays at the pointer. + let mut t = DimensionTool::default(); + for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] { + t.pointer(&c, &PointerEvent::new(kind, 240.0, 240.0)); + } + assert_eq!(t.points, vec![(Point::new(240.0, 240.0), None)]); + assert_eq!(t.snap_linear(&c, Point::new(371.0, 112.0)), (Point::new(371.0, 112.0), None)); + assert!(t.snap.guides().is_empty()); + } + #[test] fn placement_press_previews_without_a_prior_move() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 61396e01d9..c3191c4363 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -98,8 +98,13 @@ pub struct GestureTool { /// A release where the pointer last dragged to would close the stroke because it ends near /// its start (Close Paths When Ends Are Within): the cursor shows the closing ring. near_start: bool, + /// A release where the pointer last dragged to would connect the continued path to another + /// selected path's open end (`finish`): the cursor shows the continuation cursor. + connects: bool, /// Pencil/Paintbrush continuing a selected open path: (path, continue from its start). extend: Option<(NodeId, bool)>, + /// The other end of the path `extend` continues: a release near it closes the path. + close_to: Option, /// A Pencil stroke continuing a selected open path: the other paths selected at the press, /// whose open ends it connects to. connect_to: Vec, @@ -817,7 +822,9 @@ impl GestureTool { active: false, began: false, near_start: false, + connects: false, extend: None, + close_to: None, connect_to: vec![], redraw: None, fidelity, @@ -1100,6 +1107,21 @@ impl GestureTool { self.find_end(cx, p, &cx.selection.objects) } + /// The other end of the open end `find_end` found: the opposite end of `id`'s last open + /// subpath. + fn other_end(cx: &ToolContext, id: NodeId, at_start: bool) -> Option { + let pd = cx.doc.node(id)?.path_data()?; + let sp = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty())?; + let a = if at_start { sp.anchors.last() } else { sp.anchors.first() }; + a.map(|a| a.p) + } + + /// Continues the path at the open end `find_extend` found, if any, noting its other end. + fn set_extend(&mut self, cx: &ToolContext, found: Option<(NodeId, bool, Point)>) { + self.extend = found.map(|(id, at_start, _)| (id, at_start)); + self.close_to = found.and_then(|(id, at_start, _)| Self::other_end(cx, id, at_start)); + } + /// [`Self::find_extend`] among `ids`. fn find_end(&self, cx: &ToolContext, p: Point, ids: &[NodeId]) -> Option<(NodeId, bool, Point)> { if self.edit_within <= 0.0 { @@ -1144,16 +1166,32 @@ impl GestureTool { } /// Does a stroke that ends at `end` end near its start (Close Paths When Ends Are Within, off - /// at 0 pixels)? + /// at 0 pixels)? A stroke continuing a selected path ends near that path's other end, a + /// straight line too; a new straight line never closes, as it would run back over itself. fn ends_near_start(&self, cx: &ToolContext, end: Point) -> bool { - self.close_within > 0.0 && self.points.len() > 2 && self.points[0].distance(end) <= cx.tol(self.close_within) + if self.close_within <= 0.0 { + return false; + } + let tol = cx.tol(self.close_within); + match (self.extend, self.close_to) { + (Some(_), Some(to)) => self.points.len() >= 2 && to.distance(end) <= tol, + (Some(_), None) => false, + (None, _) => !self.straight && self.points.len() > 2 && self.points.first().is_some_and(|s| s.distance(end) <= tol), + } } - /// Does a release now, with the stroke ending at `end`, close a freehand stroke because it ends - /// near its start (`finish`)? A stroke that redraws a selected path closes only when the engine - /// finds it does not end on that path, so it counts as not closing. + /// The open end of another path selected at the press that a continuing Pencil stroke ending + /// at `end` connects to (`finish`). + fn connects_at(&self, cx: &ToolContext, end: Point) -> Option<(NodeId, bool, Point)> { + self.extend.and_then(|_| self.find_end(cx, end, &self.connect_to)) + } + + /// Does a release now, with the stroke ending at `end`, close the stroke because it ends near + /// its start, or near the other end of the path it continues (`finish`)? A stroke that redraws + /// a selected path closes only when the engine finds it does not end on that path, so it counts + /// as not closing, and a stroke that connects to another path doesn't close. fn release_closes_near_start(&self, cx: &ToolContext, end: Point) -> bool { - self.draws_path() && !self.smoothing && !self.straight && self.extend.is_none() && self.redraw.is_none() && self.ends_near_start(cx, end) + self.draws_path() && !self.smoothing && self.redraw.is_none() && self.ends_near_start(cx, end) && self.connects_at(cx, end).is_none() } /// Parameters of `path.freehand` for the current stroke, fitted at Fidelity on screen. Only the @@ -1194,14 +1232,16 @@ impl GestureTool { if !self.began { return vec![]; } - // Close Paths When Ends Are Within is off at 0 pixels. + // Close Paths When Ends Are Within is off at 0 pixels. A stroke continuing a + // selected path closes it near its other end, a straight line too. let near = pts.last().is_some_and(|&end| self.ends_near_start(cx, end)); - // A straight line has two points: closing it would draw it back over itself. - let closed = !self.straight && ((m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || (self.extend.is_none() && near)); - let mut params = self.freehand_params(cx, closed); + // Alt at the release closes a freehand stroke; a new straight line has two points: + // closing it would draw it back over itself. + let closed = (!self.straight && m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || near; // A Pencil stroke that continues a selected path and ends on an open end of another - // path selected at the press connects the two. - let connect = pts.last().and_then(|&end| self.find_end(cx, end, &self.connect_to)); + // path selected at the press connects the two, and doesn't close. + let connect = pts.last().and_then(|&end| self.connects_at(cx, end)); + let mut params = self.freehand_params(cx, closed && connect.is_none()); if let Some((other, at_start, _)) = connect { params["connect"] = json!({"id": other.0, "end": if at_start { "start" } else { "end" }}); } @@ -1267,6 +1307,7 @@ impl Tool for GestureTool { self.active = true; self.began = false; self.near_start = false; + self.connects = false; // Alt switches the Pencil when its toggle is on, the Blob Brush always, and the // Paintbrush when the press has a selected path. self.smoothing = ev.mods.alt @@ -1278,7 +1319,7 @@ impl Tool for GestureTool { self.straight = (self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt)) || (self.id == "knife" && ev.mods.alt); self.unconstrained = self.straight && !ev.mods.shift; let extend = if self.draws_path() && !self.smoothing { self.find_extend(cx, p) } else { None }; - self.extend = extend.map(|(id, at_start, _)| (id, at_start)); + self.set_extend(cx, extend); // The stroke's previews select the path it continues, so the others are kept now. self.connect_to = match self.extend { Some((id, _)) if self.id == "pencil" => cx.selection.objects.iter().copied().filter(|o| *o != id).collect(), @@ -1336,16 +1377,20 @@ impl Tool for GestureTool { return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] }; } self.points.truncate(1); + // The line ends at `p`, where its end snaps. + end = p; } else if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) || self.points.len() >= crate::MAX_STROKE_SAMPLES { // A release here keeps the last point unless it snaps (`Up`), so the ring // follows the point `finish` will test. let kept = self.points.last().copied().filter(|l| l.distance(p) < cx.tol(1.0) && !self.snaps()); let release_end = kept.unwrap_or(end); self.near_start = self.release_closes_near_start(cx, release_end); + self.connects = self.connects_at(cx, release_end).is_some(); return vec![]; } self.push(p, ev); self.near_start = self.release_closes_near_start(cx, end); + self.connects = self.connects_at(cx, end).is_some(); if !self.draws_path() || self.smoothing { return vec![]; } @@ -1406,11 +1451,13 @@ impl Tool for GestureTool { self.active = false; self.began = false; self.near_start = false; + self.connects = false; self.smoothing = false; self.straight = false; self.unconstrained = false; self.clear(); self.extend = None; + self.close_to = None; self.connect_to.clear(); self.redraw = None; out @@ -1440,6 +1487,7 @@ impl Tool for GestureTool { self.active = false; self.began = false; self.near_start = false; + self.connects = false; self.smoothing = false; self.straight = false; self.unconstrained = false; @@ -1470,6 +1518,7 @@ impl Tool for GestureTool { self.snap.clear(); self.active = false; self.near_start = false; + self.connects = false; self.began = false; self.smoothing = false; self.straight = false; @@ -1557,6 +1606,11 @@ impl Tool for GestureTool { if !self.active && self.straight_at_press(m) { return if self.find_extend(cx, p).is_some() { Cursor::PencilContinue } else { Cursor::PencilStraight }; } + // A continuing Pencil stroke that a release would connect to another selected path's open + // end (`finish`) shows that it continues on; `connects` tests the point the release keeps. + if self.id == "pencil" && self.active && self.connects { + return Cursor::PencilContinue; + } // Alt held at the release closes the path (`finish`). // A straight line stays open, so its release has nothing to close, and before a press Alt // gives the Pencil a straight line. @@ -1576,9 +1630,12 @@ impl Tool for GestureTool { "smooth" => smooth_cursor(cx), "pathEraser" => Cursor::PathEraser, "join" => Cursor::Join, + // Before a press over an open end of a selected path the Pencil shows that the stroke + // continues it (`PointerKind::Down`). + _ if !self.active && self.find_extend(cx, p).is_some() => Cursor::PencilContinue, // Before a press that draws a new path the Pencil shows its star; over a selected path - // that the press continues from an end or redraws (`PointerKind::Down`), it does not. - _ if !self.active && self.find_extend(cx, p).is_none() && self.find_redraw(cx, p).is_none() => Cursor::PencilNew, + // that the press redraws (`PointerKind::Down`), it does not. + _ if !self.active && self.find_redraw(cx, p).is_none() => Cursor::PencilNew, _ => Cursor::Pencil, } } diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index ece7a369bb..33d6520b84 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -793,7 +793,7 @@ fn pencil_shows_the_star_before_a_press_that_draws_a_new_path() { } } let mut t = GestureTool::new("pencil"); - assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "over an end the stroke continues"); + assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::PencilContinue, "over an end the stroke continues"); assert_eq!(t.cursor(&cx, middle, Mods::default()), Cursor::Pencil, "over the path the stroke redraws"); assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "away from the path"); // While a stroke is drawn, the Pencil's own cursor. @@ -838,7 +838,7 @@ fn pencil_shift_line_continues_a_selected_path_from_its_end() { } /// Before a press over an end of a selected open path, the Pencil shows its continuation cursor -/// exactly when the press would draw a straight line that continues the path; away from the +/// exactly when the press continues the path, freehand or with a straight line; away from the /// ends, with nothing selected, with Edit Selected Paths off, once the press is down and with /// the Paintbrush, it does not. #[test] @@ -859,12 +859,9 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { let shift = Mods { shift: true, ..Default::default() }; let alt = Mods { alt: true, ..Default::default() }; let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; - // The release's stroke: a straight line that continues the path. - let continues = |acts: &[Vec]| { - acts.last() - .and_then(|a| a.first()) - .is_some_and(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() && v["points"].as_array().is_some_and(|q| q.len() == 2))) - }; + // The release's stroke continues the path. + let continues = + |acts: &[Vec]| acts.last().and_then(|a| a.first()).is_some_and(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some())); for alt_smooth in [false, true] { for m in [Mods::default(), shift, alt, shift_alt] { for (at, other_end) in [(Point::new(405.0, 303.0), (500.0, 296.0)), (Point::new(296.0, 302.0), (200.0, 310.0))] { @@ -880,7 +877,7 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { let end = Point::new(405.0, 303.0); assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilContinue); assert_eq!(t.cursor(&cx, end, alt), Cursor::PencilContinue, "Alt alone continues at any angle"); - assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "without Shift or Alt the stroke is freehand"); + assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::PencilContinue, "a freehand stroke continues it too"); assert_eq!(t.cursor(&cx, Point::new(350.0, 360.0), shift), Cursor::PencilStraight, "away from the ends"); // While the line is drawn, the Pencil's own cursor. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, end.x, end.y).with_mods(shift)); @@ -897,6 +894,105 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { assert_eq!(Cursor::PencilContinue.precise(), Cursor::Crosshair, "Use Precise Cursors"); } +/// A Pencil stroke continuing a selected path shows the continuation cursor while a release would +/// connect it to an open end of another path selected at the press, and the closing ring while a +/// release would close the path because the stroke ends within Close Paths When Ends Are Within +/// of the path's other end, a straight line too; the release does what the cursor showed. +#[test] +fn a_continued_stroke_shows_where_it_connects_or_closes() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let add = |d: &mut vectorcraft_doc::Document, pts: &[(f64, f64)]| { + let id = d.alloc_id(); + let mut els = vec![vectorcraft_geom::PathEl::MoveTo(Point::new(pts[0].0, pts[0].1))]; + els.extend(pts[1..].iter().map(|&(x, y)| vectorcraft_geom::PathEl::LineTo(Point::new(x, y)))); + let pd = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(els)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, pd, vectorcraft_doc::Appearance::default_art())).unwrap(); + id + }; + let a = add(&mut d, &[(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)]); + let b = add(&mut d, &[(500.0, 500.0), (600.0, 500.0)]); + let p = paint(); + let mut both = Selection::default(); + both.set([a, b]); + let mut cx = cx(&d, &both, &p); + cx.smart_guides = false; + cx.snap_to_point = false; + let none = Mods::default(); + let shift = Mods { shift: true, ..Default::default() }; + // Runs a stroke from `pts[0]`, giving the cursor at its last point before the release and the + // release's `path.freehand` parameters. + let run = |t: &mut GestureTool, pts: &[(f64, f64)], m: Mods| { + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m)); + for &(x, y) in &pts[1..] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(m)); + } + let (x, y) = pts[pts.len() - 1]; + let shown = t.cursor(&cx, Point::new(x, y), m); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(m)); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; + (shown, v.clone()) + }; + let mut t = GestureTool::new("pencil"); + // To the other selected path's start: it connects, and doesn't close. + let (shown, v) = run(&mut t, &[(400.0, 400.0), (450.0, 450.0), (498.0, 502.0)], none); + assert_eq!(shown, Cursor::PencilContinue, "near the other path's end"); + assert_eq!( + (v["extend"].clone(), v["connect"].clone(), v["closed"].clone()), + (json!({"id": a.0, "end": "end"}), json!({"id": b.0, "end": "start"}), json!(false)) + ); + // Back near the path's own start (10 px from it): the ring, and the release closes it. + let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (292.0, 306.0)], none); + assert_eq!(shown, Cursor::PencilClose, "near the path's other end"); + assert_eq!((v["extend"].clone(), v["closed"].clone()), (json!({"id": a.0, "end": "end"}), json!(true))); + assert!(v.get("connect").is_none(), "{v}"); + // Farther than Close Paths When Ends Are Within (15 px): no ring, and it stays open. + let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (290.0, 330.0)], none); + assert_eq!((shown, v["closed"].clone()), (Cursor::Pencil, json!(false)), "away from the other end"); + // A straight line from the end to near the start: the ring, and it closes the path. + let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 350.0), (306.0, 306.0)], shift); + assert_eq!(shown, Cursor::PencilClose, "a straight line near the other end"); + assert_eq!((v["points"].as_array().map(Vec::len), v["closed"].clone()), (Some(2), json!(true))); + // Close Paths When Ends Are Within off: neither ring nor close. + t.set_option("closeWithin", &json!(0.0)); + let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (292.0, 306.0)], none); + assert_eq!((shown, v["closed"].clone()), (Cursor::Pencil, json!(false)), "at 0 px"); +} + +/// The continuation cursor tests where the release ends, after snapping, not the pointer: here +/// Snap to Grid moves a stroke's end from 13 pt away from another selected path's end (outside Edit +/// Selected Paths' 12 px) to 9 pt away, so the release connects, and the cursor says so. +#[test] +fn the_continuation_cursor_follows_the_snapped_release() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let add = |d: &mut vectorcraft_doc::Document, a: (f64, f64), b: (f64, f64)| { + let id = d.alloc_id(); + let els = vec![vectorcraft_geom::PathEl::MoveTo(Point::new(a.0, a.1)), vectorcraft_geom::PathEl::LineTo(Point::new(b.0, b.1))]; + let pd = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(els)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, pd, vectorcraft_doc::Appearance::default_art())).unwrap(); + id + }; + // On the 9 pt grid. + let a = add(&mut d, (306.0, 306.0), (405.0, 405.0)); + let b = add(&mut d, (504.0, 504.0), (603.0, 504.0)); + let p = paint(); + let mut both = Selection::default(); + both.set([a, b]); + let mut cx = cx(&d, &both, &p); + cx.smart_guides = false; + cx.snap_to_point = false; + cx.snap_to_grid = true; + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 405.0, 405.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 460.0, 450.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 517.0, 504.0)); + assert_eq!(t.cursor(&cx, Point::new(517.0, 504.0), Mods::default()), Cursor::PencilContinue); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 517.0, 504.0)); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["connect"], json!({"id": b.0, "end": "start"}), "{v}"); +} + /// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt /// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag. #[test] diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 006f498b6e..d7ee8ecdd8 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -257,10 +257,11 @@ impl Leave { self } - /// The straight segment from the anchor `at` to a point being dragged (an open path's end - /// moved with Direct Selection): the point may land square to a segment or grazing a curve, - /// but is not held to Shift's angles or the construction guides, which a moved anchor - /// does not follow. + /// The straight segment from the point `at` to a point being placed (an open path's end + /// moved with Direct Selection, or the Dimension tool's second Linear point after a first + /// point that is not on an anchor): the point may + /// land square to a segment or grazing a curve, but is not held to Shift's angles or the + /// construction guides through `at`. pub fn contact(at: Point) -> Self { Self { at, shift: None, angles: &[], contact: true } } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 1119bb0764..15fec287b5 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -673,7 +673,10 @@ mod cursor_tests { // Away from a selected path the Pencil shows its new-path cursor, drawn with a star; its // own cursor, for drawing and for a selected path under the pointer, borrows the crosshair. assert_eq!(cursor_at("pencil", &d, away), Cursor::PencilNew); - assert_eq!(Cursor::PencilNew.precise(), Cursor::Crosshair); + // Its badged cursors too: Use Precise Cursors shows the plain crosshair. + for c in [Cursor::PencilNew, Cursor::PencilClose, Cursor::PencilStraight, Cursor::PencilContinue, Cursor::PaintbrushClose] { + assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}"); + } assert_eq!(Cursor::PencilNew.stand_in(), None); assert_eq!(Cursor::Pencil.precise(), Cursor::Crosshair); assert_eq!(Cursor::Pencil.stand_in(), Some(Cursor::Crosshair)); diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 5e198e6fb7..51312c685a 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -48,6 +48,54 @@ pub(crate) fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) { (nw, nh) } +/// The `w × h` RGBA8 `rgba` reduced to `nw × nh` (at most `w × h`): each pixel covers a block of +/// whole source pixels, and every source pixel falls in exactly one block. A pixel's alpha is the +/// mean of its block's alphas, and its color is the block's colors weighted by their alpha, so a +/// clear pixel next to an opaque one does not darken or tint it. A wholly clear block (every alpha +/// 0) keeps the plain mean of its colors. +pub(crate) fn box_reduced(rgba: &[u8], w: u32, h: u32, nw: u32, nh: u32) -> Vec { + let span = |i: u32, n: u32, of: u32| { + let at = |i: u32| usize::try_from(u64::from(i) * u64::from(of) / u64::from(n.max(1))).unwrap_or(usize::MAX); + let start = at(i); + start..at(i + 1).max(start + 1) + }; + let cols: Vec<_> = (0..nw).map(|x| span(x, nw, w)).collect(); + let mut out = Vec::with_capacity(nw as usize * nh as usize * 4); + // Per output column: the alpha-weighted color sums, the alpha sum and the plain color sums. + let mut sums = vec![[0u64; 7]; cols.len()]; + let row_len = w as usize * 4; + for ty in 0..nh { + let rows = span(ty, nh, h); + sums.fill([0; 7]); + for y in rows.clone() { + let Some(row) = rgba.get(y * row_len..(y + 1) * row_len) else { break }; + for (s, c) in sums.iter_mut().zip(&cols) { + let px = row.get(c.start * 4..c.end * 4).unwrap_or_default(); + for &[r, g, b, a] in px.as_chunks::<4>().0 { + let a64 = u64::from(a); + s[0] += u64::from(r) * a64; + s[1] += u64::from(g) * a64; + s[2] += u64::from(b) * a64; + s[3] += a64; + s[4] += u64::from(r); + s[5] += u64::from(g); + s[6] += u64::from(b); + } + } + } + for (s, c) in sums.iter().zip(&cols) { + let count = (rows.len() * c.len()).max(1) as u64; + let mean = |v: u64, n: u64| u8::try_from((v + n / 2) / n).unwrap_or(u8::MAX); + if s[3] == 0 { + out.extend_from_slice(&[mean(s[4], count), mean(s[5], count), mean(s[6], count), 0]); + } else { + out.extend_from_slice(&[mean(s[0], s[3]), mean(s[1], s[3]), mean(s[2], s[3]), mean(s[3], count)]); + } + } + } + out +} + /// An RGBA8 raster (row-major, top row first). #[derive(Clone, Debug, PartialEq)] pub struct Raster { @@ -150,12 +198,14 @@ impl Raster { let mut limits = image::Limits::default(); limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }); reader.limits(limits); - let img = reader.decode().map_err(decode_err)?.into_rgba8(); + Ok(Self::fitted(&reader.decode().map_err(decode_err)?.into_rgba8(), MAX_PIXELS)) + } + /// `img` scaled down, aspect kept, to the largest size of at most `max` pixels. + pub(crate) fn fitted(img: &image::RgbaImage, max: u64) -> Self { let (w, h) = img.dimensions(); - let (nw, nh) = fit_size(w, h, MAX_PIXELS); + let (nw, nh) = fit_size(w, h, max); // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. - let small = image::imageops::thumbnail(&img, nw, nh); - Ok(Self { width: nw, height: nh, rgba: small.into_raw() }) + Self { width: nw, height: nh, rgba: box_reduced(img.as_raw(), w, h, nw, nh) } } /// Encode as PNG. pub fn encode_png(&self) -> Vec { diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 2bf9e1e2b6..80eb322bbc 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1085,6 +1085,30 @@ fn decode_to_fit_scales_an_oversized_image_down() { assert!(matches!(Raster::decode_to_fit(&png_header(9000, 9000)), Err(TraceError::Decode(_)))); } +/// Scaling an oversized image down weighs each color by its alpha: a clear pixel next to an +/// opaque one leaves the opaque color as it is and only lowers the alpha. +#[test] +fn scaling_down_weighs_color_by_alpha() { + use crate::raster::box_reduced; + const RED: [u8; 4] = [200, 30, 30, 255]; + // Opaque red and clear black columns, decoded from a PNG and scaled to fit as `decode_to_fit` + // does: each scaled pixel covers a red and a clear one. + let half_clear = Raster::from_fn(4, 2, |x, _| if x % 2 == 0 { RED } else { [0; 4] }); + let decoded = image::load_from_memory(&half_clear.encode_png()).unwrap().into_rgba8(); + let fitted = Raster::fitted(&decoded, 2); + assert_eq!((fitted.width, fitted.height), (2, 1)); + assert_eq!(fitted.rgba, [[200, 30, 30, 128]; 2].concat()); + // A partly clear blue pulls an opaque red toward it by its alpha. + let mixed = Raster::from_fn(2, 1, |x, _| if x == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 85] }); + assert_eq!(box_reduced(&mixed.rgba, 2, 1, 1, 1), [191, 0, 64, 170]); + // A wholly clear block stays clear, with the plain mean of its colors. + let clear = Raster::from_fn(2, 1, |x, _| [100 * x as u8, 0, 0, 0]); + assert_eq!(box_reduced(&clear.rgba, 2, 1, 1, 1), [50, 0, 0, 0]); + // A 3 → 2 reduction counts every source pixel once. + let ramp = Raster::from_fn(3, 3, |x, y| [(x * 60 + y * 10) as u8, 0, 0, 255]); + assert_eq!(box_reduced(&ramp.rgba, 3, 3, 2, 2), [0, 0, 0, 255, 90, 0, 0, 255, 15, 0, 0, 255, 105, 0, 0, 255]); +} + /// Four flat colours with anti-aliased (blended) edges between the quadrants. fn flat_quads() -> Raster { const C: [[u8; 3]; 4] = [[220, 30, 30], [30, 200, 40], [30, 40, 210], [240, 220, 20]]; diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index a438a71881..5495d3753c 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -677,18 +677,16 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { ink.circle_stroke(p + vec2(8.0, 8.0), 3.0, Stroke::new(3.0, HALO)); ink.circle_stroke(p + vec2(8.0, 8.0), 3.0, Stroke::new(1.2, INK)); } - // The Pencil's crosshair with a short straight line where the closing ring goes. + // The Pencil's crosshair with a short level line where the closing ring goes. Cursor::PencilStraight => { crosshair(ink, p); - line(ink, p + vec2(5.0, 12.0), p + vec2(12.0, 5.0)); + line(ink, p + vec2(3.55, 8.5), p + vec2(13.45, 8.5)); } - // The straight-line cursor's line leaving a filled square: the end point a line continues from. + // The Pencil's crosshair with a short line rising to the right where the closing ring goes: + // the stroke carries on from the path. Cursor::PencilContinue => { crosshair(ink, p); - // The square first, so its halo does not cover the line. - let end = p + vec2(5.0, 12.0); - poly(ink, [(-2.0, -2.0), (2.0, -2.0), (2.0, 2.0), (-2.0, 2.0)].map(|(x, y)| end + vec2(x, y)).to_vec(), INK, INK); - line(ink, end, p + vec2(12.0, 5.0)); + line(ink, p + vec2(5.0, 12.0), p + vec2(12.0, 5.0)); } Cursor::Eyedropper => eyedropper(ink, p), Cursor::ResizeH => double_arrow(ink, p, vec2(1.0, 0.0)), diff --git a/crates/ui-egui/src/cursors/tests.rs b/crates/ui-egui/src/cursors/tests.rs index ae1947616f..49c932175d 100644 --- a/crates/ui-egui/src/cursors/tests.rs +++ b/crates/ui-egui/src/cursors/tests.rs @@ -244,7 +244,7 @@ fn the_pencil_new_path_cursor_is_the_crosshair_with_a_star() { } } -/// The Pencil's straight-line cursor is the crosshair with a short straight line below right, +/// The Pencil's straight-line cursor is the crosshair with a short level line below right, /// through the point where the closing cursor's ring is hollow. #[test] fn the_pencil_straight_line_cursor_is_the_crosshair_with_a_line() { @@ -264,15 +264,18 @@ fn the_pencil_straight_line_cursor_is_the_crosshair_with_a_line() { assert_eq!(at(&cross, 8.5, 8.5)[3], 0, "the crosshair has no badge at {ppp}"); // Along the line, not across it: no ink off its ends. assert!(!inked(at(&img, 5.0, 5.0)) && !inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}"); + // Level: inked at both ends of it, and not where the continuation cursor's line rises. + assert!(inked(at(&img, 4.5, 8.5)) && inked(at(&img, 12.5, 8.5)), "a level line at {ppp}"); + assert!(!inked(at(&img, 6.0, 11.0)) && !inked(at(&img, 11.0, 6.0)), "not a rising one at {ppp}"); assert_ne!(img, ring, "at {ppp}"); assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}"); } } -/// The Pencil's continuation cursor is the straight-line cursor with a filled square on the -/// line's lower end, beside the line where the straight-line cursor has no ink. +/// The Pencil's continuation cursor is the crosshair with a short line rising to the right below +/// right, where the straight-line cursor's line is level. #[test] -fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point() { +fn the_pencil_continuation_cursor_is_the_crosshair_with_a_rising_line() { for ppp in [1.0, 2.0] { let cross = rasterize(Cursor::Crosshair, ppp).unwrap(); let img = rasterize(Cursor::PencilContinue, ppp).unwrap(); @@ -285,8 +288,10 @@ fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point( }; let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64; assert!(inked(at(&img, 8.5, 8.5)), "the line crosses the badge's middle at {ppp}"); - assert!(inked(at(&img, 3.5, 13.5)), "the end point's square at {ppp}"); - assert!(!inked(at(&straight, 3.5, 13.5)), "the straight-line cursor has no end point at {ppp}"); + assert!(inked(at(&img, 5.0, 11.0)) && inked(at(&img, 10.0, 6.0)), "the line rises to the right at {ppp}"); + assert!(!inked(at(&straight, 6.0, 11.0)), "the straight-line cursor's line is level at {ppp}"); + assert!(!inked(at(&img, 4.5, 8.5)) && !inked(at(&img, 12.5, 8.5)), "not a level one at {ppp}"); + assert!(!inked(at(&img, 3.5, 13.5)), "no end point square at {ppp}"); assert!(!inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}"); assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}"); } diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index 32d8dcb2e9..ecfb0c303e 100644 --- a/crates/ui-egui/src/dialogs/graph_data.rs +++ b/crates/ui-egui/src/dialogs/graph_data.rs @@ -149,7 +149,10 @@ pub(crate) fn grid_from_text(text: &str) -> Grid { } /// A text file's bytes as text: UTF-16 with its byte-order mark (spreadsheets' "Unicode Text"), -/// else UTF-8 without one. +/// else UTF-16 without one when the bytes hold NUL bytes and Import Data Sets' rule gives them a +/// byte order ([`vectorcraft_engine::cmd::utf16_order_without_mark`]), else UTF-8 with or without +/// one. UTF-16 is read as it is after a byte-order mark: an odd last byte is left out, and an +/// unpaired surrogate reads as U+FFFD. pub(crate) fn decode(bytes: &[u8]) -> String { let utf16 = |rest: &[u8], le: bool| { let units: Vec = rest.as_chunks::<2>().0.iter().map(|p| if le { u16::from_le_bytes(*p) } else { u16::from_be_bytes(*p) }).collect(); @@ -159,7 +162,10 @@ pub(crate) fn decode(bytes: &[u8]) -> String { [0xFF, 0xFE, rest @ ..] => utf16(rest, true), [0xFE, 0xFF, rest @ ..] => utf16(rest, false), [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), - _ => String::from_utf8_lossy(bytes).into_owned(), + _ => match bytes.contains(&0).then(|| vectorcraft_engine::cmd::utf16_order_without_mark(bytes)).flatten() { + Some(le) => utf16(bytes, le), + None => String::from_utf8_lossy(bytes).into_owned(), + }, } } @@ -526,6 +532,28 @@ mod tests { assert_eq!(grid_from_text(&wide)[0].len(), MAX_COLS); } + #[test] + fn utf16_without_a_byte_order_mark_reads_by_its_line_breaks() { + let text = "\tSales é\r\nQ1\t1\n"; + let le: Vec = text.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let be: Vec = text.encode_utf16().flat_map(u16::to_be_bytes).collect(); + assert_eq!(decode(&le), text, "little-endian"); + assert_eq!(decode(&be), text, "big-endian"); + assert_eq!(grid_from_text(&decode(&le)), g(&[&["", "Sales é"], &["Q1", "1"]])); + // One row has no line break: the side of each unit that holds the NUL byte gives the order. + for (bytes, order) in [(b"\0Q\x001\0\t\x002".to_vec(), "big-endian"), (b"Q\x001\0\t\x002\0".to_vec(), "little-endian")] { + assert_eq!(decode(&bytes), "Q1\t2", "one line, {order}"); + } + // A file cut short by one byte, or with an unpaired surrogate, reads as it would after a + // byte-order mark. + assert_eq!(decode(&[le.as_slice(), &[0x0A]].concat()), text, "odd length"); + assert_eq!(decode(&[le.as_slice(), &[0x00, 0xD8]].concat()), format!("{text}\u{FFFD}"), "unpaired surrogate"); + // A line break in both byte orders leaves the order unknown, and UTF-8 with a stray NUL + // byte isn't UTF-16: both read as UTF-8, as before. + assert_eq!(decode(&[0x0A, 0, 0, 0x0A]), "\n\0\0\n"); + assert_eq!(decode(b"a,b\n1,2\0"), "a,b\n1,2\0"); + } + #[test] fn a_sheet_sent_wider_and_taller_than_a_graph_is_cut_to_one() { // One long row and one long column: their product would be ten billion cells. diff --git a/crates/ui-egui/src/dialogs/justification.rs b/crates/ui-egui/src/dialogs/justification.rs index dc42dcff87..168f5cfda1 100644 --- a/crates/ui-egui/src/dialogs/justification.rs +++ b/crates/ui-egui/src/dialogs/justification.rs @@ -9,8 +9,10 @@ //! Fields: `wordMin`, `wordDesired`, `wordMax` (0..1000), `letterMin`, `letterDesired`, //! `letterMax` (-100..500), `glyphMin`, `glyphDesired`, `glyphMax` (50..200), `autoLeading` //! (0..500), `singleWord` (`fullJustify`, `alignLeft`, `alignCenter`, `alignRight`) and `preview`, -//! with `__initial` (the values it opened with), `__targets` (the type and range it acts on) and, -//! after Select All in threaded text, `__story` (the edited frame, whose story it acts on). +//! with `__initial` (the values it opened with), `__targets` (the type and range it acts on; with +//! the Selection tool, every frame of each story selected when the dialog opened, locked and hidden +//! frames included) and, after Select All in threaded text, `__story` (the edited frame, whose +//! story it acts on). //! //! Behaviour reference (public documentation): //! . @@ -95,6 +97,9 @@ pub fn open(app: &mut VectorcraftApp) -> Result { targets = json!({"ids": [id.0], "start": a, "end": b}); // After Select All in threaded text: every paragraph of every frame ([`params`]). story = crate::panels::character::text_story(app).map(|_| id.0); + } else { + // With the Selection tool: every frame of each selected story. + crate::panels::character::target_selected_stories(app, &mut targets); } let mut values = Map::new(); for (_, prefix, _) in ROWS { @@ -423,6 +428,74 @@ mod tests { } } + /// A threaded story of three frames, its second frame locked and its third hidden, a type object + /// of its own, and the first frame selected with the Selection tool → the app, the frames and + /// the other type object. + fn story_selected_in_its_first_frame() -> (VectorcraftApp, [u64; 3], u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + (app, [a, b, c], other) + } + + /// Every paragraph's Auto Leading in `id`. + fn auto_leadings(app: &VectorcraftApp, id: u64) -> Vec { + match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).justification.auto_leading).collect(), + _ => panic!("not text"), + } + } + + /// With the Selection tool a selected frame of threaded text stands for its whole story: the + /// dialog opens blank where a locked or hidden frame differs, previews on every frame, Cancel + /// restores them, and OK changes every frame of the story as it was when the dialog opened, in + /// one undo step, leaving other type and a selection made later alone. + #[test] + fn selection_tool_justification_reaches_every_frame_of_the_story() { + let (mut app, frames, other) = story_selected_in_its_first_frame(); + let [a, _, c] = frames; + for id in frames { + assert!(!auto_leadings(&app, id).is_empty(), "frame {id} holds text"); + } + app.run("text.setFormat", json!({"ids": [c], "autoLeading": 150})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("ui.justification", json!({})).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("autoLeading"), "the hidden frame differs"); + set(&mut app, "autoLeading", json!(200)); + set(&mut app, "preview", json!(true)); + frame(&mut app); + for id in frames { + assert!(auto_leadings(&app, id).iter().all(|l| *l == 200.0), "the preview shows on frame {id}"); + } + super::super::cancel(&mut app); + assert!(auto_leadings(&app, a).iter().all(|l| *l == 120.0)); + assert!(auto_leadings(&app, c).iter().all(|l| *l == 150.0), "Cancel restores the hidden frame"); + + app.run("ui.justification", json!({})).unwrap(); + // A selection made while the dialog is open does not change what it acts on. + app.run("select.set", json!({"ids": [other]})).unwrap(); + set(&mut app, "autoLeading", json!(140)); + let undo = app.session.active().unwrap().history.undo.len(); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); + for id in frames { + assert!(auto_leadings(&app, id).iter().all(|l| *l == 140.0), "frame {id} takes the change"); + } + assert!(auto_leadings(&app, other).iter().all(|l| *l == 120.0), "other type keeps its paragraphs"); + app.run("edit.undo", json!({})).unwrap(); + assert!(auto_leadings(&app, c).iter().all(|l| *l == 150.0), "undo restores the hidden frame"); + } + /// When paragraphs in scope disagree, only shared justification values are filled in. #[test] fn justification_dialog_mixed_paragraph_fields_show_blank() { diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7f2f03afb2..a1f6100b2f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7491,6 +7491,1820 @@ breakText Glyphs Glifák Grommets Ringlik Grommet Inset Ringlik távolsága a széltől Diameter Átmérő +# Status and error messages of commands, scripts, file import and export, and plug-ins. +@msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions a 'caller', a 'callee' és az 'arguments' nem használható szigorú módú függvényeken +@msg A profile supports one Revolve effect. Edit the existing effect's options instead. Egy profilon egy Forgatás tengely körül effektus lehet. Inkább a meglévő effektus beállításait szerkeszd. +@msg Add Points at Crossings: a path went missing Pontok hozzáadása a kereszteződésekben: egy útvonal eltűnt +@msg Alignment must be one of the choices Az igazítás csak a választási lehetőségek egyike lehet +@msg Boolean.prototype.{name} was called on a value that is not a Boolean a Boolean.prototype.{name} hívása olyan értéken történt, amely nem Boolean +@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB Azok a CMYK-képek, amelyek mintái nem voltak CMYK-ban olvashatók, RGB-ben lettek importálva +@msg CSS options: {e} CSS-beállítások: {e} +@msg CSV has a header but no data rows A CSV-ben van fejléc, de nincsenek adatsorok +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark A CSV NUL bájtokat tartalmaz: mentsd UTF-8-ként, vagy bájtsorrendjellel ellátott UTF-16-ként +@msg CSV is empty A CSV üres +@msg CSV is larger than {MAX_CSV_BYTES} bytes A CSV nagyobb, mint {MAX_CSV_BYTES} bájt +@msg CSV is not UTF-16 A CSV nem UTF-16 kódolású +@msg CSV is not UTF-8 A CSV nem UTF-8 kódolású +@msg CSV would be larger than {MAX_CSV_BYTES} bytes A CSV nagyobb lenne, mint {MAX_CSV_BYTES} bájt +@msg Cannot write to the location of “{name}”; choose where to save it Nem lehet írni ennek a helyére: „{name}”; válaszd ki, hová kerüljön a mentés +@msg Choose original artwork, not a dimension's generated paths Eredeti grafikát válassz, ne egy méretvonal generált útvonalait +@msg Contour: select paths or shapes Kontúr: jelölj ki útvonalakat vagy alakzatokat +@msg Contour: the Offset Path effect is not available Kontúr: az Útvonal eltolása effektus nem érhető el +@msg Create needs fills, strokes or both A létrehozáshoz kitöltések, körvonalak vagy mindkettő kell +@msg DCS files can only be placed linked: use File › Place with Link on A DCS-fájlok csak csatolva helyezhetők el: használd a Fájl › Elhelyezés parancsot bekapcsolt Csatolás beállítással +@msg DXF options: {e} DXF-beállítások: {e} +@msg DXF scale must be a positive number up to {_1}, not {scale} A DXF-lépték pozitív szám lehet, legfeljebb {_1}; ez nem az: {scale} +@msg DXF {field} `{s}`: {allowed} DXF {field} `{s}`: {allowed} +@msg Date.prototype method called on a value that is not a Date object Date.prototype metódus hívása olyan értéken történt, amely nem Date objektum +@msg Dimension is locked or hidden A méretvonal zárolt vagy rejtett +@msg Dimension mode does not match its source A méretvonal módja nem egyezik a forrásával +@msg Draw Inside needs exactly one selected path, compound path or text A Rajzolás belülre módhoz pontosan egy kijelölt útvonal, összetett útvonal vagy szöveg kell +@msg EPS Save writes all artwork: use document.export for selected-only EPS Az EPS-mentés a teljes grafikát kiírja: csak a kijelölést tartalmazó EPS-hez használd a document.export parancsot +@msg EPS flattener: {e} EPS-lapító: {e} +@msg EPS options: {e} EPS-beállítások: {e} +@msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} +@msg Error.prototype.toString was called on a value that is not an object az Error.prototype.toString hívása olyan értéken történt, amely nem objektum +@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft Az Exportálás képernyőkre ezeket írja: png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai vagy vectorcraft +@msg Flare click coordinates must be finite numbers A Fényvillanás kattintási koordinátái véges számok legyenek +@msg Function.prototype.toString was called on a value that is not a function a Function.prototype.toString hívása olyan értéken történt, amely nem függvény +@msg HTML Text applies to object slices of type only A HTML-szöveg csak szövegobjektumból készült szeletekre alkalmazható +@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) Az Objektumok azonosítása beállítás XML-azonosító: a névnek érvényes XML-azonosítónak kell lennie (betűvel, aláhúzásjellel vagy kettősponttal kezdődik; szóköz nélkül) +@msg Image Trace failed: internal error: its thread stopped (please report this bug) A képvektorizálás nem sikerült: belső hiba: a szála leállt (kérjük, jelentsd ezt a hibát) +@msg Image Trace failed: {e} A képvektorizálás nem sikerült: {e} +@msg Image Trace finished, but its document was closed A képvektorizálás elkészült, de a dokumentumát bezárták +@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again A képvektorizálás elkészült, de a kép vagy a beállításai közben megváltoztak: vektorizáld újra +@msg Import SVG Filter reads SVG files, not SVGZ Az SVG-szűrő importálása SVG-fájlokat olvas, SVGZ-fájlokat nem +@msg Invalid dimension offset Érvénytelen méretvonal-eltolás +@msg Invalid point Érvénytelen pont +@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (eltolás: {at}) +@msg JSON.stringify cannot write a structure that contains itself a JSON.stringify nem tud olyan szerkezetet kiírni, amely önmagát tartalmazza +@msg Join: a subpath went missing Összekapcsolás: egy részútvonal eltűnt +@msg Line Gap must be a number A Sorköz szám kell legyen +@msg Lock Workspace is not available in VectorCraft A Munkaterület zárolása nem érhető el a VectorCraftban +@msg Move Panel to New Window is not available: floating panels stay in the main window A Panel áthelyezése új ablakba nem érhető el: a lebegő panelek a főablakban maradnak +@msg Namespace.prototype.toString needs a Namespace a Namespace.prototype.toString hívásához Namespace kell +@msg New Document profiles need the preferences folder, which this run doesn't have Az új dokumentumok profiljaihoz a beállítások mappája kell, amely ebben a futásban nincs meg +@msg Number.prototype.{name} was called on a value that is not a number a Number.prototype.{name} hívása olyan értéken történt, amely nem szám +@msg Object.create's prototype must be an object or null az Object.create prototípusa objektum vagy null lehet +@msg Object.{function} was given a value that is not an object az Object.{function} olyan értéket kapott, amely nem objektum +@msg Offset Path: the path is too complex to offset Útvonal eltolása: az útvonal túl összetett az eltoláshoz +@msg Offset Path: {e} Útvonal eltolása: {e} +@msg PDF content is incomplete or exceeds the reader's limits; no document was imported A PDF tartalma hiányos, vagy túllépi az olvasó korlátait; nem lett dokumentum importálva +@msg PDF flattener: {e} PDF-lapító: {e} +@msg PDF options: {e} PDF-beállítások: {e} +@msg QName.prototype.toString needs a QName a QName.prototype.toString hívásához QName kell +@msg Recolor Artwork is no longer open A Grafika átszínezése már nincs megnyitva +@msg RegExp.prototype.{name} was called on a value that is not a RegExp object a RegExp.prototype.{name} hívása olyan értéken történt, amely nem RegExp objektum +@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 Az SVG nem UTF-8, UTF-16 vagy ISO 8859-1 kódolású +@msg Save writes one artboard: name one, or export several with document.export A mentés egy rajztáblát ír ki: nevezz meg egyet, vagy exportálj többet a document.export paranccsal +@msg Select a dimension first Előbb jelölj ki egy méretvonalat +@msg Select dimensions to update Jelöld ki a frissítendő méretvonalakat +@msg Select only dimensions Csak méretvonalakat jelölj ki +@msg Select two or more objects, or a group Jelölj ki két vagy több objektumot, vagy egy csoportot +@msg Shaper needs 1–64 source paths Az Alakformálóhoz 1–64 forrásútvonal kell +@msg Stencil Bridges: the selected art is too large or too detailed to bridge Sablonhidak: a kijelölt grafika túl nagy vagy túl részletes a hidak elhelyezéséhez +@msg String.prototype.toString was called on a value that is not a string a String.prototype.toString hívása olyan értéken történt, amely nem karakterlánc +@msg Switch x/y is for scatter graphs Az x/y felcserélése szóródiagramokhoz való +@msg Text options: {e} Szövegbeállítások: {e} +@msg The {name} profile could not be read, so the new document has the default settings: {reason} Ez a profil nem volt olvasható: {name}; ezért az új dokumentum az alapértelmezett beállításokat kapta: {reason} +@msg Trap: more than {_1} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time Csapdázás: több mint {_1} objektumpár van elég közel a csapdázáshoz, vagy túl sok objektum esik azonos vízszintes tartományba; egyszerre kevesebb objektumot csapdázz +@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off Gyomláló keret és vonalak: a grafikán kívül nem fér el gyomlálóvonal, és a gyomláló keret ki van kapcsolva +@msg \\ at end of pattern \\ a minta végén +@msg `alignOn` cannot be empty `alignOn`: nem lehet üres +@msg `alignOn` must be a string `alignOn`: karakterlánc kell +@msg `align` is start, center or end `align`: start, center vagy end +@msg `align` must be a string `align`: karakterlánc kell +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors`: [subpath, anchor] párok tömbje kell +@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors`: horgonypont-indexek listája kell (0, 1, 2…), ez nem az: {k} +@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors`: horgonypont-indexek listája kell (0, 1, 2…), ez nem az: {v} +@msg `answer` for this {kind} dialog must be one of: {_1} `answer`: ennél a párbeszédablaknál ({kind}) ezek egyike kell: {_1} +@msg `answers` has more than {MAX_ANSWERS} answers `answers`: több mint {MAX_ANSWERS} választ tartalmaz +@msg `answers` is larger than 1 MiB `answers`: nagyobb 1 MiB-nál +@msg `answers` must be a list of answers `answers`: válaszok listája kell +@msg `artboard` must be the index of an artboard of the document `artboard`: a dokumentum egyik rajztáblájának indexe kell +@msg `artboardsAt` must be [x, y] `artboardsAt`: [x, y] kell +@msg `artboards` must be a list of artboard indices `artboards`: rajztáblaindexek listája kell +@msg `artboards` names an artboard the document does not have `artboards`: olyan rajztáblát nevez meg, amely nincs a dokumentumban +@msg `artboards` names an artboard twice `artboards`: egy rajztáblát kétszer nevez meg +@msg `asset` is the asset id (see assets.list) `asset`: az elem azonosítója (lásd: assets.list) +@msg `at` must be [x, y] (got {at}) `at`: [x, y] kell (kapott érték: {at}) +@msg `at` must be a byte offset `at`: bájteltolás kell +@msg `at` must be a point [x, y] `at`: [x, y] pont kell +@msg `autoLeading` must be a number `autoLeading`: szám kell +@msg `background` must be "", "matte" or #rrggbb `background`: "", "matte" vagy #rrggbb kell +@msg `beside` needs `side` `beside`: `side` is kell hozzá +@msg `burasagari` must be "none", "standard" or "forced" `burasagari`: "none", "standard" vagy "forced" kell +@msg `center` must be [x, y] `center`: [x, y] kell +@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign`: "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" vagy "icfBottom" kell +@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection`: "default", "leftToRight" vagy "rightToLeft" kell +@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` `clearStroke`: `stroke: false` és `ids: []` kell hozzá, `stop` nélkül +@msg `clear` must be a list of type object ids, not {v} `clear`: szövegobjektum-azonosítók listája kell, ez nem az: {v} +@msg `clear` must list type objects; {_1} is not one `clear`: szövegobjektumok listája kell; ez nem az: {_1} +@msg `clear` must not list `id` `clear`: nem szerepelhet benne az `id` +@msg `code` must be a whole number `code`: egész szám kell +@msg `colorMode` must be one of rgb, cmyk `colorMode`: ezek egyike kell: rgb, cmyk +@msg `color` needs a `rule` to make the theme from `color`: `rule` is kell hozzá, amelyből a téma készül +@msg `colors` does not apply to the {name} reduction `colors`: ennél a színcsökkentésnél nem használható: {name} +@msg `colors` is empty `colors`: üres +@msg `colors` must be a whole number from 2 to 256 `colors`: 2 és 256 közötti egész szám kell +@msg `colors` must be at least 9 for the uniform reduction `colors`: az egyenletes színcsökkentéshez legalább 9 kell +@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors`: 1 és {MAX_THEME_COLORS} közötti érték kell (kapott érték: {n}) +@msg `columnType` is vertical, uniform, repeating or sliding `columnType`: vertical, uniform, repeating vagy sliding +@msg `columns` must be a whole number 1..50 `columns`: egész szám kell, 1..50 +@msg `commands` must not be empty `commands`: nem lehet üres +@msg `contents` goes with `to` `contents`: a `to` kulccsal együtt használható +@msg `corners` must be sharp, round or square, not `{other}` `corners`: sharp, round vagy square kell, ez nem az: `{other}` +@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners`: horgonypont-indexek listája kell (0, 1, 2…), ez nem az: {v} +@msg `count` must be a whole number from 0 to {MAX_COUNT} `count`: 0 és {MAX_COUNT} közötti egész szám kell +@msg `count` must be a whole number from 1 to 100 `count`: 1 és 100 közötti egész szám kell +@msg `dataBase64` is larger than the module limit `dataBase64`: nagyobb a modulok méretkorlátjánál +@msg `dataBase64` is not valid base64 `dataBase64`: nem érvényes base64 +@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly`: nem használható együtt ezekkel: `ids`, `reverse`, `append` +@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` és `childLimit`: az összegzést szűkítik; add meg ezt: `summary: true` +@msg `design` is a design name, or null for plain columns `design`: diagramminta neve, vagy null az egyszerű oszlopokhoz +@msg `design` is a design name, or null for the default marker `design`: diagramminta neve, vagy null az alapértelmezett jelölőhöz +@msg `destinationFolder` is required when destination is `folder` `destinationFolder`: kötelező, ha a cél `folder` +@msg `destination` must be `none`, `saveAndClose` or `folder` `destination`: `none`, `saveAndClose` vagy `folder` kell +@msg `diameter` must be from 0.1 to 2500 pt `diameter`: 0.1 és 2500 pt közötti érték kell +@msg `digits` must be one of {_1} `digits`: ezek egyike kell: {_1} +@msg `dimImages` is a percentage or false `dimImages`: százalék vagy false +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction`: "auto", "leftToRight" vagy "rightToLeft" kell +@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount`: 0 és 100 közötti szám kell +@msg `each` is the value one design stands for, more than 0 `each`: az az érték, amelyet egy minta jelöl; 0-nál nagyobb +@msg `end` must be "start" or "end" `end`: "start" vagy "end" kell +@msg `ends` applies to joining exactly two paths `ends`: pontosan két útvonal összekapcsolásakor használható +@msg `event` must be one of {_1} `event`: ezek egyike kell: {_1} +@msg `every` must be "all", "odd" or "even", not {v} `every`: "all", "odd" vagy "even" kell, ez nem az: {v} +@msg `expand` must be true or false `expand`: true vagy false kell +@msg `fidelity` must be from {lo} to {hi} pt `fidelity`: {lo} és {hi} pt közötti érték kell +@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files`: a legutóbbi text.findFontFiles keresés által talált betűtípusfájlok 1–1000 elérési útja kell +@msg `focal` and `radius` cannot be used together `focal` és `radius`: nem használhatók együtt +@msg `focal` must be an array of [subpath, anchor] pairs `focal`: [subpath, anchor] párok tömbje kell +@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal`: ezt nevezi meg: [{si}, {ai}], de ez nem az útvonal horgonypontja +@msg `folder` must be an absolute folder path `folder`: abszolút mappaútvonal kell +@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes`: tengelycímkék és értékek objektuma kell, például {"wght"} +@msg `format` must be a string `format`: karakterlánc kell +@msg `format` must be one of {_1} `format`: ezek egyike kell: {_1} +@msg `fractions` is chop or scale `fractions`: chop vagy scale +@msg `from` and `to` must differ `from` és `to`: különbözniük kell +@msg `from` has an empty frame `from`: üres a kerete +@msg `from` must be a non-negative integer `from`: nemnegatív egész szám kell +@msg `from` must be area type or type on a path `from`: területszöveg vagy útvonalszöveg kell +@msg `gid` must be a glyph id, not {v} `gid`: glifaazonosító kell, ez nem az: {v} +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate`: egyetlen karakterhez tartozik, nem karakterstílushoz (a text.setGlyphAlternate állítja be) +@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate`: csak null adható meg, amely megszünteti a glifaválasztást (a text.setGlyphAlternate hoz létre ilyet) +@msg `group` must be one of {_1} `group`: ezek egyike kell: {_1} +@msg `group` must be the Objects on Path group of `member` and `target` `group`: a `member` és a `target` Objektumok útvonalon csoportja kell +@msg `guide` must be a guide index (guide.list) `guide`: segédvonalindex kell (guide.list) +@msg `hAlign` must be default, left, center or right `hAlign`: default, left, center vagy right kell +@msg `id` is an object id, not {v} `id`: objektumazonosító, ez nem az: {v} +@msg `id` must be a node id `id`: csomópont-azonosító kell +@msg `id` must be an object id, not {v} `id`: objektumazonosító kell, ez nem az: {v} +@msg `ids` must name Objects on Path groups or objects in them `ids`: Objektumok útvonalon csoportokat vagy a bennük lévő objektumokat kell megneveznie +@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids`: legfeljebb {MAX_INSPECT_IDS} azonosítót fogad +@msg `imageMap` must be none, rectangle or polygon `imageMap`: none, rectangle vagy polygon kell +@msg `includeArtboardFill` must be true or false `includeArtboardFill`: true vagy false kell +@msg `includeSubdirs` must be true or false `includeSubdirs`: true vagy false kell +@msg `index` must be a point index `index`: pontindex kell +@msg `index` must be a whole number or null `index`: egész szám vagy null kell +@msg `indices` must be a non-empty array of item indices `indices`: elemindexek nem üres tömbje kell +@msg `item` needs `index` `item`: `index` is kell hozzá +@msg `items` is a list of {kind, item} `items`: {kind, item} elemek listája +@msg `items` is empty `items`: üres +@msg `items` must be a list of {id, points} `items`: {id, points} elemek listája kell +@msg `items` must be a list of {{id, anchors?}}, not {v} `items`: {{id, anchors?}} elemek listája kell, ez nem az: {v} +@msg `items` must be an array of {id, anchors} `items`: {id, anchors} elemek tömbje kell +@msg `items` must be an array of {{id, corners?}}, not {v} `items`: {{id, corners?}} elemek tömbje kell, ez nem az: {v} +@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida`: "none", "short", "medium", "long" vagy "stylistic" kell +@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore`: a sor első karakteréhez tartozik, nem karakterstílushoz (a text.setRangeStyle állítja be) +@msg `kind` is one of {_1} `kind`: ezek egyike: {_1} +@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind`: "round", "invertedRound" vagy "chamfer" kell, ez nem az: {v} +@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind`: `text`, `visibility`, `image` vagy `graph` kell +@msg `kind` must be a string `kind`: karakterlánc kell +@msg `kind` must be image, noImage or htmlText `kind`: image, noImage vagy htmlText kell +@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType`: "pushInFirst", "pushOutFirst" vagy "pushOutOnly" kell +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku`: "none", "hard" vagy "soft" kell +@msg `label` must be a non-empty name of at most 200 characters `label`: nem üres, legfeljebb 200 karakteres név kell +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language`: nyelvnév vagy nyelvcímke kell, például "Turkish" vagy "tr" +@msg `leader` must be a string `leader`: karakterlánc kell +@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel`: "romanBaseline" vagy "emBoxTop" kell +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading`: 0.1 és 5000 közötti érték kell, vagy null az automatikushoz +@msg `length` must be a number of at most 1000000 pt either way `length`: szám kell, bármelyik irányban legfeljebb 1000000 pt +@msg `line` must be true or false `line`: true vagy false kell +@msg `max` must be 1 to {MAX_SUGGESTIONS} `max`: 1 és {MAX_SUGGESTIONS} közötti érték kell +@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°): nem lehet nagyobb ennél: `maxAngle` ({max_angle}°) +@msg `mode` must be "handles", "straight" or "smooth" `mode`: "handles", "straight" vagy "smooth" kell +@msg `mode` must be "straight", "bezier" or "arc" `mode`: "straight", "bezier" vagy "arc" kell +@msg `mode` must be one of {_1} `mode`: ezek egyike kell: {_1} +@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi`: "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" vagy "lineEndHalf" kell +@msg `name` must be a name that is not empty `name`: nem üres név kell +@msg `name` must not be empty `name`: nem lehet üres +@msg `names` is a list of preset names `names`: előbeállítás-nevek listája +@msg `names` lists preset names `names`: előbeállítás-neveket sorol fel +@msg `names` must be a list of names `names`: nevek listája kell +@msg `names` must be a list of swatch names `names`: színmintanevek listája kell +@msg `names` must be an array of style names `names`: stílusnevek tömbje kell +@msg `new` is a design name, or true `new`: diagramminta neve vagy true +@msg `origin` must be [x, y] in points `origin`: [x, y] kell pontban megadva +@msg `path` must be a folder `path`: mappa kell +@msg `path` must be a path object `path`: útvonalobjektum kell +@msg `picks` must be an array (got {v}) `picks`: tömb kell (kapott érték: {v}) +@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks`: 1 és {MAX_PICKS} közötti számú választást kell tartalmaznia (kapott érték: {_1}) +@msg `pivot` must be a whole number from 0 to 8 `pivot`: 0 és 8 közötti egész szám kell +@msg `points` must be a list of [x, y] points `points`: [x, y] pontok listája kell +@msg `points` must be a list of point indexes `points`: pontindexek listája kell +@msg `points` must be an array of point indices `points`: pontindexek tömbje kell +@msg `points` must be point indexes `points`: pontindexek kellenek +@msg `points` must hold from 2 to 100000 points `points`: 2 és 100000 közötti számú pontot kell tartalmaznia +@msg `port` must be "in" or "out" `port`: "in" vagy "out" kell +@msg `position` is above or below `position`: above vagy below +@msg `preset` must be a string `preset`: karakterlánc kell +@msg `radius` must be 0 or more `radius`: 0 vagy nagyobb érték kell +@msg `ratio` must be from 0.1 to 0.5 `ratio`: 0.1 és 0.5 közötti érték kell +@msg `rect` must be [x, y, width, height] `rect`: [x, y, width, height] kell +@msg `rect` must be [x, y, width, height] with a positive size `rect`: [x, y, width, height] kell pozitív mérettel +@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect`: [x0, y0, x1, y1] kell (kapott érték: {r}) +@msg `relation` must be overlap, inside or outside `relation`: overlap, inside vagy outside kell +@msg `replace` must be a string `replace`: karakterlánc kell +@msg `reversed` must be true or false `reversed`: true vagy false kell +@msg `rotate` must be a finite number `rotate`: véges szám kell +@msg `rows` must be lists of numbers or nulls, not {r} `rows`: számok vagy null értékek listái kellenek, ez nem az: {r} +@msg `rule` must be nonZero or evenOdd `rule`: nonZero vagy evenOdd kell +@msg `scale` must be a number from 0.001 to 1000 `scale`: 0.001 és 1000 közötti szám kell +@msg `scale` must be a number in (0, 100] `scale`: a (0, 100] tartományba eső szám kell +@msg `scope.{key}` must be a non-empty array `scope.{key}`: nem üres tömb kell +@msg `scope` must be "document", {artboards} or {layers} `scope`: "document", {artboards} vagy {layers} kell +@msg `scope` must be "selection" or "document", not {other} `scope`: "selection" vagy "document" kell, ez nem az: {other} +@msg `script` must be a string `script`: karakterlánc kell +@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes`: adatsorindexek nem üres listája +@msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes`: `type` vagy `valueAxis` is kell hozzá +@msg `shift` must be a number of points `shift`: pontban megadott szám kell +@msg `showCenter` must be true or false `showCenter`: true vagy false kell +@msg `side` must be "left", "right", "top" or "bottom" `side`: "left", "right", "top" vagy "bottom" kell +@msg `side` places the artboard: leave out x, y, width and height `side`: ez helyezi el a rajztáblát; hagyd el az x, y, width és height értéket +@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification`: "fullJustify", "alignLeft", "alignCenter" vagy "alignRight" kell +@msg `size` is a percentage of the base text's size, 1 to 100 `size`: az alapszöveg méretének százaléka, 1 és 100 között +@msg `source` and `target` are the same object `source` és `target`: ugyanaz az objektum +@msg `source` is the only path in series group `target` `source`: az egyetlen útvonal ebben a sorozatcsoportban: `target` +@msg `source` must be `folder` or `datasets` `source`: `folder` vagy `datasets` kell +@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing`: ezzel több mint {MAX_COUNT} vonal jutna a keretre +@msg `spacing` must be a number `spacing`: szám kell +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing`: pontban megadott, 0 vagy nagyobb szám, vagy "auto" kell +@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing`: 0-nál nagyobb és legfeljebb {MAX_DISTANCE} pt érték kell +@msg `start` must be a finite number `start`: véges szám kell +@msg `starts` must be an array of anchor indices (or nulls) `starts`: horgonypont-indexek (vagy null értékek) tömbje kell +@msg `status` must be linked, embedded, missing or modified, or a list of them `status`: linked, embedded, missing vagy modified kell, vagy ezek listája +@msg `stop` must be a stop index `stop`: színátmenetpont-index kell +@msg `story` styles the whole story: give no `start` or `end` `story`: a teljes szövegfolyamot formázza; ne adj meg `start` vagy `end` értéket +@msg `story` takes the whole story: give no `start` or `end` `story`: a teljes szövegfolyamra vonatkozik; ne adj meg `start` vagy `end` értéket +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode`: körvonalakra vonatkozik (stroke: true) +@msg `style` must be roman or japanese `style`: roman vagy japanese kell +@msg `subpaths` must be two integer indices `subpaths`: két egész indexet kell megadni +@msg `symbol` must be a symbol name `symbol`: szimbólumnév kell +@msg `target` must be "object" or "contents", not {v} `target`: "object" vagy "contents" kell, ez nem az: {v} +@msg `target` must be an object of the same Objects on Path group as `member` `target`: ugyanannak az Objektumok útvonalon csoportnak egy objektuma kell, mint a `member` +@msg `tint` applies to global and spot colours `tint`: globális színekre és direktszínekre vonatkozik +@msg `tint` goes with a global or spot `swatch` `tint`: globális vagy direktszínű `swatch` értékkel együtt használható +@msg `tint` must be a number (%) `tint`: szám kell (%) +@msg `tint` needs a global or spot colour `tint`: globális szín vagy direktszín kell hozzá +@msg `to` is already threaded `to`: már láncolva van +@msg `to` is the design's new name `to`: a diagramminta új neve +@msg `to` must be [x, y] `to`: [x, y] kell +@msg `to` must be a closed path, area type or type on a path other than `from` `to`: zárt útvonal, területszöveg vagy útvonalszöveg kell, amely nem azonos ezzel: `from` +@msg `to` must be a whole number `to`: egész szám kell +@msg `to` takes the rows of one object's stack, not aligned rows `to`: egyetlen objektum halmának sorait fogadja, nem igazított sorokat +@msg `tolerance` must be from 0 to 100 pt `tolerance`: 0 és 100 pt közötti érték kell +@msg `useLab` must be true or false `useLab`: true vagy false kell +@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign`: default, top, middle, baseline vagy bottom kell +@msg `values` must be a list `values`: lista kell +@msg `values` must be an object `values`: objektum kell +@msg `values` must name hex, rgb, cmyk, lab or gray `values`: ezek közül kell megneveznie: hex, rgb, cmyk, lab vagy gray +@msg `values` names a kind twice `values`: egy fajtát kétszer nevez meg +@msg `version` must be 0 or {_1} `version`: 0 vagy {_1} kell +@msg `version` must be 0 or {newest} `version`: 0 vagy {newest} kell +@msg `warichuAlign` must be one of {_1} `warichuAlign`: ezek egyike kell: {_1} +@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines`: {_1} és {_2} közötti egész szám kell +@msg `weight` must be from 0.01 to 1000 pt `weight`: 0.01 és 1000 pt közötti érték kell +@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights`: {_1} számból álló lista kell, soronként egy (kapott érték: {v}) +@msg `xml` must be a string `xml`: karakterlánc kell +@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it `{KEY}`, {n}. bejegyzés: más behúzásokat ad a(z) {_1} objektum {start}. bájtjánál kezdődő bekezdésnek, mint egy korábbi bejegyzés +@msg `{KEY}` entry {n} must be an object `{KEY}`, {n}. bejegyzés: objektum kell +@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` `{KEY}`, {n}. bejegyzés: sem a `leftIndent`, sem a `firstLineIndent` értéket nem állítja be +@msg `{KEY}` entry {n}: `id` must be an object id `{KEY}`, {n}. bejegyzés: `id`: objektumazonosító kell +@msg `{KEY}` entry {n}: `start` must be a byte offset `{KEY}`, {n}. bejegyzés: `start`: bájteltolás kell +@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt `{KEY}`, {n}. bejegyzés: `{k}`: -{MAX_PARA} és {MAX_PARA} pt közötti szám kell +@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} `{KEY}`, {n}. bejegyzés: a(z) {_1} objektumnak nincs a(z) {start}. bájtnál kezdődő bekezdése +@msg `{KEY}` entry {n}: unknown key `{k}` `{KEY}`, {n}. bejegyzés: ismeretlen kulcs: `{k}` +@msg `{KEY}` entry {n}: {_1} is not a type object `{KEY}`, {n}. bejegyzés: nem szövegobjektum: {_1} +@msg `{KEY}` is empty `{KEY}`: üres +@msg `{KEY}` must be a list `{KEY}`: lista kell +@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}`: legfeljebb {MAX_PARAGRAPH_INDENTS} bejegyzést fogad (kapott érték: {_1}) +@msg `{KEY}` takes no `{other}` beside it `{KEY}`: mellette nem adható meg ez: `{other}` +@msg `{_1}` can't be edited or removed nem szerkeszthető és nem távolítható el: `{_1}` +@msg `{_1}` is built in and always listed beépített elem, mindig szerepel a listán: `{_1}` +@msg `{_1}` is not a font file the last text.findFontFiles search found nem a legutóbbi text.findFontFiles keresés által talált betűtípusfájl: `{_1}` +@msg `{_1}` would be overwritten by the package: choose a different folder or name a csomag felülírná ezt: `{_1}`; válassz másik mappát vagy nevet +@msg `{base}` is larger than {_1} MB nagyobb, mint {_1} MB: `{base}` +@msg `{base}` is not in the DCS file's folder nincs a DCS-fájl mappájában: `{base}` +@msg `{file}` has no art to place nincs benne elhelyezhető grafika: `{file}` +@msg `{file}` is over {_1} MB nagyobb, mint {_1} MB: `{file}` +@msg `{f}` belongs to a VectorCraft that is running egy futó VectorCrafthoz tartozik: `{f}` +@msg `{f}` is the copy of a document open now egy most megnyitott dokumentum másolata: `{f}` +@msg `{id}` is no writable format (see document.formats) nem írható formátum: `{id}` (lásd: document.formats) +@msg `{id}` isn't a loaded library nem betöltött könyvtár: `{id}` +@msg `{key}` has no colours `{key}`: nincsenek benne színek +@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}`: több mint {MAX_EDIT_WORDS} szót tartalmaz +@msg `{key}` is a design name `{key}`: diagramminta neve +@msg `{key}` is a design name or a list of them `{key}`: diagramminta neve vagy ilyen nevek listája +@msg `{key}` is longer than {MAX_NAME} characters `{key}`: hosszabb {MAX_NAME} karakternél +@msg `{key}` is out of range `{key}`: tartományon kívül esik +@msg `{key}` must be 0 or more `{key}`: 0 vagy nagyobb érték kell +@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}`: [min, desired, max] vagy {{min?, desired?, max?}} kell, százalékban +@msg `{key}` must be [x, y] numbers `{key}`: [x, y] számpár kell +@msg `{key}` must be a list of words `{key}`: szavak listája kell +@msg `{key}` must be a list, not {v} `{key}`: lista kell, ez nem az: {v} +@msg `{key}` must be a non-negative integer `{key}`: nemnegatív egész szám kell +@msg `{key}` must be a number or null, not {v} `{key}`: szám vagy null kell, ez nem az: {v} +@msg `{key}` must be a number {_1}..{_2} `{key}`: szám kell, {_1}..{_2} +@msg `{key}` must be a whole number of 0 or more `{key}`: 0 vagy nagyobb egész szám kell +@msg `{key}` must be a whole number of at least 0, not {n} `{key}`: legalább 0 értékű egész szám kell, ez nem az: {n} +@msg `{key}` must be an appearance item index (paint order) or null `{key}`: megjelenési elem indexe (festési sorrend) vagy null kell +@msg `{key}` must be an array of asset ids (see assets.list) `{key}`: elemazonosítók tömbje kell (lásd: assets.list) +@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}`: 0 és 180 fok közötti érték kell, ez nem az: {a} +@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}`: 0 és {MAX_DISTANCE} pt közötti érték kell +@msg `{key}` must be from {_1} to {shift_max} `{key}`: {_1} és {shift_max} közötti érték kell +@msg `{key}` must be from {lo} to {hi}, not {v} `{key}`: {lo} és {hi} közötti érték kell, ez nem az: {v} +@msg `{key}` must be one of auto, always, never `{key}`: ezek egyike kell: auto, always, never +@msg `{key}` must be one of miter, round, bevel `{key}`: ezek egyike kell: miter, round, bevel +@msg `{key}` must be one word of at most {_1} characters `{key}`: egyetlen, legfeljebb {_1} karakteres szó kell +@msg `{key}` must hold single words of at most {_1} characters `{key}`: különálló, legfeljebb {_1} karakteres szavakat kell tartalmaznia +@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}`: egy Objektumok útvonalon csoport útvonalához csatolt objektumot kell megneveznie +@msg `{key}` names an object that is hidden or locked `{key}`: rejtett vagy zárolt objektumot nevez meg +@msg `{key}`: not a colour: {v} `{key}`: nem szín: {v} +@msg `{k}.family` is required `{k}.family`: kötelező +@msg `{k}` is a number 0..1 `{k}`: szám, 0..1 +@msg `{k}` is true or false `{k}`: true vagy false +@msg `{k}` isn't an export setting: {_1} `{k}`: nem exportálási beállítás: {_1} +@msg `{k}` must be a byte offset `{k}`: bájteltolás kell +@msg `{k}` must be a number `{k}`: szám kell +@msg `{k}` must be a number of points `{k}`: pontban megadott szám kell +@msg `{k}` must be a string `{k}`: karakterlánc kell +@msg `{k}` must be a whole number `{k}`: egész szám kell +@msg `{k}` must be from {lo} to {hi} for {key} `{k}`: {lo} és {hi} közötti érték kell ehhez: {key} +@msg `{k}` must be {what}, not {v} `{k}`: ez kell: {what}; ez nem az: {v} +@msg `{l}` isn't a global or spot colour nem globális szín és nem direktszín: `{l}` +@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText nem bitkép: `{m}`; használd a clipboard.importSvg, az importPdf vagy az importText parancsot +@msg `{name}` can't be deleted nem törölhető: `{name}` +@msg `{name}` can't be duplicated nem kettőzhető: `{name}` +@msg `{name}` can't be edited nem szerkeszthető: `{name}` +@msg `{name}` can't be edited this way így nem szerkeszthető: `{name}` +@msg `{name}` can't be renamed nem nevezhető át: `{name}` +@msg `{name}` can't name a folder nem nevezhet meg mappát: `{name}` +@msg `{name}` has no art to place nincs benne elhelyezhető grafika: `{name}` +@msg `{name}` has no text to place nincs benne elhelyezhető szöveg: `{name}` +@msg `{name}` in the Word document is not UTF-8 or UTF-16 text a Word-dokumentumban lévő `{name}` nem UTF-8 vagy UTF-16 szöveg +@msg `{name}` in the Word document is not well-formed XML: {e} a Word-dokumentumban lévő `{name}` nem jól formált XML: {e} +@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep a Word-dokumentumban lévő `{name}` elemei {MAX_NESTING} szintnél mélyebben vannak egymásba ágyazva +@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` beépített előbeállítás, csak olvasható: mentsd más néven +@msg `{name}` is a built-in preset: save under another name `{name}` beépített előbeállítás: mentsd más néven +@msg `{name}` is a built-in profile and stays `{name}` beépített profil, megmarad +@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` külön fájl, amely csak a DCS-fájl elérési útja mellett található meg +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` ennek a tónusa: `{base}`; ezt szerkeszd: `{base}` (egy tónusszínminta csak új nevet kaphat) +@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` munkaszíntér: előbb válassz másikat az edit.colorSettings paranccsal +@msg `{name}` is built in and stays `{name}` beépített elem, megmarad +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` csak a saját fajtájának megfelelő festést fogad (szín, színátmenet vagy mintázat) +@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} +@msg `{n}` can't be deleted nem törölhető: `{n}` +@msg `{n}` can't be merged nem egyesíthető: `{n}` +@msg `{n}` can't be moved nem helyezhető át: `{n}` +@msg `{n}` is not a colour swatch nem színes színminta: `{n}` +@msg `{n}` isn't a solid colour nem egyszínű szín: `{n}` +@msg `{n}`: colour groups hold solid colours only `{n}`: a színcsoportok csak egyszínű színeket tartalmazhatnak +@msg `{other}` doesn't trace an image nem vektorizál képet: `{other}` +@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: a lépés hivatkozásai több mint {_1} MB értéket másolnak be +@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" nem tengelycímke: `{tag}`; a címke négy karakterből áll, például "wght" +@msg `{to}` is empty or already used üres vagy már használatban van: `{to}` +@msg `{u}` is no Unicode character nem Unicode-karakter: `{u}` +@msg `{what}` is longer than {MAX_TEXT} bytes `{what}`: hosszabb {MAX_TEXT} bájtnál +@msg `{what}` must be text `{what}`: szöveg kell +@msg `{word}` isn't in the custom dictionary nincs az egyéni szótárban: `{word}` +@msg a CMap has too many mappings egy CMap túl sok leképezést tartalmaz +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard egy DXF-rajznak egy origója van: add meg a useArtboards: true értéket, ha rajztáblánként egy rajz kell +@msg a PDF presets file with over {_1} presets olyan PDF-előbeállításfájl, amelyben több mint {_1} előbeállítás van +@msg a \\u escape needs four hexadecimal digits a \\u escape-sorozathoz négy hexadecimális számjegy kell +@msg a batch preview cannot switch documents a kötegelt előnézet nem válthat dokumentumot +@msg a brush named `{nn}` exists már van ilyen nevű ecset: `{nn}` +@msg a comma in an XPath expression must separate function arguments XPath-kifejezésben a vessző csak függvényargumentumokat választhat el +@msg a container of the listed objects is gone a felsorolt objektumok egyik tárolója már nincs meg +@msg a content stream can't be read egy tartalomfolyam nem olvasható +@msg a content stream isn't a stream egy tartalomfolyam nem adatfolyam +@msg a design holds at most {_1} objects egy diagramminta legfeljebb {_1} objektumot tartalmazhat +@msg a design needs a name a diagrammintának név kell +@msg a dictionary grew too large egy szótár túl nagyra nőtt +@msg a document holds at most {_1} SVG filters egy dokumentum legfeljebb {_1} SVG-szűrőt tartalmazhat +@msg a document keeps at most {_1} graph designs egy dokumentum legfeljebb {_1} diagrammintát tárol +@msg a document keeps at most {_1} saved selections egy dokumentum legfeljebb {_1} mentett kijelölést tárol +@msg a document keeps at most {_1} views egy dokumentum legfeljebb {_1} nézetet tárol +@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files egy dokumentum legfeljebb {MAX_SVG_SCRIPTS} JavaScript-fájlt csatolhat +@msg a document with unsaved changes has no recovery copy a mentetlen módosításokat tartalmazó dokumentumnak nincs helyreállítási másolata +@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes egy feConvolveMatrix szűrő rendje {x}×{y}, ami túlcsordítja az usvg kernelméret-szorzását +@msg a font's data is too large egy betűtípus adatai túl nagyok +@msg a font's subroutines are too large egy betűtípus szubrutinjai túl nagyok +@msg a function declaration without a name név nélküli függvénydeklaráció +@msg a gradient preset is already called {n} már van ilyen nevű színátmenet-előbeállítás: {n} +@msg a graph has at most {_1} series, so it transposes with at most that many categories egy diagramnak legfeljebb {_1} adatsora lehet, ezért legfeljebb ennyi kategóriával cserélhető fel a sor és az oszlop +@msg a host value has too many entries egy gazdaérték túl sok elemet tartalmaz +@msg a host value is nested too deep egy gazdaérték túl mélyen van egymásba ágyazva +@msg a layer can't be merged into its own sublayer egy réteg nem egyesíthető a saját alrétegébe +@msg a layer's form box isn't an array egy réteg formaobjektumának kerete nem tömb +@msg a layer's form can't be read egy réteg formaobjektuma nem olvasható +@msg a layer's form has no box egy réteg formaobjektumának nincs kerete +@msg a layer's form has no dictionary egy réteg formaobjektumának nincs szótára +@msg a legend draws at most {MAX_CHIPS} swatches a jelmagyarázat legfeljebb {MAX_CHIPS} színmintát rajzol ki +@msg a name names one asset: give multiple: false, or one object egy név egy elemet nevez meg: adj meg multiple: false értéket, vagy egyetlen objektumot +@msg a namespace declaration uses a reserved prefix or URI egy névtér-deklaráció foglalt előtagot vagy URI-t használ +@msg a namespace qualifier is undefined egy névtérminősítő nincs definiálva +@msg a namespace with an empty URI cannot have a prefix üres URI-jú névtérnek nem lehet előtagja +@msg a number has no digits egy számban nincsenek számjegyek +@msg a number's exponent has no digits egy szám kitevőjében nincsenek számjegyek +@msg a number's fraction has no digits egy szám törtrészében nincsenek számjegyek +@msg a page can't be read egy oldal nem olvasható +@msg a page object is used by something else egy oldalobjektumot valami más is használ +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) az oldalbélyegkép RGB-képpontokból álljon, oldalanként 1 és {THUMBNAIL_SIZE} között (kapott méret: {w} × {h}) +@msg a pattern layer doesn't end what it starts egy mintázatréteg nem zárja le, amit megkezd +@msg a pattern layer's colors hold something else egy mintázatréteg színei mást tartalmaznak +@msg a pattern named `{n}` exists már van ilyen nevű mintázat: `{n}` +@msg a plug-in with id {_1} is already installed már telepítve van ilyen azonosítójú bővítmény: {_1} +@msg a preset named `{n}` exists már van ilyen nevű előbeállítás: `{n}` +@msg a print presets file with over {_1} presets olyan nyomtatási előbeállításfájl, amelyben több mint {_1} előbeállítás van +@msg a profile is already called {n} már van ilyen nevű profil: {n} +@msg a property descriptor cannot have both a value or writable and a get or set egy tulajdonságleíróban nem lehet egyszerre value vagy writable és get vagy set +@msg a property descriptor must be an object a tulajdonságleíró objektum kell legyen +@msg a property's {name} must be a function or undefined egy tulajdonság {name} értéke függvény vagy undefined lehet +@msg a row can't go into itself egy sor nem kerülhet önmagába +@msg a series goes on the left or the right value axis egy adatsor a bal vagy a jobb értéktengelyre kerül +@msg a start must be an anchor index ≥ 0 or null a kezdőpont ≥ 0 horgonypontindex vagy null lehet +@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied egy benne lévő adatfolyam kibontva 256 MB-nál nagyobb lenne, vagy a dekódolási paraméterei nem alkalmazhatók +@msg a string holds a control character egy karakterlánc vezérlőkaraktert tartalmaz +@msg a string holds an escape sequence JSON does not have egy karakterlánc olyan escape-sorozatot tartalmaz, amely a JSON-ban nem létezik +@msg a string is not closed egy karakterlánc nincs lezárva +@msg a style named `{name}` already exists már van ilyen nevű stílus: `{name}` +@msg a style named `{to}` already exists már van ilyen nevű stílus: `{to}` +@msg a swatch needs a colour, gradient or pattern a színmintához szín, színátmenet vagy mintázat kell +@msg a symbol named `{n}` already exists már van ilyen nevű szimbólum: `{n}` +@msg a theme has 1 to {THEME_SIZE} colours egy témában 1–{THEME_SIZE} szín lehet +@msg a theme is already called {name} (replace: "{name}" overwrites it) már van ilyen nevű téma: {name} (a replace: "{name}" felülírja) +@msg a toolbar name has at most {MAX_NAME} characters az eszköztár neve legfeljebb {MAX_NAME} karakter lehet +@msg a view named `{_1}` exists már van ilyen nevű nézet: `{_1}` +@msg a view named `{name}` exists már van ilyen nevű nézet: `{name}` +@msg a workspace keeps at most {MAX} toolbars egy munkaterület legfeljebb {MAX} eszköztárat tárol +@msg a {_1} series can't go in a {_2} graph {_1} típusú adatsor nem kerülhet {_2} típusú diagramba +@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF-kompatibilis, ez az alapértelmezés) vagy "7" (régi .ai, 7-es verzió) +@msg already editing an opacity mask már folyik egy átlátszósági maszk szerkesztése +@msg already editing another object's opacity mask már folyik egy másik objektum átlátszósági maszkjának szerkesztése +@msg already in pattern editing mode már mintázatszerkesztési módban vagy +@msg an SVG filter is named by its id: give `xml` with `name` az SVG-szűrőt az azonosítója nevezi meg: a `name` mellé add meg az `xml` értéket +@msg an XML element has two attributes with the same name egy XML-elemnek két azonos nevű attribútuma van +@msg an XML value cannot be inserted into itself or its descendants egy XML-érték nem szúrható be önmagába vagy a leszármazottaiba +@msg an XML value's children cannot be assigned by position egy XML-érték gyermekei nem adhatók meg pozíció szerint +@msg an XML value's children cannot be deleted by position egy XML-érték gyermekei nem törölhetők pozíció szerint +@msg an XMLList converts to XML only when it has one item egy XMLList csak akkor alakítható XML-lé, ha egy eleme van +@msg an XPath node test is missing hiányzik egy XPath-csomópontteszt +@msg an XPath operand is missing hiányzik egy XPath-operandus +@msg an XPath operator is missing hiányzik egy XPath-operátor +@msg an XPath predicate or step cannot follow here itt nem következhet XPath-predikátum vagy -lépés +@msg an XPath predicate or step follows a value that is not a node-set egy XPath-predikátum vagy -lépés olyan érték után áll, amely nem csomóponthalmaz +@msg an XPath step is missing hiányzik egy XPath-lépés +@msg an artboard is listed twice egy rajztábla kétszer szerepel +@msg an empty artboard has no width or height to scale to üres rajztáblának nincs szélessége vagy magassága, amelyhez méretezni lehetne +@msg an image couldn't be decoded and is missing from the picture egy képet nem sikerült dekódolni, ezért hiányzik az ábráról +@msg an image is too large for DXF and was left out egy kép túl nagy a DXF-hez, ezért kimaradt +@msg an image of {_1} MB, at most {_2} MB a kép {_1} MB, legfeljebb {_2} MB lehet +@msg an image of {declared_w} × {declared_h} pixels is too large to draw egy {declared_w} × {declared_h} képpontos kép túl nagy a kirajzoláshoz +@msg an image of {width} × {height} pixels is too large to draw egy {width} × {height} képpontos kép túl nagy a kirajzoláshoz +@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) egy {w} × {h} képpontos kép túl nagy az átalakításhoz (legfeljebb {_1} képpont) +@msg an index must be a whole number az index egész szám kell legyen +@msg an inline graphic has no glyphs egy szövegközi grafikának nincsenek glifái +@msg an invisible text form can't be read egy láthatatlan szöveg formaobjektuma nem olvasható +@msg an object is missing hiányzik egy objektum +@msg an object's header is missing hiányzik egy objektum fejléce +@msg an overprinting form can't be read egy felülnyomó formaobjektum nem olvasható +@msg an unread object has no readable bounds egy be nem olvasott objektumnak nincsenek olvasható határai +@msg an unread object holds a frame of threaded type egy be nem olvasott objektum láncolt szöveg keretét tartalmazza +@msg an unread object is missing from its layer egy be nem olvasott objektum hiányzik a rétegéből +@msg anchor `smooth` must be true or false horgonypont, `smooth`: true vagy false kell +@msg anchor `{key}` must be [x, y] horgonypont, `{key}`: [x, y] kell +@msg anchor `{key}` must be a number horgonypont, `{key}`: szám kell +@msg angle and roundness must be finite angle és roundness: véges érték kell +@msg angles must be a list as long as pins (degrees or null) angles: a pins listával azonos hosszúságú lista kell (fok vagy null) +@msg appearance must be flat|center|edge appearance: flat|center|edge kell +@msg apply is "fill" or "stroke", not `{other}` apply: "fill" vagy "stroke", ez nem az: `{other}` +@msg apply was called on a value that is not a function az apply hívása olyan értéken történt, amely nem függvény +@msg area must be a closed polygon of 3 to 100000 [x, y] points area: 3 és 100000 közötti számú [x, y] pontból álló zárt sokszög kell +@msg area type in a thread cannot use Dynamic Text: {frames} a láncolt szövegben lévő területszöveg nem használhat dinamikus szöveget: {frames} +@msg area type needs a path that encloses an area a területszöveghez területet körülzáró útvonal kell +@msg area type {_1} is named twice a területszöveg kétszer szerepel: {_1} +@msg array literal too long a tömbliterál túl hosszú +@msg arrowAlign must be extend|tip, got {o} arrowAlign: extend|tip kell, kapott érték: {o} +@msg arrowScale must be [start, end] arrowScale: [start, end] kell +@msg artboard is a 0-based index artboard: 0-tól kezdődő index +@msg artboard must be a 0-based index artboard: 0-tól kezdődő index kell +@msg artboard {i} doesn't print ez a rajztábla nem kerül nyomtatásra: {i} +@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout: {layout?, columns?, spacing?, rightToLeft?} kell +@msg artboardLayout.columns must be 1 or more artboardLayout.columns: 1 vagy nagyobb érték kell +@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout: gridByRow, gridByColumn, row vagy column kell +@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft: true vagy false kell +@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing: 0–{MAX_SPACING} pt kell +@msg artboards must be a non-empty list artboards: nem üres lista kell +@msg artboards must be a number artboards: szám kell +@msg asset `{_1}` has no art to export ebben az elemben nincs exportálható grafika: `{_1}` +@msg assignment to a constant binding értékadás konstans kötésnek +@msg at least one row that isn't excluded needs a weight above 0 legalább egy ki nem zárt sornak 0-nál nagyobb súly kell +@msg at most {MAX_KEYWORDS} keywords legfeljebb {MAX_KEYWORDS} kulcsszó +@msg at most {MAX_MATCHES} matches are kept legfeljebb {MAX_MATCHES} találat marad meg +@msg at most {MAX_POINTS} points legfeljebb {MAX_POINTS} pont +@msg at most {MAX_QUEUE} files legfeljebb {MAX_QUEUE} fájl +@msg at must be [x, y] at: [x, y] kell +@msg at/rect lie outside the canvas at/rect: a vásznon kívül esik +@msg attribute must be size|leading|tracking|kerning|baselineShift attribute: size|leading|tracking|kerning|baselineShift kell +@msg attrs must be an object attrs: objektum kell +@msg automation path rejected: the folder's name isn't valid text az automatizálás útvonala elutasítva: a mappa neve nem érvényes szöveg +@msg automation path rejected: {_1} isn't valid text az automatizálás útvonala elutasítva: nem érvényes szöveg: {_1} +@msg automation path rejected: {why}: {path} az automatizálás útvonala elutasítva: {why}: {path} +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis: "vertical", "horizontal" vagy fokban megadott szög kell, ez nem az: `{given}` +@msg backgroundContents must be transparent or white backgroundContents: transparent vagy white kell +@msg bad AES key hibás AES-kulcs +@msg bad `runs`: {e} hibás `runs`: {e} +@msg bad arrowScale hibás arrowScale +@msg bad base64 hibás base64 +@msg bad color {c} hibás szín: {c} +@msg bad colour hibás szín +@msg bad colour in `colors` hibás szín itt: `colors` +@msg bad colour {v} hibás szín: {v} +@msg bad colour {v} in groupColors hibás szín a groupColors értékben: {v} +@msg bad cross-reference entry hibás kereszthivatkozási bejegyzés +@msg bad cross-reference table hibás kereszthivatkozási tábla +@msg bad current colour {v} hibás jelenlegi szín: {v} +@msg bad dataBase64 hibás dataBase64 +@msg bad edit hibás szerkesztés +@msg bad fill {v} hibás kitöltés: {v} +@msg bad fit: {e} hibás illesztés: {e} +@msg bad model {other} (keep, new or document) hibás modell: {other} (keep, new vagy document) +@msg bad new colour {v} hibás új szín: {v} +@msg bad object hibás objektum +@msg bad object number hibás objektumszám +@msg bad offset hibás eltolás +@msg bad path data: {e} hibás útvonaladatok: {e} +@msg bad point {v}: expected [x, y] hibás pont: {v}; [x, y] kell +@msg bad point {v}: expected [x, y] or [x, y, pressure] hibás pont: {v}; [x, y] vagy [x, y, pressure] kell +@msg bad reference hibás hivatkozás +@msg bad trailer size hibás trailerméret +@msg bad {k} colour hibás szín: {k} +@msg batches cannot nest kötegek nem ágyazhatók egymásba +@msg bind something to a variable first előbb köss valamit egy változóhoz +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed: 0–{MAX_BLEED} pt kell: egy szám, [top, bottom, left, right] vagy {{top, bottom, left, right}} +@msg blend must be a mode name blend: keverési mód neve kell +@msg both sides of an XPath | must select nodes az XPath | mindkét oldalának csomópontokat kell kijelölnie +@msg brush must be a name or null brush: név vagy null kell +@msg by must be a number by: szám kell +@msg can't add page thumbnails: {why} nem adhatók hozzá oldalbélyegképek: {why} +@msg can't add the editing data for other apps: {why} nem adhatók hozzá a más alkalmazásoknak szánt szerkesztési adatok: {why} +@msg can't edit the written PDF: {why} a kiírt PDF nem szerkeszthető: {why} +@msg can't encrypt the PDF: {why} a PDF nem titkosítható: {why} +@msg can't linearise the PDF: {why} a PDF nem linearizálható: {why} +@msg can't open `{_1}`: {_2} nem nyitható meg: `{_1}`: {_2} +@msg can't open `{_1}`: {e} nem nyitható meg: `{_1}`: {e} +@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) nem nyitható meg: `{name}`; ezt a formátumot a VectorCraft nem olvassa (lásd: document.formats) +@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) nem helyezhető el: `{name}`; ezt a formátumot a VectorCraft nem olvassa (lásd: document.formats) +@msg can't read back the written PDF: {e} a kiírt PDF nem olvasható vissza: {e} +@msg can't read this Word document: {why} ez a Word-dokumentum nem olvasható: {why} +@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp .{e} képek nem írhatók: használd a .png, .jpg, .gif, .webp, .tif vagy .bmp formátumot +@msg can't write PDF layers, overprint or invisible text: {why} nem írhatók ki a PDF-rétegek, a felülnyomás vagy a láthatatlan szöveg: {why} +@msg can't write the file's copyright: {why} nem írható ki a fájl szerzői jogi adata: {why} +@msg can't write the file's modification date: {why} nem írható ki a fájl módosítási dátuma: {why} +@msg can't write the pages' user units: {why} nem írhatók ki az oldalak felhasználói egységei: {why} +@msg canceled megszakítva +@msg cannot convert the object to a primitive value az objektum nem alakítható primitív értékké +@msg cannot convert undefined or null to an object undefined vagy null nem alakítható objektummá +@msg cannot distort a zero-size or non-finite bounding box nulla méretű vagy nem véges határolókeret nem torzítható +@msg cannot move an object into itself egy objektum nem helyezhető át önmagába +@msg case must be upper|lower|title|sentence case: upper|lower|title|sentence kell +@msg category `{c}`: one of {_1} kategória `{c}`: ezek egyike: {_1} +@msg center must be [x, y] center: [x, y] kell +@msg character dimensions cannot be represented with the source text transform a karakterméretek nem ábrázolhatók a forrásszöveg transzformációjával +@msg character dimensions cannot be represented with the target text transform a karakterméretek nem ábrázolhatók a célszöveg transzformációjával +@msg choose at least one artboard válassz legalább egy rajztáblát +@msg choose at least one status válassz legalább egy állapotot +@msg clip must be true or false clip: true vagy false kell +@msg clippingMask must be true or false clippingMask: true vagy false kell +@msg closed must be a boolean closed: logikai érték kell +@msg collapsed must be true or false collapsed: true vagy false kell +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: nincs ilyen nevű RGB- vagy CMYK-profil: „{profile}” +@msg colorMode must be rgb or cmyk, not `{m}` colorMode: rgb vagy cmyk kell, ez nem az: `{m}` +@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel: {own} (a dokumentum módja), grayscale vagy bitmap kell +@msg colour groups can't go into colour groups színcsoport nem kerülhet színcsoportba +@msg colour, mode and spot apply to solid-colour swatches only colour, mode és spot: csak egyszínű színmintákra vonatkozik +@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read {column}. oszlop: `{cell}` a diagramadat-fájlok `%` oszlopa, ezek beolvasása nem történik meg +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable {column}. oszlop: `{cell}` ezt nevezi meg: `{name}`, amely {_1} típusú változó +@msg column {column}: `{cell}` names no variable {column}. oszlop: `{cell}` nem nevez meg változót +@msg column {column}: the variable name is longer than {MAX_NAME} characters {column}. oszlop: a változó neve hosszabb {MAX_NAME} karakternél +@msg column: true floats the whole column and takes no onto, above or below column: true az egész oszlopot lebegővé teszi, és nem adható meg mellette onto, above vagy below +@msg command must be a shape.* command command: shape.* parancs kell +@msg commands[{i}]: missing `command` commands[{i}]: hiányzik: `command` +@msg composer must be "singleLine" or "everyLine" composer: "singleLine" vagy "everyLine" kell +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes összetett alakzatba útvonalak, összetett útvonalak, csoportok, összetett alakzatok, szöveg, összemosások és burkolók kerülhetnek +@msg compound {_1} must contain exactly one open path to join az összetett objektumnak ({_1}) pontosan egy összekapcsolható nyitott útvonalat kell tartalmaznia +@msg connect needs a path other than the one extended connect: a meghosszabbított útvonaltól különböző útvonal kell +@msg connect needs id connect: id is kell hozzá +@msg connect path has no open end connect: az útvonalnak nincs nyitott vége +@msg continuation must be an array of path chunks continuation: útvonaldarabok tömbje kell +@msg continuation requires points continuation: points is kell hozzá +@msg copy needs path: the file to copy into the document's folder copy: path is kell hozzá: a dokumentum mappájába másolandó fájl +@msg copy takes images only: relink placed documents where their files are copy: csak képeket fogad; az elhelyezett dokumentumokat ott csatold újra, ahol a fájljaik vannak +@msg copying a file isn't available on the web a webes változatban nem lehet fájlt másolni +@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus: unknown, copyrighted vagy publicDomain kell +@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl: URL kell (szóköz nélkül, legfeljebb 2048 karakter) +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners: 4 darab [x, y] pont kell (TL, TR, BR, BL) +@msg counterPer `{c}`: item or file counterPer `{c}`: item vagy file +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: csak a PDF-oldalaknak van art, trim, bleed és media kerete; használd a "crop" vagy a "bounding" értéket +@msg crop rectangle doesn't overlap the image a vágási téglalap nem fedi a képet +@msg cross-reference and object streams aren't supported a kereszthivatkozási és objektumfolyamok nem támogatottak +@msg damaged compressed data: {e} sérült tömörített adatok: {e} +@msg dash lengths must be numbers, got {v} dash: a hosszak számok legyenek, kapott érték: {v} +@msg dash must be a list of lengths or null, got {v} dash: hosszak listája vagy null kell, kapott érték: {v} +@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) data kulcs `{k}`: betűk, számjegyek, -, _ és . (nem lehet name vagy vc-…) +@msg data must be an object of key: value, not {v} data: kulcs: érték párokból álló objektum kell, ez nem az: {v} +@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare a(z) `{name}` adatkészlet értéket tartalmaz ehhez: `{var}`, de ezt a könyvtár nem deklarálja +@msg data set `{name}` holds two values for `{var}` a(z) `{name}` adatkészlet két értéket tartalmaz ehhez: `{var}` +@msg data set `{name}` is declared twice az adatkészlet kétszer van deklarálva: `{name}` +@msg data takes at most {MAX_DATA} keys at once data: egyszerre legfeljebb {MAX_DATA} kulcsot fogad +@msg dataset `{name}` already exists már van ilyen adatkészlet: `{name}` +@msg dataset `{to}` already exists már van ilyen adatkészlet: `{to}` +@msg date `{d}`: YYYY-MM-DD dátum `{d}`: YYYY-MM-DD +@msg decoded data is too large a dekódolt adatok túl nagyok +@msg define a variable first előbb definiálj egy változót +@msg detect must be true or false detect: true vagy false kell +@msg direct-select anchor points of a path jelöld ki közvetlen kijelöléssel egy útvonal horgonypontjait +@msg distance must be > 0 distance: > 0 érték kell +@msg dx and dy give the arrow's direction dx és dy: a nyíl irányát adják meg +@msg dx and dy must be numbers dx és dy: számok kellenek +@msg dxf fitTo must be [width, height], positive dxf fitTo: [width, height] kell, pozitív értékekkel +@msg dxf must be an object of DXF import options dxf: a DXF-importálási beállítások objektuma kell +@msg dxf scale must be a positive number up to {_1}, not {s} dxf scale: pozitív szám kell, legfeljebb {_1}; ez nem az: {s} +@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf unit `{u}`: mértékegység kell, például mm, cm, in vagy pt +@msg dxf: {e} dxf: {e} +@msg each format is an object {format, scale?, suffix?} minden formátum egy {format, scale?, suffix?} objektum +@msg each item needs an id minden elemnek azonosító kell +@msg each match needs `kind` minden találathoz `kind` kell +@msg each match needs a `name` minden találathoz `name` kell +@msg each of `fonts` is {family, style?}; 1 to 1000 of them a `fonts` minden eleme {family, style?}; 1–1000 darab lehet +@msg each of `items` is {kind, item} az `items` minden eleme {kind, item} +@msg each of `items` is {{id, corners?}} with an object id, not {it} az `items` minden eleme {{id, corners?}} objektumazonosítóval, ez nem az: {it} +@msg each path chunk must contain 1 to 4096 points minden útvonaldarabnak 1–4096 pontot kell tartalmaznia +@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) minden választás {{at: [x, y]}} vagy {{rect: [x0, y0, x1, y1]}} (kapott érték: {v}) +@msg each row needs `from` minden sorhoz `from` kell +@msg each stop needs a position minden színátmenetpontnak pozíció kell +@msg each subpath needs an anchors array minden részútvonalhoz anchors tömb kell +@msg each weight must be a number of 0 or more (got {v}) minden súly 0 vagy nagyobb szám legyen (kapott érték: {v}) +@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType: easeIn, easeOut, easeInOut vagy easeOutIn kell +@msg editingData must be true or false editingData: true vagy false kell +@msg editingData must be true, false or "always" editingData: true, false vagy "always" kell +@msg edits overlap a szerkesztések átfedik egymást +@msg effects of objects whose appearances differ can't be moved together eltérő megjelenésű objektumok effektusai nem helyezhetők át együtt +@msg empty header in column {_1} üres fejléc ebben az oszlopban: {_1} +@msg encoding `{_1}`: utf8 or utf16 kódolás `{_1}`: utf8 vagy utf16 +@msg ends must be two of "first" and "last" ends: két érték kell ezekből: "first" és "last" +@msg every numbered name for the result is taken az eredmény minden számozott neve foglalt +@msg expanded SVG styles exceed the import size limit a kibontott SVG-stílusok túllépik az importálási méretkorlátot +@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes az exportálási előbeállítások fájlja nagyobb, mint {MAX_PRESET_FILE_BYTES} bájt +@msg exportText must be editable or appearance exportText: editable vagy appearance kell +@msg exportType must be movieClip or graphic, not `{s}` exportType: movieClip vagy graphic kell, ez nem az: `{s}` +@msg extend needs id extend: id is kell hozzá +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges: legfeljebb {MAX_FEATURE_CHANGES} címkét sorolhat fel +@msg featureChanges must be a list of tags featureChanges: címkék listája kell +@msg features must be a list of tags features: címkék listája kell +@msg fidelity must be 0..100 fidelity: 0..100 kell +@msg files must be {name, dataBase64} files: {name, dataBase64} kell +@msg finally table {t} out of range a finally tábla tartományon kívül esik: {t} +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit: none|autoHeight|shrinkText|block kell, kapott érték: {id} +@msg flags cannot be given with a RegExp object RegExp objektum mellett nem adhatók meg jelzők +@msg flattener: {e} lapító: {e} +@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low vagy egy mentett előbeállítás (lásd: flattener.presets.list) +@msg flipProfile must be along|across, got {o} flipProfile: along|across kell, kapott érték: {o} +@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none, rows, format, item vagy itemFormat +@msg folders deeper than {MAX_BATCH_DEPTH} levels {MAX_BATCH_DEPTH} szintnél mélyebb mappák +@msg folders deeper than {MAX_DEPTH} levels {MAX_DEPTH} szintnél mélyebb mappák +@msg font family `{family}` is not available a betűcsalád nem érhető el: `{family}` +@msg font family `{tf}` is not available a betűcsalád nem érhető el: `{tf}` +@msg format `{f}`: pdf or postscript format `{f}`: pdf vagy postscript +@msg format `{f}`: text or json format `{f}`: text vagy json +@msg format is `{format}` a formátum: `{format}` +@msg formats must be an array of rows, not {v} formats: sorok tömbje kell, ez nem az: {v} +@msg frequency must be between 1 and 300 lines per inch frequency: 1 és 300 vonal/hüvelyk közötti érték kell +@msg from and to must be finite from és to: véges érték kell +@msg from must be [x0, y0, x1, y1] from: [x0, y0, x1, y1] kell +@msg function declaration index out of range a függvénydeklaráció indexe tartományon kívül esik +@msg give `before` or `after`, not both a `before` vagy az `after` értéket add meg, ne mindkettőt +@msg give `char`, `text`, `code` or `unicode` add meg a `char`, a `text`, a `code` vagy a `unicode` értéket +@msg give `colors`, or `color` and `rule` add meg a `colors` értéket, vagy a `color` és a `rule` értéket +@msg give `count` or `spacing`, not both a `count` vagy a `spacing` értéket add meg, ne mindkettőt +@msg give `event`, such as onclick add meg az `event` értéket, például onclick +@msg give `file`, the JavaScript file's path or URL add meg a `file` értéket: a JavaScript-fájl elérési útját vagy URL-jét +@msg give `fill` and/or `stroke` add meg a `fill` és/vagy a `stroke` értéket +@msg give `fill`, `stroke` or both add meg a `fill` vagy a `stroke` értéket, vagy mindkettőt +@msg give `folder` add meg a `folder` értéket +@msg give `fonts`, or open a document add meg a `fonts` értéket, vagy nyiss meg egy dokumentumot +@msg give `from` or `indices`, not both a `from` vagy az `indices` értéket add meg, ne mindkettőt +@msg give `id` (no text is being edited) add meg az `id` értéket (nincs szerkesztés alatt álló szöveg) +@msg give `id` and `anchors`, or `items`, not both az `id` és az `anchors` értéket, vagy az `items` értéket add meg, ne mindkettőt +@msg give `id` or `guide`, not both az `id` vagy a `guide` értéket add meg, ne mindkettőt +@msg give `length` or `to` add meg a `length` vagy a `to` értéket +@msg give `matches`: [{kind, name, target}] add meg a `matches` értéket: [{kind, name, target}] +@msg give `name` (or edit a pattern) add meg a `name` értéket (vagy szerkessz egy mintázatot) +@msg give `name` or `names` add meg a `name` vagy a `names` értéket +@msg give `name`, an SVG filter's name add meg a `name` értéket: egy SVG-szűrő nevét +@msg give `path` (a .wasm file) or `dataBase64` (the module) add meg a `path` (egy .wasm fájl) vagy a `dataBase64` (a modul) értéket +@msg give `path`, `data` or `dataBase64` add meg a `path`, a `data` vagy a `dataBase64` értéket +@msg give `path`, `data`, `dataBase64` or `csv` add meg a `path`, a `data`, a `dataBase64` vagy a `csv` értéket +@msg give `path`, `xml` or `dataBase64` add meg a `path`, az `xml` vagy a `dataBase64` értéket +@msg give `path`, an SVG file, or `svg`, SVG text add meg a `path` értéket (egy SVG-fájl) vagy az `svg` értéket (SVG-szöveg) +@msg give `script`, the JavaScript the event runs ("" removes the event) add meg a `script` értéket: az esemény által futtatott JavaScriptet (a "" eltávolítja az eseményt) +@msg give `text` (the reading) or `remove` add meg a `text` (az olvasat) vagy a `remove` értéket +@msg give `x` and `y` (and a `tolerance` of 0 or more) add meg az `x` és az `y` értéket (és egy 0 vagy nagyobb `tolerance` értéket) +@msg give `xml`, one SVG element add meg az `xml` értéket: egyetlen SVG elemet +@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments adj meg egy módot, vagy r/g/b, c/m/y/k, gray vagy tint módosításokat +@msg give a name, or paint the active proxy with a saved preset adj meg egy nevet, vagy fesd az aktív mintadobozt egy mentett előbeállítással +@msg give a name, or select a stroke with a saved profile adj meg egy nevet, vagy jelölj ki egy mentett profilú körvonalat +@msg give a pattern name or select an object painted with a pattern adj meg egy mintázatnevet, vagy jelölj ki egy mintázattal festett objektumot +@msg give at least one of clip, invert, disabled, linked adj meg legalább egyet ezek közül: clip, invert, disabled, linked +@msg give color, none, swatch or gradient adj meg color, none, swatch vagy gradient értéket +@msg give dataBase64 add meg a dataBase64 értéket +@msg give either at (a click) or area (a path drawn around the overlaps) add meg az at (egy kattintás) vagy az area (az átfedések köré rajzolt útvonal) értéket +@msg give exactly one of `at`, `rect` and `points` pontosan egyet adj meg ezek közül: `at`, `rect`, `points` +@msg give exactly one of `path`, `data` and `dataBase64` pontosan egyet adj meg ezek közül: `path`, `data`, `dataBase64` +@msg give formats or a preset, not both a formats értéket vagy egy előbeállítást adj meg, ne mindkettőt +@msg give index or indexes, not both az index vagy az indexes értéket add meg, ne mindkettőt +@msg give newName or delete add meg a newName vagy a delete értéket +@msg give offset or by add meg az offset vagy a by értéket +@msg give one element egyetlen elemet adj meg +@msg give one of `to`, `rect` or `at` egyet adj meg ezek közül: `to`, `rect`, `at` +@msg give one of assets, layers, selection or fullDocument egyet adj meg ezek közül: assets, layers, selection, fullDocument +@msg give only one of onto, above, below, icon and a column place csak egyet adj meg ezek közül: onto, above, below, icon vagy egy oszlopbeli hely +@msg give opacity or blend add meg az opacity vagy a blend értéket +@msg give origin: [x, y], or reset: true add meg ezt: origin: [x, y], vagy ezt: reset: true +@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note add meg az overprintFill, overprintStroke, showCenter, imageMap, url vagy note értéket +@msg give path (a file) or folder add meg a path (egy fájl) vagy a folder értéket +@msg give path, or name and dataBase64 add meg a path értéket, vagy a name és a dataBase64 értéket +@msg give paths or files add meg a paths vagy a files értéket +@msg give rows, rowHeight, columns or columnWidth add meg a rows, rowHeight, columns vagy columnWidth értéket +@msg give svg, or dataBase64 of an SVG or SVGZ file add meg az svg értéket, vagy egy SVG- vagy SVGZ-fájl dataBase64 értékét +@msg give text add meg a text értéket +@msg give the asset a name adj nevet az elemnek +@msg give the preset a `name` adj `name` értéket az előbeállításnak +@msg give the preset a name adj nevet az előbeállításnak +@msg give the preset a name (1–64 characters) adj nevet az előbeállításnak (1–64 karakter) +@msg give the settings to change, e.g. {"formats"}]} add meg a módosítandó beállításokat, például {"formats"}]} +@msg give the style a name adj nevet a stílusnak +@msg give two or more style `names` adj meg két vagy több stílusnevet a `names` értékben +@msg give two or more swatches adj meg két vagy több színmintát +@msg give x, y, or a handle's length or angle add meg az x, az y értéket, vagy egy fogantyú hosszát vagy szögét +@msg give {key, value} or {values} add meg ezt: {key, value} vagy {values} +@msg global must be true or false global: true vagy false kell +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) a(z) {_1} glifa nem ehhez a karakterhez rajzolódik ki a karakter stílusával: {ch} (ilyen például a Csupa nagybetű, a Kiskapitális vagy egy számrendszer) +@msg glyph {g} is not an alternate of {ch} in {_1} {_2} a(z) {g} glifa nem alternatívája ennek: {ch} (betűtípus: {_1} {_2}) +@msg gradient must be "objects" or "mesh" gradient: "objects" vagy "mesh" kell +@msg grays must be "profile" or "black", not `{g}` grays: "profile" vagy "black" kell, ez nem az: `{g}` +@msg gridColors must be a preset name or two colours gridColors: előbeállítás neve vagy két szín kell +@msg gridSize must be small, medium or large gridSize: small, medium vagy large kell +@msg guide {g} is not selected a segédvonal nincs kijelölve: {g} +@msg handle must be 0..3 handle: 0..3 kell +@msg handler at {_1} expects {_2} values but its range starts with {d} a(z) {_1} helyen lévő kezelő {_2} értéket vár, de a tartománya ennyivel kezdődik: {d} +@msg handler depth too large a kezelő mélysége túl nagy +@msg handler range {_1}..{_2} out of range a kezelő tartománya ({_1}..{_2}) tartományon kívül esik +@msg hiddenLayers must be true or false hiddenLayers: true vagy false kell +@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (ez az alapértelmezés), "omit" vagy "visible" +@msg highlight a layer to merge into emelj ki egy réteget, amelybe az egyesítés történjen +@msg highlight two or more rows in one layer or group emelj ki két vagy több sort egyetlen rétegben vagy csoportban +@msg highlight two or more rows to merge emelj ki két vagy több egyesítendő sort +@msg hvcC hvcC +@msg icon docks a panel in the icon column: use window.panel.dock icon: a panelt az ikonoszlopba dokkolja; használd a window.panel.dock parancsot +@msg id must be a dimension id id: méretvonal-azonosító kell +@msg image has no bounds a képnek nincsenek határai +@msg image {_1} is linked already a kép már csatolva van: {_1} +@msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client vagy server +@msg images in a spot ink whose samples couldn't be read were imported in RGB azok a direktszínű képek, amelyek mintái nem voltak olvashatók, RGB-ben lettek importálva +@msg images past the most pixels one file may hold were left out kimaradtak azok a képek, amelyek túllépték az egy fájlban megengedett legnagyobb képpontszámot +@msg images too large to read were left out a beolvasáshoz túl nagy képek kimaradtak +@msg index out of range az index tartományon kívül esik +@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes: legfeljebb {MAX_GUIDE_INDEXES} elemet tartalmazhat +@msg indexes must be an array of guide indexes indexes: segédvonalindexek tömbje kell +@msg instances must be 1..1000 instances: 1..1000 kell +@msg instruction out of range az utasítás tartományon kívül esik +@msg instruction {pc} ({op}) pops below the stack a(z) {pc}. utasítás ({op}) a verem alja alól venne ki értéket +@msg instruction {pc} reached with stack depths {seen} and {d} a(z) {pc}. utasítás kétféle veremmélységgel érhető el: {seen} és {d} +@msg invalid `color` érvénytelen `color` +@msg invalid anchor index {v}: expected a non-negative integer érvénytelen horgonypontindex: {v}; nemnegatív egész szám kell +@msg invalid anchor reference {v}: expected [subpath, anchor] érvénytelen horgonypont-hivatkozás: {v}; [subpath, anchor] kell +@msg invalid angle érvénytelen szög +@msg invalid array length érvénytelen tömbhossz +@msg invalid brush: {e} érvénytelen ecset: {e} +@msg invalid color érvénytelen szín +@msg invalid colour {c} érvénytelen szín: {c} +@msg invalid delta érvénytelen eltérés +@msg invalid group érvénytelen csoport +@msg invalid object id {v}: expected a non-negative integer érvénytelen objektumazonosító: {v}; nemnegatív egész szám kell +@msg invalid point érvénytelen pont +@msg invalid regular expression flag '{_1}' érvénytelen jelző a reguláris kifejezésben: '{_1}' +@msg invalid regular expression: {m} érvénytelen reguláris kifejezés: {m} +@msg invalid size érvénytelen méret +@msg invalid t érvénytelen t +@msg invalid time value érvénytelen időérték +@msg invalid tolerance érvénytelen tűrés +@msg is a confirm's answer, whose `value` must be true or false egy megerősítés válasza, amelynek `value` értéke true vagy false lehet +@msg is a prompt's answer, whose `value` must be a string or null egy bekérés válasza, amelynek `value` értéke karakterlánc vagy null lehet +@msg is an alert's answer, whose `value` must be "ok" egy figyelmeztetés válasza, amelynek `value` értéke "ok" lehet +@msg is longer than 16 KiB hosszabb 16 KiB-nál +@msg it draws its image through a clipping path a képét vágóútvonalon keresztül rajzolja ki +@msg it draws no image nem rajzol ki képet +@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes nagyobb, mint {MAX_DICTIONARY_FILE_BYTES} bájt +@msg it isn't a file nem fájl +@msg it names {_1} plates, and at most {MAX_PLATES} are read {_1} színkivonatot nevez meg, de legfeljebb {MAX_PLATES} olvasható be +@msg its art is too long or too detailed for a head a grafikája túl hosszú vagy túl részletes egy nyílhegyhez +@msg its art paints nothing a grafikája semmit sem fest +@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) minden rajztáblája a vásznon kívül esik (több mint {_1} pt távolságra az origótól) +@msg its clipping masks are nested too deeply a vágómaszkjai túl mélyen vannak egymásba ágyazva +@msg its image is too large to read a képe túl nagy a beolvasáshoz +@msg its metadata has no description a metaadataiban nincs leírás +@msg its metadata has no direct length a metaadatainak nincs közvetlenül megadott hossza +@msg its plates are too large: more than {MAX_SAMPLES} pixels together a színkivonatai túl nagyok: együtt több mint {MAX_SAMPLES} képpont +@msg its plates are too large: {_1} plates of {w} × {h} pixels a színkivonatai túl nagyok: {_1} darab {w} × {h} képpontos színkivonat +@msg its plates can't be kept together a színkivonatai nem tarthatók együtt +@msg its shapes could not be combined into one az alakzatait nem sikerült eggyé egyesíteni +@msg join two different paths két különböző útvonalat kapcsolj össze +@msg jump to {pc}, past the end ugrás a végén túlra: {pc} +@msg justify must be auto|left|center|right|justifyAll justify: auto|left|center|right|justifyAll kell +@msg kbps must be a positive number, not {kbps} kbps: pozitív szám kell, ez nem az: {kbps} +@msg keywords must be a list of words or a comma-separated string keywords: szavak listája vagy vesszővel tagolt karakterlánc kell +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords: legfeljebb {MAX_KEYWORD} karakter lehet, vezérlőkarakterek nélkül +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind: 1, 2 vagy 3 kell, vagy adj meg egy előbeállítást a `name` értékben (lásd: perspective.presets.list) +@msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind: graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle vagy paraStyle kell +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout: "on", "off", "neutral" vagy logikai érték kell, ez nem az: {v} +@msg layer `{name}` has no art to export ebben a rétegben nincs exportálható grafika: `{name}` +@msg layer must name a layer or sublayer layer: réteget vagy alréteget kell megneveznie +@msg layers can't be intertwined: select the objects in them rétegek nem fonhatók össze: a bennük lévő objektumokat jelöld ki +@msg layers go only at the top level or in layers, objects in layers and groups rétegek csak a legfelső szintre vagy rétegekbe kerülhetnek, objektumok rétegekbe és csoportokba +@msg layers must be true or false layers: true vagy false kell +@msg layout must be gridByRow, gridByColumn, row or column layout: gridByRow, gridByColumn, row vagy column kell +@msg leading must be a number or "auto" leading: szám vagy "auto" kell +@msg left/right must be non-negative widths left/right: nemnegatív szélességek kellenek +@msg legacyNameEncoding must be one of {_1} legacyNameEncoding: ezek egyike kell: {_1} +@msg level `{t}`: 2 or 3 level `{t}`: 2 vagy 3 +@msg line {line}: more than {MAX_CSV_COLS} columns {line}. sor: több mint {MAX_CSV_COLS} oszlop +@msg line {line}: unclosed quote {line}. sor: lezáratlan idézőjel +@msg line {line}: value for `{var}` must match its variable kind {line}. sor: a(z) `{var}` értékének meg kell felelnie a változó típusának +@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf vagy crlf +@msg loading profiles from disk isn't available in the browser böngészőben nem lehet profilokat lemezről betölteni +@msg localize: the first argument must be a localization object or a string localize: az első argumentum lokalizációs objektum vagy karakterlánc kell legyen +@msg margin: a {m} pt margin leaves no imageable area on the paper margin: {m} pt margó mellett nem marad nyomtatható terület a papíron +@msg matrix must be finite and invertible, depth finite matrix: véges és invertálható kell legyen, a depth véges +@msg matte: {e} matte: {e} +@msg member is for order front, forward, backward or back member: az order front, forward, backward vagy back értékéhez való +@msg missing `artboards` hiányzik: `artboards` +@msg missing `at` [x, y] hiányzik: `at` [x, y] +@msg missing `attrs` hiányzik: `attrs` +@msg missing `color` or `opacity` hiányzik: `color` vagy `opacity` +@msg missing `colors` hiányzik: `colors` +@msg missing `commands` hiányzik: `commands` +@msg missing `destinationFolder` hiányzik: `destinationFolder` +@msg missing `effect` id hiányzik az `effect` azonosítója +@msg missing `family` hiányzik: `family` +@msg missing `find` hiányzik: `find` +@msg missing `gid` (a glyph id, or null for the default glyph) hiányzik: `gid` (glifaazonosító, vagy null az alapértelmezett glifához) +@msg missing `group` hiányzik: `group` +@msg missing `id` hiányzik: `id` +@msg missing `id`: give one, or select one type object hiányzik: `id`; adj meg egyet, vagy jelölj ki egy szövegobjektumot +@msg missing `index` (a point index, or null to clear) hiányzik: `index` (pontindex, vagy null a törléshez) +@msg missing `index` (a stop index, or null to clear) hiányzik: `index` (színátmenetpont-index, vagy null a törléshez) +@msg missing `index` (an item index or null) hiányzik: `index` (elemindex vagy null) +@msg missing `item` hiányzik: `item` +@msg missing `library` hiányzik: `library` +@msg missing `library` (see graphicStyle.libraries) hiányzik: `library` (lásd: graphicStyle.libraries) +@msg missing `library` (see swatch.library.list) hiányzik: `library` (lásd: swatch.library.list) +@msg missing `map` (rows [{from, to}] or {current}) hiányzik: `map` ([{from, to}] sorok vagy {current}) +@msg missing `matrix` [a,b,c,d,e,f] hiányzik: `matrix` [a,b,c,d,e,f] +@msg missing `name` (see gradient.presets.list) hiányzik: `name` (lásd: gradient.presets.list) +@msg missing `offset` (a distance in points) hiányzik: `offset` (távolság pontban) +@msg missing `path` hiányzik: `path` +@msg missing `path` (a path object id) hiányzik: `path` (egy útvonalobjektum azonosítója) +@msg missing `picks` hiányzik: `picks` +@msg missing `rule` hiányzik: `rule` +@msg missing `source` id hiányzik a `source` azonosítója +@msg missing `start` (the byte offset of a character) hiányzik: `start` (egy karakter bájteltolása) +@msg missing `status`: linked, embedded, missing or modified hiányzik: `status`; linked, embedded, missing vagy modified lehet +@msg missing `target` id hiányzik a `target` azonosítója +@msg missing `text` hiányzik: `text` +@msg missing `to` hiányzik: `to` +@msg missing `to` (an index) hiányzik: `to` (egy index) +@msg missing `to`: a non-negative integer hiányzik: `to`; nemnegatív egész szám kell +@msg missing `type` hiányzik: `type` +@msg missing `value` hiányzik: `value` +@msg missing `word` hiányzik: `word` +@msg missing `{key}` hiányzik: `{key}` +@msg missing `{k}` hiányzik: `{k}` +@msg missing anchor hiányzik a horgonypont +@msg missing anchors hiányoznak a horgonypontok +@msg missing anchors [[subpath, anchor]…] hiányoznak a horgonypontok: [[subpath, anchor]…] +@msg missing array `{key}` hiányzik a tömb: `{key}` +@msg missing case hiányzik: case +@msg missing color hiányzik a szín +@msg missing commands hiányoznak a parancsok +@msg missing destination folder hiányzik a célmappa +@msg missing from hiányzik: from +@msg missing group hiányzik a csoport +@msg missing id or ids hiányzik: id vagy ids +@msg missing ids hiányzik: ids +@msg missing indexes hiányzik: indexes +@msg missing matrix [a,b,c,d,e,f] hiányzik: matrix [a,b,c,d,e,f] +@msg missing name hiányzik a név +@msg missing op hiányzik: op +@msg missing or invalid `color` hiányzó vagy érvénytelen `color` +@msg missing or unknown `tool` hiányzó vagy ismeretlen `tool` +@msg missing point hiányzik a pont +@msg missing point [x,y] hiányzik a pont: [x,y] +@msg missing point `{key}` ([x, y]) hiányzik a pont: `{key}` ([x, y]) +@msg missing points hiányoznak a pontok +@msg missing points [[x,y]…] hiányoznak a pontok: [[x,y]…] +@msg missing segment hiányzik a szakasz +@msg missing stops hiányoznak a színátmenetpontok +@msg missing subpaths hiányoznak a részútvonalak +@msg missing target hiányzik a cél +@msg missing to hiányzik: to +@msg missing x, y hiányzik: x, y +@msg mode is set, add or toggle, not `{other}` mode: set, add vagy toggle, ez nem az: `{other}` +@msg mode must be "cmyk" or "rgb" mode: "cmyk" vagy "rgb" kell +@msg mode must be area|onPath, got {o} mode: area|onPath kell, kapott érték: {o} +@msg more than 10000 linked files: package dependencies in smaller parts több mint 10000 csatolt fájl: kisebb részletekben csomagold a függőségeket +@msg more than 256 commands több mint 256 parancs +@msg more than {MAX_BATCH_FILES} data sets több mint {MAX_BATCH_FILES} adatkészlet +@msg more than {MAX_BATCH_FILES} files több mint {MAX_BATCH_FILES} fájl +@msg more than {MAX_CSV_ROWS} data rows több mint {MAX_CSV_ROWS} adatsor +@msg more than {MAX_FILES} images több mint {MAX_FILES} kép +@msg more than {MAX_IMPORT} presets több mint {MAX_IMPORT} előbeállítás +@msg moved must be [[x,y]…] moved: [[x,y]…] kell +@msg must be an object with `kind` and `value` `kind` és `value` kulcsot tartalmazó objektum kell +@msg must have `kind` alert, confirm or prompt a `kind` értéke alert, confirm vagy prompt kell legyen +@msg must have exactly `kind` and `value` pontosan a `kind` és a `value` kulcsot kell tartalmaznia +@msg name (a toolbar's name) is required name (egy eszköztár neve): kötelező +@msg name (a toolbar's name) must be a string name (egy eszköztár neve): karakterlánc kell +@msg name must be a toolbar's name name: egy eszköztár neve kell +@msg names is empty names: üres +@msg names must be an array of symbol names names: szimbólumnevek tömbje kell +@msg need `anchor` or `segment` + `t` `anchor` vagy `segment` + `t` kell +@msg need at least one anchor legalább egy horgonypont kell +@msg need at least one point legalább egy pont kell +@msg need at least two points legalább két pont kell +@msg needs a closing quote after the name a név után záró idézőjel kell +@msg needs a name név kell +@msg needs a name of at most 64 bytes legfeljebb 64 bájtos név kell +@msg needs a name of letters, digits and _, or a quoted name betűkből, számjegyekből és _ jelből álló név vagy idézőjelbe tett név kell +@msg needs a name without control characters vezérlőkarakterek nélküli név kell +@msg needs points pontok kellenek +@msg nesting too deep túl mély egymásba ágyazás +@msg nineSlice must be true or false nineSlice: true vagy false kell +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides: [left, top, right, bottom] kell, mindegyik -16..16, ahol left + right < 1 és top + bottom < 1 +@msg no HP-GL format nincs HP-GL formátum +@msg no New Document profile called `{shown}` ({_1}) nincs ilyen nevű új dokumentumprofil: `{shown}` ({_1}) +@msg no PDF preset named `{n}` nincs ilyen nevű PDF-előbeállítás: `{n}` +@msg no SVG filter at index {k} nincs SVG-szűrő ennél az indexnél: {k} +@msg no action set is current nincs aktuális műveletkészlet +@msg no active data set nincs aktív adatkészlet +@msg no appearance item {index} nincs ilyen megjelenési elem: {index} +@msg no arrowheads file here itt nincs nyílhegyfájl +@msg no artboard {_1} nincs ilyen rajztábla: {_1} +@msg no artboard {artboard} (0-based; the document has {_1}) nincs ilyen rajztábla: {artboard} (0-tól számozva; a dokumentumban {_1} van) +@msg no asset {id} nincs ilyen elem: {id} +@msg no asset {id} (see assets.list) nincs ilyen elem: {id} (lásd: assets.list) +@msg no assets to export nincs exportálható elem +@msg no assets yet: collect art for export first (assets.add) még nincsenek elemek: előbb gyűjts grafikát az exportáláshoz (assets.add) +@msg no brush named `{n}` nincs ilyen nevű ecset: `{n}` +@msg no character at byte {start} nincs karakter ennél a bájtnál: {start} +@msg no character has code {code} egyetlen karakternek sem ez a kódja: {code} +@msg no colour nincs szín +@msg no colour group `{group}` nincs ilyen színcsoport: `{group}` +@msg no colour group `{name}` nincs ilyen színcsoport: `{name}` +@msg no custom arrowhead {name} (see stroke.arrowheads.list) nincs ilyen egyéni nyílhegy: {name} (lásd: stroke.arrowheads.list) +@msg no data sets nincsenek adatkészletek +@msg no dataset named `{from}` nincs ilyen nevű adatkészlet: `{from}` +@msg no dataset named `{name}` nincs ilyen nevű adatkészlet: `{name}` +@msg no datasets nincsenek adatkészletek +@msg no design named `{name}` nincs ilyen nevű diagramminta: `{name}` +@msg no design named `{n}` nincs ilyen nevű diagramminta: `{n}` +@msg no design named `{old}` nincs ilyen nevű diagramminta: `{old}` +@msg no encoder for {_1} yet ehhez még nincs kódoló: {_1} +@msg no export preset `{name}` (see document.exportPresets.list) nincs ilyen exportálási előbeállítás: `{name}` (lásd: document.exportPresets.list) +@msg no file to open nincs megnyitandó fájl +@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder nincs mappa: add meg a `path` értéket, vagy állítsd be ezt: Beállítások › Bővítmények & Munkalemezek › További bővítmények mappája +@msg no fonts to look for: they are all available nincs keresendő betűtípus: mindegyik elérhető +@msg no formats to export nincs exportálandó formátum +@msg no gradient preset `{name}` (see gradient.presets.list) nincs ilyen színátmenet-előbeállítás: `{name}` (lásd: gradient.presets.list) +@msg no graphic style `{n}` in `{_1}` nincs ilyen grafikai stílus: `{n}` (itt: `{_1}`) +@msg no graphic style library `{key}` (see graphicStyle.libraries) nincs ilyen grafikaistílus-könyvtár: `{key}` (lásd: graphicStyle.libraries) +@msg no guide selected nincs kijelölt segédvonal +@msg no guide selected (or give an index) nincs kijelölt segédvonal (vagy adj meg egy indexet) +@msg no guide {i} nincs ilyen segédvonal: {i} +@msg no images in the folder a mappában nincsenek képek +@msg no instructions nincsenek utasítások +@msg no item at index {_1} nincs elem ennél az indexnél: {_1} +@msg no item at index {from} nincs elem ennél az indexnél: {from} +@msg no item at one of the indices {idx} az indexek egyikénél nincs elem: {idx} +@msg no layer nincs réteg +@msg no layer to rename nincs átnevezhető réteg +@msg no layer {id} (see layer.list) nincs ilyen réteg: {id} (lásd: layer.list) +@msg no layers to export nincs exportálható réteg +@msg no library `{key}` nincs ilyen könyvtár: `{key}` +@msg no metafile format nincs metafájlformátum +@msg no object {_1} nincs ilyen objektum: {_1} +@msg no objects nincsenek objektumok +@msg no parent layer nincs szülőréteg +@msg no perspective grid preset named `{b}` (see perspective.presets.list) nincs ilyen nevű perspektivikusrács-előbeállítás: `{b}` (lásd: perspective.presets.list) +@msg no perspective grid preset named `{n}` nincs ilyen nevű perspektivikusrács-előbeállítás: `{n}` +@msg no point selected (give `index`) nincs kijelölt pont (add meg az `index` értéket) +@msg no preset called `{from}` (see file.newPresets) nincs ilyen nevű előbeállítás: `{from}` (lásd: file.newPresets) +@msg no preset called `{name}` (see file.newPresets) nincs ilyen nevű előbeállítás: `{name}` (lásd: file.newPresets) +@msg no preset named `{n}` nincs ilyen nevű előbeállítás: `{n}` +@msg no print preset named `{b}` (see print.presets.list) nincs ilyen nevű nyomtatási előbeállítás: `{b}` (lásd: print.presets.list) +@msg no print preset named `{name}` (presets: {_1}) nincs ilyen nevű nyomtatási előbeállítás: `{name}` (előbeállítások: {_1}) +@msg no print preset named `{n}` nincs ilyen nevű nyomtatási előbeállítás: `{n}` +@msg no profile named `{name}` nincs ilyen nevű profil: `{name}` +@msg no random numbers ({e}) nincsenek véletlenszámok ({e}) +@msg no readable files in the folder a mappában nincsenek olvasható fájlok +@msg no recovery copy `{f}` (see file.recovery.list) nincs ilyen helyreállítási másolat: `{f}` (lásd: file.recovery.list) +@msg no rows nincsenek sorok +@msg no saved PDF preset named `{name}` nincs ilyen nevű mentett PDF-előbeállítás: `{name}` +@msg no saved export preset `{name}` nincs ilyen mentett exportálási előbeállítás: `{name}` +@msg no saved gradient preset called {name} nincs ilyen nevű mentett színátmenet-előbeállítás: {name} +@msg no saved gradient presets nincs mentett színátmenet-előbeállítás +@msg no saved preset called `{name}` nincs ilyen nevű mentett előbeállítás: `{name}` +@msg no saved presets to export (name [Default] in `names` to export it) nincs exportálható mentett előbeállítás (az exportálásához nevezd meg a [Default] előbeállítást a `names` értékben) +@msg no saved presets to export (name built-in ones in `names`) nincs exportálható mentett előbeállítás (a beépítetteket a `names` értékben nevezd meg) +@msg no saved print preset named `{name}` nincs ilyen nevű mentett nyomtatási előbeállítás: `{name}` +@msg no saved profile called {name} nincs ilyen nevű mentett profil: {name} +@msg no saved selection `{name}` nincs ilyen mentett kijelölés: `{name}` +@msg no saved theme called {name} (see colorTheme.list) nincs ilyen nevű mentett téma: {name} (lásd: colorTheme.list) +@msg no saved views nincsenek mentett nézetek +@msg no selected or given object has a visible 3D effect egyik kijelölt vagy megadott objektumnak sincs látható 3D effektusa +@msg no series {i} nincs ilyen adatsor: {i} +@msg no slice {n} in the image (see slice.list) a képen nincs ilyen szelet: {n} (lásd: slice.list) +@msg no source art nincs forrásgrafika +@msg no stop {i} (the gradient has {n}) nincs ilyen színátmenetpont: {i} (a színátmenetnek {n} van) +@msg no style `{name}` nincs ilyen stílus: `{name}` +@msg no such artboard {v} nincs ilyen rajztábla: {v} +@msg no such assets (see assets.list) nincsenek ilyen elemek (lásd: assets.list) +@msg no such frame anchor, or the frame would reach past the canvas nincs ilyen kerethorgonypont, vagy a keret túlnyúlna a vásznon +@msg no such objects nincsenek ilyen objektumok +@msg no such path anchor, or the path would reach past the canvas nincs ilyen útvonal-horgonypont, vagy az útvonal túlnyúlna a vásznon +@msg no such rows nincsenek ilyen sorok +@msg no swatch `{keep}` nincs ilyen színminta: `{keep}` +@msg no swatch `{n}` nincs ilyen színminta: `{n}` +@msg no swatch `{n}` (swatches and colour groups move separately) nincs ilyen színminta: `{n}` (a színminták és a színcsoportok külön mozgathatók) +@msg no swatch library `{key}` (see swatch.library.list) nincs ilyen színmintakönyvtár: `{key}` (lásd: swatch.library.list) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) nincs ilyen színmintakönyvtár: `{key}` (lásd: swatch.library.list; "document": a dokumentum színmintái) +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) nincs ilyen színmintakönyvtár: `{key}` (lásd: swatch.library.list; "{DOCUMENT_SWATCHES}": a dokumentum színmintái) +@msg no swatch named `{n}` nincs ilyen nevű színminta: `{n}` +@msg no swatch or colour group `{n}` nincs ilyen színminta vagy színcsoport: `{n}` +@msg no swatch or colour group `{n}` in `{_1}` nincs ilyen színminta vagy színcsoport: `{n}` (itt: `{_1}`) +@msg no symbol named `{_1}` nincs ilyen nevű szimbólum: `{_1}` +@msg no symbol named `{name}` nincs ilyen nevű szimbólum: `{name}` +@msg no text objects selected nincs kijelölt szövegobjektum +@msg no toolbar named `{_1}` nincs ilyen nevű eszköztár: `{_1}` +@msg no toolbar named `{name}` nincs ilyen nevű eszköztár: `{name}` +@msg no tracing preset `{preset}` (see imageTrace.presets) nincs ilyen vektorizálási előbeállítás: `{preset}` (lásd: imageTrace.presets) +@msg no user library folder here (save with a path, or without one to get the data) itt nincs felhasználói könyvtármappa (ments elérési úttal, vagy anélkül, hogy megkapd az adatokat) +@msg no variable named `{from}` nincs ilyen nevű változó: `{from}` +@msg no variable named `{name}` nincs ilyen nevű változó: `{name}` +@msg no variable named `{var}` nincs ilyen nevű változó: `{var}` +@msg no variable named `{v}` nincs ilyen nevű változó: `{v}` +@msg no view named `{name}` nincs ilyen nevű nézet: `{name}` +@msg node {_1} already has the text variable `{_2}` a(z) {_1} csomópontnak már van szövegváltozója: `{_2}` +@msg node {_1} is not a graph nem diagram ez a csomópont: {_1} +@msg node {_1} is not an image nem kép ez a csomópont: {_1} +@msg node {_1} is not type nem szöveg ez a csomópont: {_1} +@msg not a DCS file nem DCS-fájl +@msg not a PDF presets file (format `{_1}`) nem PDF-előbeállításfájl (formátum: `{_1}`) +@msg not a PDF presets file: {e} nem PDF-előbeállításfájl: {e} +@msg not a RegExp object nem RegExp objektum +@msg not a Word document: its WordDocument stream doesn't start with a file information block nem Word-dokumentum: a WordDocument adatfolyama nem fájlinformációs blokkal kezdődik +@msg not a Word document: its compound file has no WordDocument stream nem Word-dokumentum: az összetett fájljában nincs WordDocument adatfolyam +@msg not a Word document: its main part is not a WordprocessingML document nem Word-dokumentum: a fő része nem WordprocessingML-dokumentum +@msg not a Word document: its package has no main document part nem Word-dokumentum: a csomagjában nincs fő dokumentumrész +@msg not a closed path nem zárt útvonal +@msg not a flattener presets file (format `{_1}`) nem lapítási előbeállításfájl (formátum: `{_1}`) +@msg not a flattener presets file: {e} nem lapítási előbeállításfájl: {e} +@msg not a layer or group nem réteg és nem csoport +@msg not a library file: {e} nem könyvtárfájl: {e} +@msg not a mesh envelope point nem hálós burkoló pontja +@msg not a perspective grid presets file (format `{_1}`) nem perspektivikusrács-előbeállításfájl (formátum: `{_1}`) +@msg not a perspective grid presets file: {e} nem perspektivikusrács-előbeállításfájl: {e} +@msg not a print presets file (format `{_1}`) nem nyomtatási előbeállításfájl (formátum: `{_1}`) +@msg not a print presets file: {e} nem nyomtatási előbeállításfájl: {e} +@msg not a regular expression nem reguláris kifejezés +@msg not a stored or deflated central record nem tárolt vagy deflate-tel tömörített központi rekord +@msg not a valid SVGZ file: {e} nem érvényes SVGZ-fájl: {e} +@msg not an RTF file: it doesn't start with {\\rtf nem RTF-fájl: nem ezzel kezdődik: {\\rtf +@msg not an action set: the text doesn't start with /version and a number nem műveletkészlet: a szöveg nem a /version szóval és egy számmal kezdődik +@msg not an image VectorCraft reads (see document.formats) nem olyan kép, amelyet a VectorCraft olvas (lásd: document.formats) +@msg not area type (text in a frame) outside perspective nem perspektíván kívüli területszöveg (keretben lévő szöveg) +@msg not editing an opacity mask nem folyik átlátszósági maszk szerkesztése +@msg not type on a path outside perspective nem perspektíván kívüli útvonalszöveg +@msg nothing prints semmi sem kerül nyomtatásra +@msg nothing to bind nincs mit kötni +@msg nothing to change nincs mit módosítani +@msg nothing to change: give dx, dy, hScale, vScale or rotation nincs mit módosítani: add meg a dx, dy, hScale, vScale vagy rotation értéket +@msg nothing to crop nincs mit körülvágni +@msg nothing to export nincs mit exportálni +@msg nothing to export: the document has no visible art nincs mit exportálni: a dokumentumban nincs látható grafika +@msg nothing to mark nincs mit megjelölni +@msg nothing to print: every artboard picked is blank nincs mit nyomtatni: minden kiválasztott rajztábla üres +@msg nothing to print: the document has no art that prints nincs mit nyomtatni: a dokumentumban nincs nyomtatásra kerülő grafika +@msg nothing to repeat nincs mit ismételni +@msg numbers out of order in {_1} quantifier a számok rossz sorrendben vannak ebben a kvantorban: {_1} +@msg object is not a path az objektum nem útvonal +@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object az objektum átlátszósága nem vehető át kiemelt karakterekre; kapcsold ki az Átlátszóság beállítást a Pipetta beállításaiban, vagy jelöld ki a teljes objektumot +@msg object {_1} can't be set as point type ez az objektum nem állítható be pontszövegként: {_1} +@msg object {_1} has lost its file's contents: relink it ez az objektum elvesztette a fájlja tartalmát: {_1}; csatold újra +@msg object {_1} has no path ennek az objektumnak nincs útvonala: {_1} +@msg object {_1} has no size ennek az objektumnak nincs mérete: {_1} +@msg object {_1} is hidden or locked, or on a locked artboard ez az objektum rejtett vagy zárolt, vagy zárolt rajztáblán van: {_1} +@msg object {_1} is not a placed document ez az objektum nem elhelyezett dokumentum: {_1} +@msg object {_1} is not an Intertwine group ez az objektum nem összefonási csoport: {_1} +@msg object {_1} is not an image ez az objektum nem kép: {_1} +@msg object {_1} is not an image or a linked file ez az objektum nem kép és nem csatolt fájl: {_1} +@msg object {_1} is not an image or a placed document ez az objektum nem kép és nem elhelyezett dokumentum: {_1} +@msg object {_1} is not in this Intertwine group ez az objektum nincs ebben az összefonási csoportban: {_1} +@msg object {_1} needs a 3D effect ehhez az objektumhoz 3D effektus kell: {_1} +@msg object {_1} needs a 3D effect at index {index} a(z) {_1} objektumhoz 3D effektus kell ennél az indexnél: {index} +@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic {_1} objektum: az üreges Kihúzás és élletörés effektusnak nincsenek fedőlapjai; grafika leképezéséhez kapcsold be a Fedőlap beállítást +@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front {_1} objektum: 0 mélységnél az objektumnak egy lapja van; a grafikáit az előlapra képezd le +@msg object {_1}: {_2} {_1} objektum: {_2} +@msg object {_1}: {e} {_1} objektum: {e} +@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams {_1} objektum: a(z) {index} indexű {name} effektusnak nincsenek megvilágítási előbeállításai; a fényeit az effect.setParams paranccsal állítsd be +@msg object {id} has anchors 0–{_1} for `corners`, not {past} a(z) {id} objektum horgonypontjai a `corners` értékhez: 0–{_1}; ilyen nincs: {past} +@msg object {id} has {n} anchors, so no anchor {past} a(z) {id} objektumnak {n} horgonypontja van, ezért ilyen nincs: {past} +@msg objects must be none, move or copy objects: none, move vagy copy kell +@msg objects were left out objektumok maradtak ki +@msg offset out of range az eltolás tartományon kívül esik +@msg on must be true or false on: true vagy false kell +@msg only groups, layers, type and images have a Contents (Characters) row csak a csoportoknak, a rétegeknek, a szövegnek és a képeknek van Tartalom (Karakterek) sora +@msg only interior mesh lines can be deleted csak belső hálóvonalak törölhetők +@msg only solid colours can be added (not gradients or patterns) csak egyszínű színek adhatók hozzá (színátmenetek és mintázatok nem) +@msg only solid-colour swatches can be spot colours csak egyszínű színminták lehetnek direktszínek +@msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first ennek az Affinity-fájlnak csak a beágyazott előnézete volt olvasható: a Fájl › Megnyitás paranccsal megnézheted a figyelmeztetésével együtt, vagy előbb exportálj SVG-t vagy PDF-et az Affinityből +@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning ennek az Affinity-fájlnak csak a beágyazott előnézete volt olvasható; a Fájl › Megnyitás paranccsal megnézheted a figyelmeztetésével együtt +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto, above, below, icon és az oszlopbeli hely: a window.panel.float és a window.panel.dock parancshoz tartoznak +@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto, above, below, icon és az oszlopbeli hely: nem adható meg mellettük anchor, side, zone vagy before +@msg onto, above, below, icon and a column place take no x, y, width or height onto, above, below, icon és az oszlopbeli hely: nem adható meg mellettük x, y, width vagy height +@msg opacity must be a number opacity: szám kell +@msg open a document to look for its linked files a csatolt fájlok kereséséhez nyiss meg egy dokumentumot +@msg operand too large az operandus túl nagy +@msg options must be an object (see file.formatOptions) options: objektum kell (lásd: file.formatOptions) +@msg order is a whole number from 0 to {others}, not `{v}` order: 0 és {others} közötti egész szám, ez nem az: `{v}` +@msg order must be leftToRight or rightToLeft order: leftToRight vagy rightToLeft kell +@msg orientation must be portrait or landscape, not `{o}` orientation: portrait vagy landscape kell, ez nem az: `{o}` +@msg outlineText {v}: true or false outlineText {v}: true vagy false +@msg output intent profile: {e} kimeneti cél profilja: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: nincs ilyen nevű RGB- vagy CMYK-profil: „{name}” +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: a(z) {_1} fájlok CMYK színmódúak, „{name}” pedig RGB-profil +@msg output.destination: {e} output.destination: {e} +@msg output.inks: no ink is set to print output.inks: egyetlen festék sincs nyomtatásra állítva +@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: nincs ilyen nevű profil: „{name}”, a(z) {std} kimeneti cél pedig beágyazza a profilját +@msg output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) output.outputIntent: nincs ilyen nevű profil: „{name}”; a(z) {std} kimeneti cél beágyaz egy profilt, vagy megnevez egy regisztrált nyomtatási feltételt (outputConditionId és registry) +@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: a(z) {std} fájlok CMYK-ban nyomtatódnak, „{name}” pedig RGB-profil +@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: „{name}” szürke profil, amely nem lehet {std} kimeneti cél +@msg overlap: {o} pt is half a tile or more overlap: {o} pt egy fél csempe vagy annál több +@msg overlapping Shaper paths must be in the same layer or group az egymást átfedő Alakformáló-útvonalaknak ugyanabban a rétegben vagy csoportban kell lenniük +@msg overlapping edits egymást átfedő szerkesztések +@msg page art conflicts with image data for {key} az oldal grafikája ütközik ennek a képadataival: {key} +@msg page art conflicts with pattern {name} az oldal grafikája ütközik ezzel a mintázattal: {name} +@msg page art conflicts with swatch {name} az oldal grafikája ütközik ezzel a színmintával: {name} +@msg page art is missing image data for {key} az oldal grafikájából hiányoznak ennek a képadatai: {key} +@msg page art is missing pattern {name} az oldal grafikájából hiányzik ez a mintázat: {name} +@msg page art is missing swatch {name} az oldal grafikájából hiányzik ez a színminta: {name} +@msg page must be a whole number from 1 page: 1-től kezdődő egész szám kell +@msg page thumbnail: {e} oldalbélyegkép: {e} +@msg page {page} of the PDF has no objects a PDF {page}. oldalán nincsenek objektumok +@msg page {page}: `{file}` has {n} {artboards} {page}. oldal: `{file}` tartalma: {n} {artboards} +@msg page {page}: `{name}` has {n} {pages} {page}. oldal: `{name}` tartalma: {n} {pages} +@msg pages must be a range such as "2-3" or a page number, not {v} pages: tartomány (például "2-3") vagy oldalszám kell, ez nem az: {v} +@msg pages: {e} pages: {e} +@msg panel position must be finite a panel pozíciója véges érték kell legyen +@msg parameters must be an object parameters: objektum kell +@msg params must be an object params: objektum kell +@msg passwords can have up to {MAX_PASSWORD} bytes a jelszavak legfeljebb {MAX_PASSWORD} bájtosak lehetnek +@msg path chunks must share their boundary point az útvonaldarabok határpontjának közösnek kell lennie +@msg path has no open end az útvonalnak nincs nyitott vége +@msg paths must be strings paths: karakterláncok kellenek +@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false esetén minden rajztábla kell ahhoz, hogy a szerkeszthető grafika megmaradjon +@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false pdfCompatible: false esetén a rajztáblák kellenek a szerkeszthető grafika megtartásához, ha a useArtboards értéke false +@msg pdfs `{m}`: single or multiple pdfs `{m}`: single vagy multiple +@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom: "artwork" vagy "swatches", ez nem az: {v} +@msg pins and moved must be non-empty lists of the same length pins és moved: azonos hosszúságú, nem üres listák kellenek +@msg pins must be [[x,y]…] pins: [[x,y]…] kell +@msg pins must be finite pins: véges értékek kellenek +@msg place is above, below or inside, not `{other}` place: above, below vagy inside, ez nem az: `{other}` +@msg plane must be left|right|ground plane: left|right|ground kell +@msg plane must be left|right|ground|none plane: left|right|ground|none kell +@msg point is outside the mesh a pont a hálón kívül esik +@msg point x must be a number a pont x értéke szám kell legyen +@msg point y must be a number a pont y értéke szám kell legyen +@msg points must be [t, left, right] with non-negative widths points: [t, left, right] kell nemnegatív szélességekkel +@msg points must be [x,y] pairs points: [x,y] párok kellenek +@msg points must be a non-empty [[x,y,pressure?]…] list points: nem üres [[x,y,pressure?]…] lista kell +@msg points must be a non-empty list of finite [x,y] points: véges [x,y] értékek nem üres listája kell +@msg points must be an array or null points: tömb vagy null kell +@msg points must be finite points: véges értékek kellenek +@msg points must be finite [x,y] pairs points: véges [x,y] párok kellenek +@msg points must be finite canvas points points: véges vászonpontok kellenek +@msg points must contain 1–4096 coordinates points: 1–4096 koordinátát kell tartalmaznia +@msg position must be normal, superscript or subscript position: normal, superscript vagy subscript kell +@msg position must be topLeft, topRight, bottomLeft or bottomRight position: topLeft, topRight, bottomLeft vagy bottomRight kell +@msg positions must be between 0 and 100000 pt positions: 0 és 100000 pt közötti értékek kellenek +@msg ppi must be a positive number, not {ppi} ppi: pozitív szám kell, ez nem az: {ppi} +@msg ppi must be between 1 and 2400 ppi: 1 és 2400 közötti érték kell +@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}`: nem transforms|bounds|fileDimensions|fit|fill +@msg preserveShape must be clippingMask|transparency preserveShape: clippingMask|transparency kell +@msg preset `{id}`: mobile or density preset `{id}`: mobile vagy density +@msg preset must be standard, diffuse, topLeft or right preset: standard, diffuse, topLeft vagy right kell +@msg preset must be the name of a print preset preset: egy nyomtatási előbeállítás neve kell +@msg previewMode must be default, pixel or overprint previewMode: default, pixel vagy overprint kell +@msg print settings: {e} nyomtatási beállítások: {e} +@msg procedures nest too deeply az eljárások túl mélyen ágyazódnak egymásba +@msg procedures nested too deeply az eljárások túl mélyen vannak egymásba ágyazva +@msg quotes must be {double?, single?} quotes: {double?, single?} kell +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k}: egy nyitó és egy záró idézőjel kell, például "“”" +@msg range out of order in character class rossz sorrendű tartomány a karakterosztályban +@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi: 1–2400 kell (72 képernyő, 150 közepes, 300 magas) +@msg rating must be a whole number from 0 to {MAX_RATING} rating: 0 és {MAX_RATING} közötti egész szám kell +@msg rect and {k} contradict each other: give the area or the artboards rect és {k}: ellentmondanak egymásnak; a területet vagy a rajztáblákat add meg +@msg rect applies to raster formats, not {_1} rect: raszteres formátumokra vonatkozik, erre nem: {_1} +@msg rect is [x, y, width, height] in points rect: [x, y, width, height] pontban megadva +@msg rect must be [x, y, width, height] rect: [x, y, width, height] kell +@msg rect must be [x, y, width, height] with a positive width and height rect: [x, y, width, height] kell pozitív szélességgel és magassággal +@msg rect needs finite numbers and a positive width and height rect: véges számok, valamint pozitív szélesség és magasság kell +@msg redraw needs id redraw: id is kell hozzá +@msg redraw within must be a finite distance of 0 or more redraw within: 0 vagy nagyobb véges távolság kell +@msg reduce of an empty array with no initial value reduce hívása üres tömbön, kezdőérték nélkül +@msg registration must be 0..8 registration: 0..8 kell +@msg regular expression flag '{_1}' given twice a reguláris kifejezés jelzője kétszer szerepel: '{_1}' +@msg release a layer or a group réteget vagy csoportot oldj fel +@msg replace keeps the replaced object's place and transform: drop template, at and rect replace: megtartja a lecserélt objektum helyét és transzformációját; hagyd el a template, at és rect értéket +@msg replace: select the one object to replace replace: jelöld ki azt az egy objektumot, amelyet le kell cserélni +@msg resolution must be a number or screen|medium|high resolution: szám vagy screen|medium|high kell +@msg resolution must be between 1 and 2400 ppi resolution: 1 és 2400 ppi közötti érték kell +@msg rotation must be a number of degrees rotation: fokban megadott szám kell +@msg rotation must be finite rotation: véges érték kell +@msg rows and cols must be 1..50 rows és cols: 1..50 kell +@msg rows and columns must be 1..500 rows és columns: 1..500 kell +@msg rows of objects whose appearances differ can't be reordered together eltérő megjelenésű objektumok sorai nem rendezhetők át együtt +@msg sampled text would exceed the canvas (coordinates out of range) a mintavételezett szöveg túllépné a vásznat (a koordináták tartományon kívül esnek) +@msg save the document first: Package collects a saved document előbb mentsd a dokumentumot: a Csomagolás mentett dokumentumot gyűjt össze +@msg save the document first: copy puts the file in its folder előbb mentsd a dokumentumot: a copy a dokumentum mappájába teszi a fájlt +@msg savedPreset: {e} savedPreset: {e} +@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) scale `{s}`: szorzó (például 2x), szélesség (100w), magasság (100h) vagy felbontás (72ppi) kell +@msg scale must be a number above 0, at most 64 scale: 0-nál nagyobb, legfeljebb 64 értékű szám kell +@msg scale must be non-zero scale: nem lehet nulla +@msg scope must be selection or all (got {other}) scope: selection vagy all kell (kapott érték: {other}) +@msg scribble is too long a firka túl hosszú +@msg segment does not exist a szakasz nem létezik +@msg select a Shaper group jelölj ki egy alakformáló csoportot +@msg select a blend to set easing or color shift a gyorsítás vagy a színeltolás beállításához jelölj ki egy összemosást +@msg select a compound shape jelölj ki egy összetett alakzatot +@msg select a graph jelölj ki egy diagramot +@msg select a group or layer with visible, unlocked members jelölj ki egy csoportot vagy réteget, amelynek látható, nem zárolt tagjai vannak +@msg select a layer jelölj ki egy réteget +@msg select a text object jelölj ki egy szövegobjektumot +@msg select an SVG filter jelölj ki egy SVG-szűrőt +@msg select an image (or an Image Trace group) jelölj ki egy képet (vagy egy képvektorizálási csoportot) +@msg select an image, or pass id jelölj ki egy képet, vagy add meg az id értéket +@msg select an object (or pass `id`) jelölj ki egy objektumot (vagy add meg az `id` értéket) +@msg select an object with a 3D effect jelölj ki egy 3D effektussal rendelkező objektumot +@msg select an object with a 3D effect, or give its id in ids jelölj ki egy 3D effektussal rendelkező objektumot, vagy add meg az azonosítóját az ids értékben +@msg select an object with a freeform gradient jelölj ki egy szabad formájú színátmenettel rendelkező objektumot +@msg select an object with an opacity mask jelölj ki egy átlátszósági maszkkal rendelkező objektumot +@msg select area type jelölj ki területszöveget +@msg select area type (text in a frame) jelölj ki területszöveget (keretben lévő szöveget) +@msg select area type and closed paths (at least one area type object) jelölj ki területszöveget és zárt útvonalakat (legalább egy területszöveg-objektumot) +@msg select area type whose first line can fit its frame jelölj ki olyan területszöveget, amelynek az első sora elfér a keretében +@msg select artwork to add its colours jelölj ki grafikát a színei hozzáadásához +@msg select artwork to make a group of its colours jelölj ki grafikát, hogy a színeiből csoport készüljön +@msg select artwork to recolour, or give a colour `group` jelölj ki átszínezendő grafikát, vagy adj meg színcsoportot a `group` értékben +@msg select exactly two path ends to join pontosan két összekapcsolandó útvonalvéget jelölj ki +@msg select members of a compound shape jelöld ki egy összetett alakzat tagjait +@msg select objects or give ids jelölj ki objektumokat, vagy add meg az ids értéket +@msg select objects to align to the guide jelöld ki a segédvonalhoz igazítandó objektumokat +@msg select one Intertwine group egyetlen összefonási csoportot jelölj ki +@msg select one image or pass id egyetlen képet jelölj ki, vagy add meg az id értéket +@msg select one layer, group or object to rename egyetlen átnevezendő réteget, csoportot vagy objektumot jelölj ki +@msg select paths jelölj ki útvonalakat +@msg select paths or compound paths jelölj ki útvonalakat vagy összetett útvonalakat +@msg select rows or objects to collect jelöld ki az összegyűjtendő sorokat vagy objektumokat +@msg select something to export jelölj ki valamit az exportáláshoz +@msg select something to export (or turn off selectedOnly) jelölj ki valamit az exportáláshoz (vagy kapcsold ki a selectedOnly beállítást) +@msg select text to redefine the style from jelölj ki szöveget, amelyből a stílus újradefiniálható +@msg select the art and, on top of it, the mask object jelöld ki a grafikát és a fölötte lévő maszkobjektumot +@msg select the art for the design jelöld ki a diagrammintához szánt grafikát +@msg select the art for the pattern tile jelöld ki a mintacsempéhez szánt grafikát +@msg select the art to add first előbb jelöld ki a hozzáadandó grafikát +@msg select the art to collect (or give its ids) jelöld ki az összegyűjtendő grafikát (vagy add meg az azonosítóit) +@msg select the art to weed jelöld ki a gyomlálandó grafikát +@msg select the content and a path on top jelöld ki a tartalmat és egy fölötte lévő útvonalat +@msg select the images to relink, or pass ids jelöld ki az újracsatolandó képeket, vagy add meg az ids értéket +@msg select the images, or pass ids jelöld ki a képeket, vagy add meg az ids értéket +@msg select the linked images or placed documents, or pass ids jelöld ki a csatolt képeket vagy az elhelyezett dokumentumokat, vagy add meg az ids értéket +@msg select the object to copy from jelöld ki azt az objektumot, amelyről a másolás történjen +@msg select the objects (or give their ids) jelöld ki az objektumokat (vagy add meg az azonosítóikat) +@msg select the objects to attach to the path jelöld ki az útvonalhoz csatolandó objektumokat +@msg select the objects to bind jelöld ki a kötendő objektumokat +@msg select two or more objects to intertwine jelölj ki két vagy több összefonandó objektumot +@msg select two or more slices to combine jelölj ki két vagy több egyesítendő szeletet +@msg select type on a path jelölj ki útvonalszöveget +@msg select type on a path or area type jelölj ki útvonalszöveget vagy területszöveget +@msg selection `{m}`: one or each selection `{m}`: one vagy each +@msg settings is an object of document.exportForScreens params settings: a document.exportForScreens paramétereiből álló objektum +@msg settings must be an object settings: objektum kell +@msg show `{show}`: all, missing, modified or embedded show `{show}`: all, missing, modified vagy embedded +@msg show must be true or false show: true vagy false kell +@msg side is left or right, not `{v}` side: left vagy right, ez nem az: `{v}` +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size: "small", "medium", "large" vagy 0-nál nagyobb, legfeljebb {_1} pontérték kell +@msg size must be positive size: pozitív érték kell +@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode: "fixed" vagy "pressure", ez nem az: `{other}` +@msg sizeMode is "fixed" or "pressure", not {other} sizeMode: "fixed" vagy "pressure", ez nem az: {other} +@msg sizeVariation must be 0 or more sizeVariation: 0 vagy nagyobb érték kell +@msg smallCaps must be true or false smallCaps: true vagy false kell +@msg sort `{x}`: name, kind or status sort `{x}`: name, kind vagy status +@msg source paths are too large for Shaper a forrásútvonalak túl nagyok az Alakformálóhoz +@msg source text transform is collapsed or unrepresentable a forrásszöveg transzformációja összeesett vagy nem ábrázolható +@msg spot, mode and group apply to solid colours only spot, mode és group: csak egyszínű színekre vonatkozik +@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked, embedded, missing vagy modified +@msg steps must be 1..1000 steps: 1..1000 kell +@msg steps must be 1..{MAX_STEPS} steps: 1..{MAX_STEPS} kell +@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) {i}. színátmenetpont: `plates`: négy 0 és 1 közötti szám kell (C, M, Y, K) +@msg stopped before the archive was finished leállt, mielőtt az archívum elkészült volna +@msg strokeMode must be within|along|across, got {m} strokeMode: within|along|across kell, kapott érték: {m} +@msg strokeOptions must be an object of stroke.set options strokeOptions: a stroke.set beállításaiból álló objektum kell +@msg strokeWidth must be a positive number of pixels strokeWidth: képpontban megadott pozitív szám kell +@msg subpath {si} is not an open subpath ez a részútvonal nem nyitott: {si} +@msg subpaths required when joining the same path ugyanazon útvonal összekapcsolásakor a subpaths kötelező +@msg swatchConflict must be "merge" or "add", not {v} swatchConflict: "merge" vagy "add" kell, ez nem az: {v} +@msg table must be true or false table: true vagy false kell +@msg takes one name egy nevet fogad +@msg target text transform is collapsed or unrepresentable a célszöveg transzformációja összeesett vagy nem ábrázolható +@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text: {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} kell +@msg text transform is collapsed or unrepresentable a szöveg transzformációja összeesett vagy nem ábrázolható +@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces: 2 és 100 közötti egész szám kell +@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: a "{_1}" vagy a "{_2}" értéket használd +@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: add meg egy képfájl {path} vagy {name, dataBase64} értékét, vagy egy textúrát úgy, ahogy az effect.list megadja +@msg textureImage: {e} textureImage: {e} +@msg that makes more than {MAX_PIECES} slices ez több mint {MAX_PIECES} szeletet eredményezne +@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer ez túl sok darabot (legfeljebb 100000) vagy szaggatási lépést eredményezne: egyszerűsítsd a szaggatási mintát, vagy növeld a vonalkák hosszát +@msg that object is not in the overlap ez az objektum nincs az átfedésben +@msg the Affinity document has no page with a size az Affinity-dokumentumban nincs mérettel rendelkező oldal +@msg the CMYK image couldn't be written a CMYK-képet nem sikerült kiírni +@msg the Layers panel is not shown a Rétegek panel nem látható +@msg the Layers panel's search, filter or Show Layers Only hides its row a Rétegek panel keresése, szűrője vagy a Csak rétegek megjelenítése beállítás elrejti a sorát +@msg the Objects on Path group is hidden or locked az Objektumok útvonalon csoport rejtett vagy zárolt +@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected a PDF biztonsági beállításai a jelszavak nélkül lettek mentve: add meg újra az openPassword vagy a permissionsPassword értéket, vagy a védelem nélküli exportáláshoz a security értéket passwordRequired nélkül +@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) az RTF-fájlban túl sok a bekezdés vagy a formázásváltás ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {MAX_PARAGRAPHS} bekezdés és {MAX_RUNS} szövegrész) +@msg the RTF file is too large to place as type (at most {_1} MB) az RTF-fájl túl nagy ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {_1} MB) +@msg the SVG expands to more than {0} nested SVG images az SVG kibontva több mint {0} beágyazott SVG-képet tartalmaz +@msg the SVG expands to more than {0} objects az SVG kibontva több mint {0} objektumot tartalmaz +@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes az SVG-fájl nagyobb, mint {MAX_IMPORT_BYTES} bájt +@msg the SVG has no drawable objects az SVG-ben nincsenek kirajzolható objektumok +@msg the SVGZ file unpacks to more than {_1} MB az SVGZ-fájl kicsomagolva nagyobb, mint {_1} MB +@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place a Word-dokumentumban több mint {MAX_PAGES} oldalnyi tulajdonság van, ez túl sok az elhelyezéshez +@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place a Word-dokumentumban több mint {MAX_RUNS} karakter- vagy bekezdéstulajdonság-szakasz van, ez túl sok az elhelyezéshez +@msg the Word document has more than {_1} MB of formatting properties, too many to place a Word-dokumentumban több mint {_1} MB formázási tulajdonság van, ez túl sok az elhelyezéshez +@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) a Word-dokumentumban túl sok a szöveg vagy a formázás ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {MAX_PARAGRAPHS} bekezdés, {MAX_RUNS} szövegrész és {_1} MB szöveg) +@msg the Word document has too much text to place as type (at most {_1} MB) a Word-dokumentumban túl sok a szöveg ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {_1} MB) +@msg the Word document is encrypted or protected with a password; save it without one to place it a Word-dokumentum titkosított vagy jelszóval védett; az elhelyezéshez mentsd jelszó nélkül +@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) a Word-dokumentum túl nagy az elhelyezéshez (a(z) {name} adatfolyama több mint {_1} MB) +@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off a Word-dokumentum utoljára gyorsmentéssel lett mentve, amelyet a VectorCraft nem olvas; mentsd újra kikapcsolt gyorsmentéssel +@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document a Word-dokumentumot a Word 6.0 vagy a Word 95 mentette, ezt a VectorCraft nem olvassa; mentsd Word 97-2003 dokumentumként +@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place a Word-dokumentum szövege több mint {MAX_PIECES} darabból áll, ez túl sok az elhelyezéshez +@msg the XML operation needs an XML or XMLList value az XML-művelethez XML vagy XMLList érték kell +@msg the XPath expression does not evaluate to a node-set az XPath-kifejezés eredménye nem csomóponthalmaz +@msg the XPath expression ends early az XPath-kifejezés túl korán ér véget +@msg the XPath expression has an unmatched ) az XPath-kifejezésben pár nélküli ) van +@msg the XPath expression has an unmatched ] az XPath-kifejezésben pár nélküli ] van +@msg the XPath expression has an unmatched closing bracket az XPath-kifejezésben pár nélküli záró zárójel van +@msg the XPath expression selects a namespace node, which is not an XML value az XPath-kifejezés névtércsomópontot jelöl ki, amely nem XML-érték +@msg the XPath expression selects the root node, which is not an XML value az XPath-kifejezés a gyökércsomópontot jelöli ki, amely nem XML-érték +@msg the XPath function {name} needs a node-set ehhez az XPath-függvényhez csomóponthalmaz kell: {name} +@msg the XPath function {name} takes the wrong number of arguments ez az XPath-függvény nem megfelelő számú argumentumot kapott: {name} +@msg the XPath name functions need a node-set az XPath névfüggvényeihez csomóponthalmaz kell +@msg the XPath prefix {shown} is not bound to a namespace ez az XPath-előtag nincs névtérhez kötve: {shown} +@msg the XPath variable ${shown} is not bound ez az XPath-változó nincs kötve: ${shown} +@msg the action set ids are not a list a műveletkészlet-azonosítók nem alkotnak listát +@msg the active paint is not a gradient az aktív festés nem színátmenet +@msg the argument list is not an object az argumentumlista nem objektum +@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size a rajztáblák nem férnek el a vásznon: kevesebb rajztábla, kisebb térköz vagy kisebb méret kell +@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out a grafika túl nagy az EPS-hez: ami 256 MB PostScript után következik, az kimaradt +@msg the bitmaps hold more pixels than one file may a bitképek több képpontot tartalmaznak, mint amennyi egy fájlban megengedett +@msg the character's size can't be measured a karakter mérete nem mérhető meg +@msg the code runs past its last instruction a kód túlfut az utolsó utasításán +@msg the compound shape is too complex to expand az összetett alakzat túl bonyolult a kibontáshoz +@msg the container of the listed objects is gone a felsorolt objektumok tárolója már nincs meg +@msg the copy `{user_id}` can't be read as a library a másolat nem olvasható be könyvtárként: `{user_id}` +@msg the copy would leave the canvas a másolat kikerülne a vászonról +@msg the curve tolerance must be from 0.01 to 10 points a görbetűrés 0,01 és 10 pont között lehet +@msg the custom dictionary couldn't be saved to {_1}: {e} az egyéni szótárat nem sikerült ide menteni: {_1}: {e} +@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is az egyéni szótár nem olvasható: {_1} ({why}), ezért változatlan marad +@msg the cut cannot be made on this art: {e} a vágás ezen a grafikán nem végezhető el: {e} +@msg the data isn't an EMF or WMF picture az adatok nem EMF- vagy WMF-képet alkotnak +@msg the data isn't an EPS or PostScript picture az adatok nem EPS- vagy PostScript-képet alkotnak +@msg the deferred paste is no longer available a halasztott beillesztés már nem érhető el +@msg the design is too big to draw at every data point of this graph a diagramminta túl nagy ahhoz, hogy a diagram minden adatpontjánál kirajzolható legyen +@msg the design is too big to draw in every column of this graph a diagramminta túl nagy ahhoz, hogy a diagram minden oszlopában kirajzolható legyen +@msg the dialog it was opened over is no longer open a párbeszédablak, amely fölött megnyílt, már nincs nyitva +@msg the dictionary stack overflowed a szótárverem túlcsordult +@msg the document already has an SVG filter named {name} a dokumentumban már van ilyen nevű SVG-szűrő: {name} +@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it a dokumentumban van egy másik ilyen nevű SVG-szűrő: {name}; használd az svgFilter.apply parancsot, vagy a szerkesztéséhez az svgFilter.define parancsot a `replace` értékkel +@msg the document has no SVG filter named {name} a dokumentumban nincs ilyen nevű SVG-szűrő: {name} +@msg the document has no SVG filter named {r} a dokumentumban nincs ilyen nevű SVG-szűrő: {r} +@msg the document has no color swatches a dokumentumban nincsenek színes színminták +@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 a dokumentum színmintái {_1} színt tartalmaznak; a document színcsökkentés legfeljebb 255-öt fogad +@msg the editing data's hash has no place a szerkesztési adatok kivonatának nincs helye +@msg the entities the document declares expand to too much text a dokumentumban deklarált entitások túl sok szöveggé bomlanak ki +@msg the envelope's content has no bounds a burkoló tartalmának nincsenek határai +@msg the envelope's mesh is malformed a burkoló hálója hibás szerkezetű +@msg the file holds no `variableSets` element a fájlban nincs `variableSets` elem +@msg the file is larger than {_1} MB a fájl nagyobb, mint {_1} MB +@msg the file is too large a fájl túl nagy +@msg the file isn't valid SVG: {e} a fájl nem érvényes SVG: {e} +@msg the file names no plate a fájl nem nevez meg színkivonatot +@msg the file was updated or encrypted already a fájl már frissítve vagy titkosítva lett +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) a sor első karaktere előtt nincs karakter, amelyhez alávágható lenne: csak felfelé és lefelé mozdul (dy) +@msg the font {_1} is not available ez a betűtípus nem érhető el: {_1} +@msg the format has no file extension a formátumnak nincs fájlkiterjesztése +@msg the function's prototype is not an object a függvény prototípusa nem objektum +@msg the graphic can't be read: {e} a grafika nem olvasható: {e} +@msg the graphic is empty a grafika üres +@msg the graphic's data is damaged a grafika adatai sérültek +@msg the graphic's document has no artboard a grafika dokumentumában nincs rajztábla +@msg the guide is hidden or locked a segédvonal rejtett vagy zárolt +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them a(z) `{name}` változó `{cell}` fejléce a(z) `{other}` változóként olvasódna vissza: nevezd át az egyiket +@msg the image `{path}` isn't followed: {r} a kép nem lesz követve: `{path}`: {r} +@msg the image has no embedded pixels a képnek nincsenek beágyazott képpontjai +@msg the image is empty a kép üres +@msg the image is fully transparent a kép teljesen átlátszó +@msg the image is not on an artboard; pass rect a kép nincs rajztáblán; add meg a rect értéket +@msg the image's pixels are missing a kép képpontjai hiányoznak +@msg the image's pixels aren't in the document a kép képpontjai nincsenek a dokumentumban +@msg the image's pixels can't be read: {e} a kép képpontjai nem olvashatók: {e} +@msg the image's transparency doesn't fit it a kép átlátszósága nem illik a képhez +@msg the job would print more than {MAX_PAGES} pages a feladat több mint {MAX_PAGES} oldalt nyomtatna +@msg the job would print more than {_1} pages a feladat több mint {_1} oldalt nyomtatna +@msg the layers hold no art to mask a rétegekben nincs maszkolható grafika +@msg the layout moved az elrendezés elmozdult +@msg the library has a group `{name}` already a könyvtárban már van ilyen csoport: `{name}` +@msg the library has no group `{name}` a könyvtárban nincs ilyen csoport: `{name}` +@msg the library has no item `{item}` a könyvtárban nincs ilyen elem: `{item}` +@msg the library has no item `{other}` a könyvtárban nincs ilyen elem: `{other}` +@msg the library holds {MAX_GROUPS} groups already a könyvtár már {MAX_GROUPS} csoportot tartalmaz +@msg the library holds {MAX_ITEMS} items of this kind already a könyvtár már {MAX_ITEMS} ilyen fajta elemet tartalmaz +@msg the library's file is outside the read roots a könyvtár fájlja kívül esik az olvasási gyökereken +@msg the line is too long, or the paths it passes too many, to check a vonal túl hosszú, vagy túl sok útvonalon halad át az ellenőrzéshez +@msg the method needs an XML value a metódushoz XML-érték kell +@msg the name is empty a név üres +@msg the object has no fill, stroke or effect az objektumnak nincs kitöltése, körvonala vagy effektusa +@msg the object has no graphic style (pass `name`) az objektumnak nincs grafikai stílusa (add meg a `name` értéket) +@msg the objects don't match the plan az objektumok nem egyeznek a tervvel +@msg the open and permissions passwords must differ a megnyitási és a jogosultsági jelszónak különböznie kell +@msg the operand stack overflowed az operandusverem túlcsordult +@msg the page has no area az oldalnak nincs területe +@msg the page's type in an unread object's box isn't in its page art az oldal egy be nem olvasott objektum keretében lévő szövege nincs benne az oldal grafikájában +@msg the paint behind the proxy is not a gradient a mintadoboz mögötti festés nem színátmenet +@msg the paint is not a freeform gradient (apply one with paint.editGradient {kind}) a festés nem szabad formájú színátmenet (alkalmazz egyet a paint.editGradient {kind} paranccsal) +@msg the parameters or body do not form one function a paraméterek vagy a törzs nem alkotnak egyetlen függvényt +@msg the parent must be a layer or sublayer a szülő réteg vagy alréteg kell legyen +@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it a beillesztett SVG-szűrő ({name}) másképp rajzol, mint a dokumentum azonos nevű szűrője, és nem jut neki szabad név +@msg the path can't be extended az útvonal nem hosszabbítható meg +@msg the path can't be inside one of the objects to attach az útvonal nem lehet az egyik csatolandó objektum belsejében +@msg the path has no segments to measure az útvonalnak nincs mérhető szakasza +@msg the path is empty az útvonal üres +@msg the path is too short to shorten that much az útvonal túl rövid ahhoz, hogy ennyivel rövidíthető legyen +@msg the paths are too many or too tangled to find where they cross túl sok az útvonal, vagy túlságosan összegabalyodtak ahhoz, hogy a metszéspontjaik megtalálhatók legyenek +@msg the pen table holds more than 255 pens a tolltábla több mint 255 tollat tartalmaz +@msg the picture has no objects az ábrán nincsenek objektumok +@msg the plates can't be kept a színkivonatok nem tarthatók meg +@msg the position table does not match the instructions a pozíciótábla nem egyezik az utasításokkal +@msg the program runs too long a program túl sokáig fut +@msg the program uses too much memory a program túl sok memóriát használ +@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes a bekérés szövege hosszabb {MAX_PROMPT_BYTES} bájtnál +@msg the radix must be between 2 and 36 a számrendszer alapja 2 és 36 között lehet +@msg the rectangle must have a width and a height a téglalapnak szélesség és magasság kell +@msg the rectangle needs a size and must lie on the canvas a téglalapnak méret kell, és a vásznon kell lennie +@msg the rectangle or line reaches too many paths to check a téglalap vagy a vonal túl sok útvonalat ér el az ellenőrzéshez +@msg the right side of 'in' is not an object az 'in' jobb oldala nem objektum +@msg the right side of 'instanceof' is not a function az 'instanceof' jobb oldala nem függvény +@msg the selected art has no width a kijelölt grafikának nincs szélessége +@msg the selected art is nested more than {MAX_DEPTH} levels deep a kijelölt grafika {MAX_DEPTH} szintnél mélyebben van egymásba ágyazva +@msg the selected art is too large to measure a kijelölt grafika túl nagy a méréshez +@msg the selection draws nothing a kijelölés semmit sem rajzol ki +@msg the selection has no area a kijelölésnek nincs területe +@msg the selection has no art to export a kijelölésben nincs exportálható grafika +@msg the selection has no bounds a kijelölésnek nincsenek határai +@msg the selection has no graphic style a kijelölésnek nincs grafikai stílusa +@msg the selection has no outlines to weed around a kijelölésnek nincs körvonala, amely körül gyomlálni lehetne +@msg the slice would leave the canvas a szelet kikerülne a vászonról +@msg the spot colour couldn't be added a direktszínt nem sikerült hozzáadni +@msg the subpath has no ends to extend (it is closed or a single point) a részútvonalnak nincs meghosszabbítható vége (zárt, vagy egyetlen pontból áll) +@msg the symbols are nested too deeply to expand their instances a szimbólumok túl mélyen vannak egymásba ágyazva a példányaik kibontásához +@msg the text file is too large to place as type (at most {_1} MB) a szövegfájl túl nagy ahhoz, hogy szövegként elhelyezhető legyen (legfeljebb {_1} MB) +@msg the text is empty a szöveg üres +@msg the text is over {_1} MB a szöveg nagyobb, mint {_1} MB +@msg the top object must be a path a legfelső objektum útvonal kell legyen +@msg the trace was cancelled a vektorizálás megszakítva +@msg the trailer has no size a trailernek nincs mérete +@msg the type object is flattened to nothing a szövegobjektumból a lapítás után semmi sem marad +@msg the value bind was called on is not a function az érték, amelyen a bind hívása történt, nem függvény +@msg the value is not a constructor az érték nem konstruktor +@msg the value is not a function az érték nem függvény +@msg the value is not a valid XML name az érték nem érvényes XML-név +@msg the value is not an XML attribute name az érték nem XML-attribútumnév +@msg the value is not an XML name az érték nem XML-név +@msg the value of axis `{tag}` must be a number or null a(z) `{tag}` tengely értéke szám vagy null lehet +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets a változókönyvtár több mint {MAX_LIBRARY_DATASETS} adatkészletet deklarál +@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities a változókönyvtár több mint {MAX_LIBRARY_ENTITIES} entitást deklarál +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables a változókönyvtár több mint {MAX_LIBRARY_VARIABLES} változót deklarál +@msg the variable library declares no variables a változókönyvtár nem deklarál változókat +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes a változókönyvtár nagyobb, mint {MAX_LIBRARY_BYTES} bájt +@msg the variable library is not UTF-8 a változókönyvtár nem UTF-8 kódolású +@msg the variable library is not well-formed XML: {e} a változókönyvtár nem jól formált XML: {e} +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep a változókönyvtár elemei {MAX_LIBRARY_NESTING} szintnél mélyebben vannak egymásba ágyazva +@msg the variable library's document type declaration holds something other than entities of plain text a változókönyvtár dokumentumtípus-deklarációja mást is tartalmaz, mint egyszerű szöveges entitásokat +@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes a változókönyvtár entitásai kibontva nagyobbak, mint {MAX_LIBRARY_BYTES} bájt +@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines a gyomlálóvonalak túl sokszor metszik a grafika körvonalait a méréshez; használj kevesebb vonalat +@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap a gyomlálóvonalak túl sok körvonalszakaszhoz haladnak túl közel a méréshez; használj kevesebb vonalat vagy kisebb hézagot +@msg the weeding lines would be more than {max_pieces} pieces a gyomlálóvonalak több mint {max_pieces} darabból állnának +@msg the window has no dock az ablaknak nincs dokkja +@msg the written PDF has no catalog a kiírt PDF-nek nincs katalógusa +@msg the written PDF has no cross-reference table a kiírt PDF-nek nincs kereszthivatkozási táblája +@msg the written PDF's metadata can't be edited a kiírt PDF metaadatai nem szerkeszthetők +@msg the {_1} plate lies outside the file ez a színkivonat a fájlon kívül esik: {_1} +@msg there are no colour swatches to draw nincs kirajzolható színes színminta +@msg there is already a design named `{name}` már van ilyen nevű diagramminta: `{name}` +@msg there is already a design named `{to}` már van ilyen nevű diagramminta: `{to}` +@msg there is already a toolbar named `{name}` már van ilyen nevű eszköztár: `{name}` +@msg there is already a toolbar named `{to}` már van ilyen nevű eszköztár: `{to}` +@msg there is no library: create one first nincs könyvtár: előbb hozz létre egyet +@msg these paths are too degenerate for Shaper ezek az útvonalak túlságosan elfajultak az Alakformálóhoz +@msg this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that ez bináris DXF-fájl, amely nem olvasható: mentsd ASCII DXF-ként a CAD-alkalmazásból, és azt nyisd meg +@msg this warp's points can't be edited: use Reset with Mesh first ennek a hajlításnak a pontjai nem szerkeszthetők: előbb használd a Visszaállítás hálóval parancsot +@msg thumbnail: no page {v} (the {what} has {count}) bélyegkép: nincs ilyen oldal: {v} (a(z) {what} oldalszáma: {count}) +@msg tileRange of {what}: {e} tileRange ({what}): {e} +@msg title can't be empty title: nem lehet üres +@msg to (the new name) is required to (az új név): kötelező +@msg to must be fill or stroke to: fill vagy stroke kell +@msg to must be smooth or corner to: smooth vagy corner kell +@msg toExponential() digits must be between 0 and 20 toExponential(): a számjegyek száma 0 és 20 között lehet +@msg toFixed() digits must be between 0 and 20 toFixed(): a számjegyek száma 0 és 20 között lehet +@msg toISOString is not a function a toISOString nem függvény +@msg toPrecision() argument must be between 1 and 21 toPrecision(): az argumentum 1 és 21 között lehet +@msg toSource: a getter cannot be written as source toSource: getter nem írható ki forrásként +@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: nem írható ki forrásként az a tömb, amelynek az elemein kívül tulajdonságai is vannak, nem felsorolható tulajdonsága van, vagy más a prototípusa +@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: nem írható ki forrásként az az objektum, amelynek nem felsorolható tulajdonsága van, vagy a prototípusa nem Object.prototype +@msg toSource: the source is too long toSource: a forrás túl hosszú +@msg toSource: the value contains itself toSource: az érték önmagát tartalmazza +@msg toSource: the value has too many entries toSource: az érték túl sok elemet tartalmaz +@msg toSource: the value is nested too deep toSource: az érték túl mélyen van egymásba ágyazva +@msg toString is not a function a toString nem függvény +@msg too large for a zip archive: package to a folder túl nagy egy zip-archívumhoz: csomagolj mappába +@msg too many Shaper edits túl sok Alakformáló-szerkesztés +@msg too many `points` (at most 100000) túl sok `points` elem (legfeljebb 100000) +@msg too many arguments túl sok argumentum +@msg too many characters: at most {MAX_CHARS} túl sok karakter: legfeljebb {MAX_CHARS} +@msg too many dots for this size: lower the frequency túl sok raszterpont ehhez a mérethez: csökkentsd a vonalsűrűséget +@msg too many filters over one file túl sok szűrő egyetlen fájlon +@msg too many overlapping paths for Shaper túl sok egymást átfedő útvonal az Alakformálóhoz +@msg too many quantifiers túl sok kvantor +@msg too many saved clips túl sok mentett vágóútvonal +@msg too many saved graphics states túl sok mentett grafikus állapot +@msg toolbar (a toolbar's name) must be a string toolbar (egy eszköztár neve): karakterlánc kell +@msg toolbar name can't be empty az eszköztár neve nem lehet üres +@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (ez az alapértelmezés) vagy "omit" +@msg two saved selections would be named `{_1}` két mentett kijelölés neve lenne ugyanaz: `{_1}` +@msg type dimension cannot be represented with this text transform a szövegméret nem ábrázolható ezzel a szövegtranszformációval +@msg type dimension must be finite a szövegméret véges érték kell legyen +@msg unexpected resources váratlan erőforrások +@msg unknown CMYK profile `{name}` ismeretlen CMYK-profil: `{name}` +@msg unknown File Info field `{k}` ismeretlen fájlinformációs mező: `{k}` +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) ismeretlen OpenType-funkció: `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; a - előtag kikapcsolja; a swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom és aalt egy alternatíva számát `tag=N` alakban fogadja, ahol N 0 és {_1} között lehet, például `salt=2`) +@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) ismeretlen OpenType-funkció a featureChanges értékben: `{t}` (a `features` által fogadott címkék használhatók) +@msg unknown PDF preset `{name}` (presets: {_1}) ismeretlen PDF-előbeállítás: `{name}` (előbeállítások: {_1}) +@msg unknown Warp style ismeretlen hajlítási stílus +@msg unknown alignToPath `{a}` ismeretlen alignToPath: `{a}` +@msg unknown arrowhead {v} ismeretlen nyílhegy: {v} +@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) ismeretlen artboardLayout kulcs: `{k}` (layout, columns, spacing, rightToLeft) +@msg unknown attribute `{attribute}` ismeretlen attribútum: `{attribute}` +@msg unknown attribute `{k}` ismeretlen attribútum: `{k}` +@msg unknown axis `{other}` (vertical|horizontal|free) ismeretlen tengely: `{other}` (vertical|horizontal|free) +@msg unknown blend mode `{b}` ismeretlen keverési mód: `{b}` +@msg unknown blend mode {b} ismeretlen keverési mód: {b} +@msg unknown brush type `{o}` ismeretlen ecsettípus: `{o}` +@msg unknown category `{c}` ismeretlen kategória: `{c}` +@msg unknown category `{c}` ({_1}) ismeretlen kategória: `{c}` ({_1}) +@msg unknown character `{c}` ismeretlen karakter: `{c}` +@msg unknown character style number field ismeretlen karakterstílus-számmező +@msg unknown dither `{d}` (diffusion, pattern, noise or none) ismeretlen dither: `{d}` (diffusion, pattern, noise vagy none) +@msg unknown effect `{e}` ismeretlen effektus: `{e}` +@msg unknown end {e} ismeretlen end: {e} +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) ismeretlen flattenerPreset: `{name}` (High Resolution, Medium Resolution, Low Resolution vagy egy mentett előbeállítás: lásd: flattener.presets.list) +@msg unknown format `{f}` (vcswatches, gpl, ase or css) ismeretlen formátum: `{f}` (vcswatches, gpl, ase vagy css) +@msg unknown from {f} ismeretlen from: {f} +@msg unknown graph type `{t}` ismeretlen diagramtípus: `{t}` +@msg unknown grid colours `{name}` (one of {_1}) ismeretlen rácsszínek: `{name}` (ezek egyike kell: {_1}) +@msg unknown gridType `{t}` ismeretlen gridType: `{t}` +@msg unknown highlight `{v}` ismeretlen highlight: `{v}` +@msg unknown intent `{i}` ismeretlen intent: `{i}` +@msg unknown issue kind {id} ismeretlen problémafajta: {id} +@msg unknown issue kind {v} ismeretlen problémafajta: {v} +@msg unknown knockout state {k} ismeretlen kiütési állapot: {k} +@msg unknown language `{language}` (one of {_1}) ismeretlen nyelv: `{language}` (ezek egyike kell: {_1}) +@msg unknown language `{l}` (one of {_1}) ismeretlen nyelv: `{l}` (ezek egyike kell: {_1}) +@msg unknown method `{m}` ({_1}) ismeretlen módszer: `{m}` ({_1}) +@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) ismeretlen mód: `{other}` (gray, rgb, hsb, lab, cmyk vagy web) +@msg unknown mode `{other}` (mono|cmyk) ismeretlen mód: `{other}` (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) ismeretlen mód: `{other}` (rgb|cmyk|gray|global) +@msg unknown mode `{other}` (set, add, subtract) ismeretlen mód: `{other}` (set, add, subtract) +@msg unknown mode `{o}` ismeretlen mód: `{o}` +@msg unknown mode {m} ismeretlen mód: {m} +@msg unknown model `{other}` ismeretlen modell: `{other}` +@msg unknown model `{other}` (keep, new or document) ismeretlen modell: `{other}` (keep, new vagy document) +@msg unknown op {o} ismeretlen op: {o} +@msg unknown order `{o}`: toggle, front, forward, backward or back ismeretlen order: `{o}`; toggle, front, forward, backward vagy back lehet +@msg unknown overprints `{v}` (preserve, simulate or discard) ismeretlen overprints: `{v}` (preserve, simulate vagy discard) +@msg unknown panel ismeretlen panel +@msg unknown panel `{panel}` ismeretlen panel: `{panel}` +@msg unknown parameter `{key}` ismeretlen paraméter: `{key}` +@msg unknown parameter `{within}settings.{format}`; {why} ismeretlen paraméter: `{within}settings.{format}`; {why} +@msg unknown parameter `{within}{key}`; {why} ismeretlen paraméter: `{within}{key}`; {why} +@msg unknown pieLegend `{v}` (none, standard or wedges) ismeretlen pieLegend: `{v}` (none, standard vagy wedges) +@msg unknown piePosition `{v}` (even, ratio or stacked) ismeretlen piePosition: `{v}` (even, ratio vagy stacked) +@msg unknown pieSort `{v}` (none, all or first) ismeretlen pieSort: `{v}` (none, all vagy first) +@msg unknown plate `{n}` ismeretlen színkivonat: `{n}` +@msg unknown preference `{k}` ismeretlen beállítás: `{k}` +@msg unknown profile {id} (see stroke.widthProfile.list) ismeretlen profil: {id} (lásd: stroke.widthProfile.list) +@msg unknown proof target `{t}` ismeretlen proofcél: `{t}` +@msg unknown quotes key `{k}` (double, single) ismeretlen quotes kulcs: `{k}` (double, single) +@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) ismeretlen színcsökkentés: `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform vagy document) +@msg unknown resolution `{v}` ismeretlen felbontás: `{v}` +@msg unknown rule `{rule}` ({_1}) ismeretlen szabály: `{rule}` ({_1}) +@msg unknown rule `{r}` (see color.harmony) ismeretlen szabály: `{r}` (lásd: color.harmony) +@msg unknown setting `{k}` ismeretlen beállítás: `{k}` +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) ismeretlen alakzat: `{v}` (circle|ellipse|square|diamond|line) +@msg unknown spacing `{o}` (smooth|steps|distance) ismeretlen spacing: `{o}` (smooth|steps|distance) +@msg unknown tileType `{t}` ({_1}) ismeretlen tileType: `{t}` ({_1}) +@msg unknown units ismeretlen mértékegység +@msg unknown units `{u}` ismeretlen mértékegység: `{u}` +@msg unknown value axis `{v}` (left, right or both) ismeretlen értéktengely: `{v}` (left, right vagy both) +@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) ismeretlen variáció: `{v}` (tintsShades|warmCool|vividMuted) +@msg unknown warp style `{s}` ismeretlen hajlítási stílus: `{s}` +@msg unknown {key} `{v}` (none, short or full) ismeretlen {key}: `{v}` (none, short vagy full) +@msg unknown {key} profile `{n}` ismeretlen {key} profil: `{n}` +@msg unknown {k} key `{key}` (size, position) ismeretlen {k} kulcs: `{key}` (size, position) +@msg unmatched ')' pár nélküli ')' +@msg unreadable object olvashatatlan objektum +@msg unsafe destination file name nem biztonságos célfájlnév +@msg unterminated character class lezáratlan karakterosztály +@msg unterminated group lezáratlan csoport +@msg use paths or compound paths útvonalakat vagy összetett útvonalakat használj +@msg useArtboards must be true or false, not {v} useArtboards: true vagy false kell, ez nem az: {v} +@msg value for `{var}` must match its variable kind a(z) `{var}` értékének meg kell felelnie a változó típusának +@msg variable `{_1}` is already defined a változó már definiálva van: `{_1}` +@msg variable `{name}` has no `trait` a(z) `{name}` változónak nincs `trait` értéke +@msg variable `{name}` is already defined a változó már definiálva van: `{name}` +@msg variable `{name}` is declared twice a változó kétszer van deklarálva: `{name}` +@msg variable `{to}` already exists már van ilyen változó: `{to}` +@msg view is required a view kötelező +@msg view must be one of {_1} view: ezek egyike kell: {_1} +@msg warpStyle can only edit an applied Warp effect warpStyle: csak már alkalmazott Hajlítás effektust szerkeszthet +@msg width and height must be more than 0 and at most {limit} width és height: 0-nál nagyobb és legfeljebb {limit} érték kell +@msg width factors must be finite a szélességszorzók véges értékek legyenek +@msg width must be positive width: pozitív érték kell +@msg width out of range a szélesség tartományon kívül esik +@msg width/height must be > 0 width/height: > 0 érték kell +@msg wordGap and lineGap need area type in the selection wordGap és lineGap: területszöveg kell a kijelölésben +@msg x and y must be finite numbers x és y: véges számok kellenek +@msg x and y must be given together x és y: együtt kell megadni +@msg xpath needs its variables as an object xpath: a változóit objektumként kell megadni +@msg xpath needs the expression as a string xpath: a kifejezést karakterláncként kell megadni +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone: anchor is kell hozzá; dokkolj egy panel mellé az anchor értékkel, vagy új dokkoszlopba a side értékkel (Start vagy End) +@msg zoom must be positive zoom: pozitív érték kell +@msg {C}: no effect at index {list} {C}: nincs effektus ennél az indexnél: {list} +@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} védett, megmarad +@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} védett: mentsd a beállításokat más néven +@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED}, a beállításai és a gyorsítótára pedig nem helyezhetők vissza innen: {_1}, ide: {_2}: {e} +@msg {LABEL} produced an empty result {LABEL}: az eredmény üres +@msg {_1} MB of document, at most {_2} MB a dokumentum {_1} MB, legfeljebb {_2} MB lehet +@msg {_1} MB of images, at most {_2} MB a képek összesen {_1} MB, legfeljebb {_2} MB lehet +@msg {_1} bytes of RGBA data for a {width} × {height} image {_1} bájt RGBA-adat egy {width} × {height} méretű képhez +@msg {_1} can't be read, or changed since it was read: relink or update it first {_1}: nem olvasható, vagy a beolvasása óta megváltozott; előbb csatold újra vagy frissítsd +@msg {_1} can't go into {into} {_1}: nem kerülhet ide: {into} +@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1}: nem korlátozhatja a nyomtatást kis felbontásra; válassz PDF 1.4 vagy újabb verziót, vagy engedélyezd a nagy felbontású nyomtatást +@msg {_1} files can't be opened yet ezek a fájlok még nem nyithatók meg: {_1} +@msg {_1} files can't be password-protected: remove the passwords or choose no standard {_1} fájlok nem védhetők jelszóval: távolítsd el a jelszavakat, vagy ne válassz szabványt +@msg {_1} files can't be {_2} {_1} fájlokra ez nem lehetséges: {_2} +@msg {_1} files can't carry editing data: turn off preserveEditing {_1} fájlok nem tartalmazhatnak szerkesztési adatokat: kapcsold ki a preserveEditing beállítást +@msg {_1} files can't have PDF layers: turn off createLayers {_1} fájlokban nem lehetnek PDF-rétegek: kapcsold ki a createLayers beállítást +@msg {_1} files need a creation date, and this platform has no clock to give one {_1} fájlokhoz létrehozási dátum kell, ezen a platformon pedig nincs óra, amely megadná +@msg {_1} is not a folder: {e} nem mappa: {_1}: {e} +@msg {_1} isn't a path nem útvonal: {_1} +@msg {_1} options: {e} {_1} beállításai: {e} +@msg {_1} paints nothing to rasterize {_1}: semmi raszterizálhatót nem fest +@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) {_1}: a jelszavak legfeljebb {MAX_LEGACY_PASSWORD} ASCII-karakterből állhatnak (a PDF 1.7 és az újabb verziók bármilyet elfogadnak) +@msg {_1} with linked images or an image map writes several files: use document.export {_1}: csatolt képekkel vagy képtérképpel több fájlt ír ki; használd a document.export parancsot +@msg {_1} writes one file per artboard here: use document.export, or name one artboard {_1}: itt rajztáblánként egy fájlt ír ki; használd a document.export parancsot, vagy nevezz meg egy rajztáblát +@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} képpont túl nagy ehhez: {_3} (oldalanként legfeljebb {max} képpont); csökkentsd a léptéket +@msg {_1}: belongs to another account {_1}: másik fiókhoz tartozik +@msg {_1}: not a folder of its own {_1}: nem saját mappa +@msg {_1}: not a plain file {_1}: nem egyszerű fájl +@msg {_1}: {_2} and {_3} are in the way {_1}: útban van: {_2} és {_3} +@msg {code}: {0} {code}: {0} +@msg {dest}: {e} {dest}: {e} +@msg {dir}: {e} {dir}: {e} +@msg {doc} has no folder nincs mappája: {doc} +@msg {file} isn't linked nincs csatolva: {file} +@msg {file} names more than one linked JavaScript file {file}: egynél több csatolt JavaScript-fájlt nevez meg +@msg {folder} is not a package folder nem csomagmappa: {folder} +@msg {folder}: more than {max} files and folders {folder}: több mint {max} fájl és mappa +@msg {folder}: {e} {folder}: {e} +@msg {g} is not a Live Paint group nem Élő festés csoport: {g} +@msg {id} is not a slice nem szelet: {id} +@msg {key} must be 0–{MAX_ADD_AROUND} pt {key}: 0–{MAX_ADD_AROUND} pt kell +@msg {key} must be [x,y] {key}: [x,y] kell +@msg {key} must be a percentage {min}..100 {key}: százalék kell, {min}..100 +@msg {key} must be a positive number, not {n} {key}: pozitív szám kell, ez nem az: {n} +@msg {key} must be a positive number, not {v} {key}: pozitív szám kell, ez nem az: {v} +@msg {key} must be one of {_1} {key}: ezek egyike kell: {_1} +@msg {key} must be {_1}, got {s} {key}: ez kell: {_1}; kapott érték: {s} +@msg {key} must name a floating panel {key}: lebegő panelt kell megneveznie +@msg {key} must name a panel in another group {key}: másik csoportban lévő panelt kell megneveznie +@msg {key} must name a panel that shows {key}: látható panelt kell megneveznie +@msg {key} must name a registered panel {key}: regisztrált panelt kell megneveznie +@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} fájlokban nincs átlátszóság, a fájlban viszont ez lenne: {what}; előbb lapítsd (object.flattenTransparency) +@msg {kind} images can't be opened (see document.formats) {kind} képek nem nyithatók meg (lásd: document.formats) +@msg {k} `{v}` is not one of the choices {k} `{v}`: nem a választási lehetőségek egyike +@msg {k} has control characters{_1} {k}: vezérlőkaraktereket tartalmaz{_1} +@msg {k} is too long (at most {MAX_TEXT} characters) {k}: túl hosszú (legfeljebb {MAX_TEXT} karakter) +@msg {k} must be > 0 {k}: > 0 érték kell +@msg {k} must be a number (pt) or a length such as "210 mm" {k}: szám (pt) vagy hosszúság kell, például "210 mm" +@msg {k} must be a number from {min} to {max} {k}: {min} és {max} közötti szám kell +@msg {k} must be a number or "auto" {k}: szám vagy "auto" kell +@msg {k} must be a string {k}: karakterlánc kell +@msg {k} must be text {k}: szöveg kell +@msg {k} must be true or false {k}: true vagy false kell +@msg {k} must be {{size?, position?}} {k}: {{size?, position?}} kell +@msg {k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip {k}: az egyéni nyílhegy útvonalának kitölthető terület kell, legfeljebb {most} horgonyponttal és {most} részútvonallal, és minden horgonypont legfeljebb {far} nyílhegyegységre lehet a csúcsától +@msg {k}: bad custom arrowhead path: {e} {k}: hibás egyéni nyílhegyútvonal: {e} +@msg {k}: bad custom arrowhead: {e} {k}: hibás egyéni nyílhegy: {e} +@msg {k}: can't read `{s}` as a length {k}: nem olvasható hosszúságként: `{s}` +@msg {k}: custom must be a name {k}: custom: név kell +@msg {label} must be a number at most {max} {label}: legfeljebb {max} értékű szám kell +@msg {label} must be a number from -1296 to 1296 {label}: -1296 és 1296 közötti szám kell +@msg {label} must be a number from 1 to 10000 {label}: 1 és 10000 közötti szám kell +@msg {name} `{s}`: {ids} {name} `{s}`: {ids} +@msg {name} is built in and can't be deleted {name}: beépített elem, nem törölhető +@msg {name} must be a number {name}: szám kell +@msg {name} must be {lo}–{hi}{unit} (got {v}) {name}: {lo}–{hi}{unit} kell (kapott érték: {v}) +@msg {name}: CRC or size mismatch {name}: a CRC vagy a méret nem egyezik +@msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: ennek az Affinity-fájlnak csak a beágyazott előnézete volt olvasható; a Fájl › Megnyitás paranccsal megnézheted a figyelmeztetésével együtt, vagy az elhelyezés előtt exportálj SVG-t vagy PDF-et az Affinityből +@msg {name}: {e} {name}: {e} +@msg {native}; its embedded preview can't be read either ({e}) {native}; a beágyazott előnézete sem olvasható ({e}) +@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: a copy csak akkor fogad DCS-fájlt, ha a színkivonatai benne vannak; ott csatold újra, ahol a színkivonatfájljai vannak +@msg {path}: larger than {_1} MB {path}: nagyobb, mint {_1} MB +@msg {shown} is not an XPath 1.0 function nem XPath 1.0-függvény: {shown} +@msg {std} files are CMYK, and the file would have colours in {_1} {std} fájlok CMYK színmódúak, a fájlban viszont ilyen színek is lennének: {_1} +@msg {std} files embed their fonts, and the file would have one that isn't {std} fájlok beágyazzák a betűtípusaikat, a fájlban viszont lenne olyan, amely nincs beágyazva +@msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway {target}: ebből a fájlból lett beolvasva ez a dokumentum, és a beolvasás során egyes részek kimaradtak ({what}): a felülírásával ezek végleg elvesznének. Írj inkább másik fájlt, vagy add meg az acknowledgeLoss: true értéket, ha mégis le akarod cserélni +@msg {ty} brushes need selected art (or params.{k}) {ty} ecsetekhez kijelölt grafika kell (vagy params.{k}) +@msg {v} is not an asset id nem elemazonosító: {v} +@msg {v} names no layer or sublayer {v}: nem nevez meg réteget vagy alréteget +@msg {what} can't be intertwined nem fonható össze: {what} +@msg {what} doesn't fit the paper with its bleed and marks {what}: a kifutóval és a jelekkel együtt nem fér el a papíron +@msg {what} is not a function nem függvény: {what} +@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what}: egy ívre több mint {MAX_COPIES_PER_SHEET} példány kerülne; növeld a méretét +@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what}: több mint {MAX_TILES} csempére kerülne nyomtatásra; csökkentsd a méretét +@msg {what} {i} out of range {what} {i}: tartományon kívül esik +@msg `{key}` must be from 0.01 to 1000 pt `{key}`: 0.01 és 1000 pt közötti érték kell +@msg select the banner, or mark every artboard jelöld ki a molinót, vagy jelöld meg az összes rajztáblát +@msg the banner has no width or height a molinónak nincs szélessége vagy magassága +@msg the grommet inset is too large for the banner a ringlik széltől mért távolsága túl nagy a molinóhoz +@msg the hem is too deep for the banner a szegés túl mély a molinóhoz +@msg the spacing makes more than {MAX_GROMMETS} grommets ezzel a térközzel több mint {MAX_GROMMETS} ringli keletkezne +@msg there is no banner to mark nincs megjelölhető molinó +@msg turn on at least one side kapcsolj be legalább egy oldalt +@msg with no hem and no grommets there is nothing to mark szegés és ringlik nélkül nincs mit megjelölni +@msg {saving} failed: {e} {saving}: sikertelen: {e} # 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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f832a5baf3..6743704785 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -8935,6 +8935,134 @@ breakText Glyphs Glifi @msg {what} can't be intertwined {what} non può essere intrecciato @msg {what} doesn't fit the paper with its bleed and marks {what} non entra nella carta con l'abbondanza e i segni @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} verrebbe stampato su più di {MAX_TILES} riquadri: riducilo +# 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 Gli effetti 3D richiedono coordinate finite entro 100 milioni di punti +@msg 3D effects require one to ten lights Gli effetti 3D richiedono da una a dieci luci +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Profondità BMP {_1}: 1, 4, 8, 16, 24 o 32 bit per pixel +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Codifica BMP non riuscita: {width} × {height} pixel non sono compatibili con il formato o con il buffer dei pixel +@msg GIF encoding failed: the palette image is malformed Codifica GIF non riuscita: l'immagine con palette non è valida +@msg GIF encoding failed: {e} Codifica GIF non riuscita: {e} +@msg JPEG colour profile: {e} Profilo colore JPEG: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size Codifica JPEG non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg JPEG encoding failed: {e} Codifica JPEG non riuscita: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size Codifica PNG non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg PNG encoding failed: the palette image is malformed Codifica PNG non riuscita: l'immagine con palette non è valida +@msg PNG encoding failed: {e} Codifica PNG non riuscita: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Codifica PNG non riuscita: {width} × {height} pixel non è una dimensione valida per un'immagine +@msg PSD colour profile: {e} Profilo colore PSD: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size Codifica PSD non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg Revolve profile contains invalid coordinates Rivoluzione: il profilo contiene coordinate non valide +@msg Revolve profile is too complex Rivoluzione: il profilo è troppo complesso +@msg Revolve projection is not finite Rivoluzione: la proiezione non è finita +@msg Revolve requires finite profile coordinates within 100 million points Rivoluzione richiede coordinate del profilo finite entro 100 milioni di punti +@msg Revolve surface is too complex; reduce Segments or simplify the profile Rivoluzione: la superficie è troppo complessa; riduci Segmenti o semplifica il profilo +@msg Revolve surface is too complex; simplify the profile Rivoluzione: la superficie è troppo complessa; semplifica il profilo +@msg Revolve visibility cannot split the projected boundary Rivoluzione: il calcolo della visibilità non può dividere il contorno proiettato +@msg Revolve visibility creates too many fragments Rivoluzione: il calcolo della visibilità crea troppi frammenti +@msg Revolve visibility creates too many vector anchors Rivoluzione: il calcolo della visibilità crea troppi punti di ancoraggio vettoriali +@msg Revolve visibility exceeds its geometry work limit Rivoluzione: il calcolo della visibilità supera il suo limite di lavoro geometrico +@msg Revolve visibility exceeds its spatial index limit Rivoluzione: il calcolo della visibilità supera il suo limite di indice spaziale +@msg Revolve visibility has too many boundary crossings Rivoluzione: il calcolo della visibilità ha troppi attraversamenti del contorno +@msg Revolve visibility has too many input anchors Rivoluzione: il calcolo della visibilità ha troppi punti di ancoraggio in ingresso +@msg Revolve visibility requires convex cap sectors Rivoluzione: il calcolo della visibilità richiede settori convessi delle basi +@msg Revolve visibility requires finite projected faces Rivoluzione: il calcolo della visibilità richiede facce proiettate finite +@msg SVG options: {e} Opzioni SVG: {e} +@msg Save for Web settings: {e} Impostazioni di Salva per il web: {e} +@msg TIFF colour profile: {e} Profilo colore TIFF: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size Codifica TIFF non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg TIFF encoding failed: {e} Codifica TIFF non riuscita: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Profondità Targa {_1}: 16, 24 o 32 bit per pixel +@msg Targa encoding failed: the pixel buffer doesn't match the image size Codifica Targa non riuscita: il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg The 3D projection is not finite La proiezione 3D non è finita +@msg The 3D shadow is too complex; turn Shadows off or simplify the art L'ombra 3D è troppo complessa; disattiva Ombre o semplifica la grafica +@msg The shape contains invalid coordinates La forma contiene coordinate non valide +@msg Too many Revolve faces for visibility evaluation Troppe facce di Rivoluzione per il calcolo della visibilità +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft legge al massimo {ASE_MAX_ENTRIES} colori e gruppi da un file di scambio campioni, e questa libreria ne ha {entries} +@msg WebP encoding failed: {e} Codifica WebP non riuscita: {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` è smallThumb, mediumThumb, largeThumb, smallList o largeList +@msg a line can't run from a point to itself una linea non può andare da un punto a sé stesso +@msg a line needs at least two points una linea richiede almeno due punti +@msg a number cannot be read impossibile leggere un numero +@msg a swatch exchange block is too long un blocco del file di scambio campioni è troppo lungo +@msg a swatch exchange name is too long un nome nel file di scambio campioni è troppo lungo +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels un'immagine di {sw} × {sh} non può essere ridimensionata a {width} × {height} pixel +@msg a {width} × {height} image is too large un'immagine di {width} × {height} è troppo grande +@msg a {width} × {height} image: {e} un'immagine di {width} × {height}: {e} +@msg an image could not be written: {e} impossibile scrivere un'immagine: {e} +@msg an image that couldn't be read was left out un'immagine che non è stato possibile leggere è stata omessa +@msg can't open “{name}”: {e} impossibile aprire “{name}”: {e} +@msg cannot align zip entries to {align} bytes impossibile allineare le voci zip a {align} byte +@msg cannot open the automation {kind} root `{_1}`: {e} impossibile aprire la cartella radice {kind} dell'automazione `{_1}`: {e} +@msg cannot read profile: {0} impossibile leggere il profilo: {0} +@msg color book version {version} isn't supported (only 1) la versione {version} del libro colori non è supportata (solo 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) i libri colori nello spazio colore {other} non sono supportati (solo RGB, CMYK e Lab) +@msg file name too long to zip: {_1} nome file troppo lungo per un archivio zip: {_1} +@msg file name too long to zip: {name} nome file troppo lungo per un archivio zip: {name} +@msg freeform line {i}: {e} linea libera {i}: {e} +@msg freeform point {i}: {e} punto libero {i}: {e} +@msg give `{key}` or `{other}`, not both indica `{key}` o `{other}`, non entrambi +@msg grayscale profiles can't be working spaces yet i profili in scala di grigi non possono ancora essere spazi di lavoro +@msg input contains NaN or infinite coordinates l'input contiene coordinate NaN o infinite +@msg input path could not be closed impossibile chiudere il tracciato di input +@msg invalid ICC profile: {0} profilo ICC non valido: {0} +@msg invalid scale {scale} scala non valida {scale} +@msg its localized names take more than {_1} KB i suoi nomi localizzati occupano più di {_1} KB +@msg its objects are nested too deep ({e}) i suoi oggetti sono annidati troppo in profondità ({e}) +@msg its tag table lists the `{_1}` tag more than once la sua tabella dei tag elenca il tag `{_1}` più di una volta +@msg not a graphic style library: {e} non è una libreria di stili grafici: {e} +@msg not a swatch library (.vcswatches or .gpl) non è una libreria campioni (.vcswatches o .gpl) +@msg not a swatch library (format `{_1}`) non è una libreria campioni (formato `{_1}`) +@msg not a swatch library: {e} non è una libreria campioni: {e} +@msg not an action set file ({e}) non è un file di set di azioni ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray lo spazio colore {other} del profilo non è RGB, CMYK o scala di grigi +@msg regular expression too large espressione regolare troppo grande +@msg script file is not UTF-8 text: {value} il file di script non è testo UTF-8: {value} +@msg stop {i}: {e} cursore colore {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) la versione {major}.{minor} del file di scambio campioni non è supportata (solo 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors il libro colori contiene più di {ASE_MAX_ENTRIES} colori +@msg the color book is cut short il libro colori è troncato +@msg the color book's names take more than {_1} MB i nomi del libro colori occupano più di {_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 l'immagine misurerebbe {w} × {h} pixel; le esportazioni raster sono limitate a {MAX_RASTER_SIDE} pixel per lato e {_1} megapixel: riduci la scala o la risoluzione +@msg the optimised image can't be read back: {e} impossibile rileggere l'immagine ottimizzata: {e} +@msg the pixel buffer doesn't match the image size il buffer dei pixel non corrisponde alle dimensioni dell'immagine +@msg the preview: {e} l'anteprima: {e} +@msg the request may not read the New Document profiles folder la richiesta non può leggere la cartella dei profili di nuovo documento +@msg the shapes are too degenerate to combine le forme sono troppo degeneri per essere combinate +@msg the shapes cross themselves and each other too often to combine le forme si intersecano con sé stesse e tra loro troppe volte per essere combinate +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups il file di scambio campioni contiene più di {ASE_MAX_ENTRIES} colori e gruppi +@msg the swatch exchange file is cut short il file di scambio campioni è troncato +@msg the {_1} plate can't be read: {e} impossibile leggere la lastra {_1}: {e} +@msg this binary DXF file is damaged or cut short questo file DXF binario è danneggiato o troncato +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that questo è un disegno DWG, che non può essere letto: salvalo dall'app CAD come DXF e apri quel file +@msg too many classes troppe classi di caratteri +@msg too many groups troppi gruppi +@msg too many layers for PSD (at most 32767) troppi livelli per PSD (al massimo 32767) +@msg too many swatch exchange blocks troppi blocchi nel file di scambio campioni +@msg unknown colour profile: {0} profilo colore sconosciuto: {0} +@msg {_1} clashes with a built-in profile name {_1} coincide con il nome di un profilo incorporato +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} pixel è troppo grande per GIF (al massimo 65535 pixel per lato): riduci la risoluzione +@msg {_1}: no usable transform to/from sRGB {_1}: nessuna trasformazione utilizzabile da/verso sRGB +@msg {name} is not a CMYK profile {name} non è un profilo CMYK +@msg {name} is not an RGB profile {name} non è un profilo RGB +@msg {path}: could not be read {path}: lettura non riuscita +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pixel è troppo grande per JPEG (al massimo 65535 pixel per lato): riduci la risoluzione +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pixel è troppo grande per Targa (al massimo 65535 pixel per lato): riduci la risoluzione +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pixel è troppo grande per PSD (al massimo {MAX_SIDE} pixel per lato): riduci la risoluzione +# Messages other catalogs translate and Italian did not: Banner Hems and Grommets, parameter checks and recovery data. +@msg `{key}` must be from 0.01 to 1000 pt `{key}` deve essere compreso tra 0,01 e 1000 pt +@msg select the banner, or mark every artboard seleziona lo striscione, oppure contrassegna ogni tavola da disegno +@msg the banner has no width or height lo striscione non ha larghezza o altezza +@msg the grommet inset is too large for the banner la distanza degli occhielli dal bordo è troppo grande per lo striscione +@msg the hem is too deep for the banner l'orlo è troppo profondo per lo striscione +@msg the spacing makes more than {MAX_GROMMETS} grommets la spaziatura produce più di {MAX_GROMMETS} occhielli +@msg there is no banner to mark non c'è nessuno striscione da contrassegnare +@msg turn on at least one side attiva almeno un lato +@msg unknown parameter `{within}{key}`; {why} parametro sconosciuto `{within}{key}`; {why} +@msg with no hem and no grommets there is nothing to mark senza orlo e senza occhielli non c'è nulla da contrassegnare +@msg {name} must be {lo}–{hi}{unit} (got {v}) {name} deve essere {lo}–{hi}{unit} (ricevuto {v}) +@msg {saving} failed: {e} {saving} non riuscito: {e} Banner Hems and Grommets… Orli e occhielli per striscioni… Banner Hems and Grommets Orli e occhielli per striscioni Hem Orlo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3abb388f49..c9cdb8fcbe 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6343,6 +6343,1810 @@ axis Y Axis Y 軸 @msg no menu item that can be inserted runs this command このコマンドを実行する、挿入できるメニュー項目はありません @msg the action already has 10,000 steps アクションにはすでに 10,000 ステップがあります @msg the action set would be larger than 8 MiB アクションセットが 8 MiB を超えます +@msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions strict モードの関数では 'caller'、'callee'、'arguments' を使用できません +@msg Alignment must be one of the choices 揃えには選択肢のいずれかを指定してください +@msg Boolean.prototype.{name} was called on a value that is not a Boolean Boolean.prototype.{name} が Boolean ではない値に対して呼び出されました +@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB サンプルを CMYK で読み取れなかった CMYK 画像は RGB で読み込みました +@msg Cannot write to the location of “{name}”; choose where to save it 「{name}」の場所に書き込めません。保存先を選択してください +@msg DCS files can only be placed linked: use File › Place with Link on DCS ファイルはリンクとしてのみ配置できます: 「リンク」をオンにして「ファイル › 配置」を使用してください +@msg Date.prototype method called on a value that is not a Date object Date.prototype のメソッドが Date オブジェクトではない値に対して呼び出されました +@msg Error.prototype.toString was called on a value that is not an object Error.prototype.toString がオブジェクトではない値に対して呼び出されました +@msg Function.prototype.toString was called on a value that is not a function Function.prototype.toString が関数ではない値に対して呼び出されました +@msg Import SVG Filter reads SVG files, not SVGZ 「SVG フィルターを読み込み」が読み込むのは SVG ファイルで、SVGZ ファイルは読み込みません +@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (オフセット {at}) +@msg JSON.stringify cannot write a structure that contains itself JSON.stringify は自身を含む構造を書き出せません +@msg Line Gap must be a number 行間には数値を指定してください +@msg Namespace.prototype.toString needs a Namespace Namespace.prototype.toString には Namespace が必要です +@msg New Document profiles need the preferences folder, which this run doesn't have 新規ドキュメントプロファイルには環境設定フォルダーが必要ですが、この実行環境にはありません +@msg Number.prototype.{name} was called on a value that is not a number Number.prototype.{name} が数値ではない値に対して呼び出されました +@msg Object.create's prototype must be an object or null Object.create のプロトタイプにはオブジェクトまたは null を指定してください +@msg Object.{function} was given a value that is not an object Object.{function} にオブジェクトではない値が渡されました +@msg QName.prototype.toString needs a QName QName.prototype.toString には QName が必要です +@msg RegExp.prototype.{name} was called on a value that is not a RegExp object RegExp.prototype.{name} が RegExp オブジェクトではない値に対して呼び出されました +@msg Stencil Bridges: the selected art is too large or too detailed to bridge ステンシルブリッジ: 選択したアートが大きすぎるか細かすぎるため、ブリッジを作成できません +@msg String.prototype.toString was called on a value that is not a string String.prototype.toString が文字列ではない値に対して呼び出されました +@msg The {name} profile could not be read, so the new document has the default settings: {reason} {name} プロファイルを読み込めなかったため、新規ドキュメントには初期設定を使用しました: {reason} +@msg Trap: more than {_1} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time トラップ: トラップできるほど近いオブジェクトの組が {_1} 組を超えているか、水平方向の範囲が重なるオブジェクトが多すぎます。一度にトラップするオブジェクトを減らしてください +@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off カス取り枠とライン: アートの外側に収まるカス取りラインがなく、カス取り枠もオフになっています +@msg \\ at end of pattern パターンの末尾に \\ があります +@msg `alignOn` cannot be empty `alignOn` を空にすることはできません +@msg `alignOn` must be a string `alignOn` には文字列を指定してください +@msg `align` must be a string `align` には文字列を指定してください +@msg `answer` for this {kind} dialog must be one of: {_1} この {kind} ダイアログの `answer` には次のいずれかを指定してください: {_1} +@msg `answers` has more than {MAX_ANSWERS} answers `answers` の回答が {MAX_ANSWERS} 個を超えています +@msg `answers` is larger than 1 MiB `answers` が 1 MiB を超えています +@msg `answers` must be a list of answers `answers` には回答のリストを指定してください +@msg `artboard` must be the index of an artboard of the document `artboard` にはドキュメントのアートボードのインデックスを指定してください +@msg `artboardsAt` must be [x, y] `artboardsAt` には [x, y] を指定してください +@msg `artboards` names an artboard the document does not have `artboards` にドキュメントにないアートボードが指定されています +@msg `artboards` names an artboard twice `artboards` に同じアートボードが 2 回指定されています +@msg `at` must be [x, y] (got {at}) `at` には [x, y] を指定してください (指定値: {at}) +@msg `at` must be a point [x, y] `at` には点 [x, y] を指定してください +@msg `beside` needs `side` `beside` には `side` が必要です +@msg `code` must be a whole number `code` には整数を指定してください +@msg `colorMode` must be one of rgb, cmyk `colorMode` には rgb、cmyk のいずれかを指定してください +@msg `colors` does not apply to the {name} reduction `colors` は {name} 減色には適用されません +@msg `colors` must be a whole number from 2 to 256 `colors` には 2 から 256 までの整数を指定してください +@msg `colors` must be at least 9 for the uniform reduction 均等減色では `colors` に 9 以上を指定してください +@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors` には 1 から {MAX_THEME_COLORS} までの値を指定してください (指定値: {n}) +@msg `count` must be a whole number from 0 to {MAX_COUNT} `count` には 0 から {MAX_COUNT} までの整数を指定してください +@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount` には 0 から 100 までの数値を指定してください +@msg `fidelity` must be from {lo} to {hi} pt `fidelity` には {lo}~{hi} pt の値を指定してください +@msg `focal` and `radius` cannot be used together `focal` と `radius` は同時に使用できません +@msg `focal` must be an array of [subpath, anchor] pairs `focal` には [subpath, anchor] の組の配列を指定してください +@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` に指定された [{si}, {ai}] はパスのアンカーではありません +@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate` に指定できるのは null のみで、字形の選択を解除します (選択は text.setGlyphAlternate で行います) +@msg `group` must be one of {_1} `group` には {_1} のいずれかを指定してください +@msg `group` must be the Objects on Path group of `member` and `target` `group` には `member` と `target` のパス上のオブジェクトグループを指定してください +@msg `id` is an object id, not {v} `id` にはオブジェクト ID を指定してください ({v} は指定できません) +@msg `item` needs `index` `item` には `index` が必要です +@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore` は文字スタイルではなく行の先頭文字の設定です (text.setRangeStyle で設定します) +@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType` には "pushInFirst"、"pushOutFirst"、"pushOutOnly" のいずれかを指定してください +@msg `leader` must be a string `leader` には文字列を指定してください +@msg `max` must be 1 to {MAX_SUGGESTIONS} `max` には 1~{MAX_SUGGESTIONS} を指定してください +@msg `name` must be a name that is not empty `name` には空でない名前を指定してください +@msg `name` must not be empty `name` を空にすることはできません +@msg `origin` must be [x, y] in points `origin` にはポイント単位の [x, y] を指定してください +@msg `picks` must be an array (got {v}) `picks` には配列を指定してください (指定値: {v}) +@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` には 1~{MAX_PICKS} 個の指定が必要です (指定数: {_1}) +@msg `points` must hold from 2 to 100000 points `points` には 2~100000 個の点が必要です +@msg `radius` must be 0 or more `radius` には 0 以上を指定してください +@msg `rect` must be [x, y, width, height] `rect` には [x, y, width, height] を指定してください +@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect` には [x0, y0, x1, y1] を指定してください (指定値: {r}) +@msg `replace` must be a string `replace` には文字列を指定してください +@msg `rows` must be lists of numbers or nulls, not {r} `rows` には数値または null のリストを指定してください ({r} は指定できません) +@msg `scale` must be a number from 0.001 to 1000 `scale` には 0.001 から 1000 までの数値を指定してください +@msg `scope.{key}` must be a non-empty array `scope.{key}` には空でない配列を指定してください +@msg `scope` must be "document", {artboards} or {layers} `scope` には "document"、{artboards}、{layers} のいずれかを指定してください +@msg `side` must be "left", "right", "top" or "bottom" `side` には "left"、"right"、"top"、"bottom" のいずれかを指定してください +@msg `side` places the artboard: leave out x, y, width and height `side` でアートボードを配置します: x、y、width、height は省略してください +@msg `source` is the only path in series group `target` `source` は系列グループ `target` の唯一のパスです +@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing` ではボックス全体のラインが {MAX_COUNT} 本を超えます +@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing` には 0 より大きく {MAX_DISTANCE} pt 以下の値を指定してください +@msg `target` must be an object of the same Objects on Path group as `member` `target` には `member` と同じパス上のオブジェクトグループのオブジェクトを指定してください +@msg `tolerance` must be from 0 to 100 pt `tolerance` には 0~100 pt の値を指定してください +@msg `version` must be 0 or {_1} `version` には 0 または {_1} を指定してください +@msg `version` must be 0 or {newest} `version` には 0 または {newest} を指定してください +@msg `warichuAlign` must be one of {_1} `warichuAlign` には {_1} のいずれかを指定してください +@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines` には {_1} から {_2} までの整数を指定してください +@msg `weight` must be from 0.01 to 1000 pt `weight` には 0.01~1000 pt の値を指定してください +@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights` には行ごとに 1 つずつ、{_1} 個の数値のリストを指定してください (指定値: {v}) +@msg `xml` must be a string `xml` には文字列を指定してください +@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it `{KEY}` のエントリ {n} は、{_1} のバイト {start} の段落に、前のエントリとは異なるインデントを指定しています +@msg `{KEY}` entry {n} must be an object `{KEY}` のエントリ {n} にはオブジェクトを指定してください +@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` `{KEY}` のエントリ {n} は `leftIndent` も `firstLineIndent` も設定していません +@msg `{KEY}` entry {n}: `id` must be an object id `{KEY}` のエントリ {n}: `id` にはオブジェクト ID を指定してください +@msg `{KEY}` entry {n}: `start` must be a byte offset `{KEY}` のエントリ {n}: `start` にはバイトオフセットを指定してください +@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt `{KEY}` のエントリ {n}: `{k}` には -{MAX_PARA}~{MAX_PARA} pt の数値を指定してください +@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} `{KEY}` のエントリ {n}: {_1} にはバイト {start} から始まる段落がありません +@msg `{KEY}` entry {n}: unknown key `{k}` `{KEY}` のエントリ {n}: `{k}` は不明なキーです +@msg `{KEY}` entry {n}: {_1} is not a type object `{KEY}` のエントリ {n}: {_1} はテキストオブジェクトではありません +@msg `{KEY}` is empty `{KEY}` が空です +@msg `{KEY}` must be a list `{KEY}` にはリストを指定してください +@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}` に指定できるエントリは最大 {MAX_PARAGRAPH_INDENTS} 個です (指定数: {_1}) +@msg `{KEY}` takes no `{other}` beside it `{KEY}` と一緒に `{other}` は指定できません +@msg `{base}` is larger than {_1} MB `{base}` は {_1} MB を超えています +@msg `{base}` is not in the DCS file's folder `{base}` は DCS ファイルのフォルダーにありません +@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}` の単語が {MAX_EDIT_WORDS} 個を超えています +@msg `{key}` must be 0 or more `{key}` には 0 以上を指定してください +@msg `{key}` must be [x, y] numbers `{key}` には数値の [x, y] を指定してください +@msg `{key}` must be a list of words `{key}` には単語のリストを指定してください +@msg `{key}` must be a list, not {v} `{key}` にはリストを指定してください ({v} は指定できません) +@msg `{key}` must be a number or null, not {v} `{key}` には数値または null を指定してください ({v} は指定できません) +@msg `{key}` must be a whole number of 0 or more `{key}` には 0 以上の整数を指定してください +@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}` には 0~{MAX_DISTANCE} pt の値を指定してください +@msg `{key}` must be from {lo} to {hi}, not {v} `{key}` には {lo}~{hi} の値を指定してください ({v} は指定できません) +@msg `{key}` must be one of auto, always, never `{key}` には auto、always、never のいずれかを指定してください +@msg `{key}` must be one of miter, round, bevel `{key}` には miter、round、bevel のいずれかを指定してください +@msg `{key}` must be one word of at most {_1} characters `{key}` には {_1} 文字以内の 1 語を指定してください +@msg `{key}` must hold single words of at most {_1} characters `{key}` には {_1} 文字以内の単語を 1 語ずつ指定してください +@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}` にはパス上のオブジェクトグループのパスに配置されたオブジェクトを指定してください +@msg `{key}` names an object that is hidden or locked `{key}` に指定されたオブジェクトは非表示またはロックされています +@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` は独立したファイルで、DCS ファイルのパスと同じ場所でのみ見つけられます +@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} +@msg `{u}` is no Unicode character `{u}` は Unicode 文字ではありません +@msg `{word}` isn't in the custom dictionary `{word}` はカスタム辞書にありません +@msg a CMap has too many mappings CMap のマッピングが多すぎます +@msg a \\u escape needs four hexadecimal digits \\u エスケープには 4 桁の 16 進数が必要です +@msg a comma in an XPath expression must separate function arguments XPath 式のカンマは関数の引数を区切るものでなければなりません +@msg a document holds at most {_1} SVG filters ドキュメントに含められる SVG フィルターは最大 {_1} 個です +@msg a document with unsaved changes has no recovery copy 未保存の変更があるドキュメントに復帰用のコピーがありません +@msg a font's data is too large フォントのデータが大きすぎます +@msg a font's subroutines are too large フォントのサブルーチンが大きすぎます +@msg a function declaration without a name 名前のない関数宣言です +@msg a host value has too many entries ホストの値のエントリが多すぎます +@msg a host value is nested too deep ホストの値の入れ子が深すぎます +@msg a namespace declaration uses a reserved prefix or URI 名前空間の宣言で予約済みのプレフィックスまたは URI が使用されています +@msg a namespace qualifier is undefined 名前空間の修飾子が定義されていません +@msg a namespace with an empty URI cannot have a prefix URI が空の名前空間にはプレフィックスを付けられません +@msg a number has no digits 数値に数字がありません +@msg a number's exponent has no digits 数値の指数部に数字がありません +@msg a number's fraction has no digits 数値の小数部に数字がありません +@msg a property descriptor cannot have both a value or writable and a get or set プロパティ記述子に value または writable と get または set の両方を指定することはできません +@msg a property descriptor must be an object プロパティ記述子にはオブジェクトを指定してください +@msg a property's {name} must be a function or undefined プロパティの {name} には関数または undefined を指定してください +@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied 含まれるストリームをデコードすると 256 MB を超えるか、デコードパラメーターを適用できません +@msg a string holds a control character 文字列に制御文字が含まれています +@msg a string holds an escape sequence JSON does not have 文字列に JSON にないエスケープシーケンスが含まれています +@msg a string is not closed 文字列が閉じていません +@msg an SVG filter is named by its id: give `xml` with `name` SVG フィルターの名前はその ID です: `xml` と `name` を指定してください +@msg an XML element has two attributes with the same name XML 要素に同じ名前の属性が 2 つあります +@msg an XML value cannot be inserted into itself or its descendants XML 値をそれ自身またはその子孫に挿入することはできません +@msg an XML value's children cannot be assigned by position XML 値の子を位置で代入することはできません +@msg an XML value's children cannot be deleted by position XML 値の子を位置で削除することはできません +@msg an XMLList converts to XML only when it has one item XMLList を XML に変換できるのは、項目が 1 つの場合だけです +@msg an XPath node test is missing XPath のノードテストがありません +@msg an XPath operand is missing XPath のオペランドがありません +@msg an XPath operator is missing XPath の演算子がありません +@msg an XPath predicate or step cannot follow here ここに XPath の述語やステップを続けることはできません +@msg an XPath predicate or step follows a value that is not a node-set XPath の述語またはステップがノードセットではない値の後にあります +@msg an XPath step is missing XPath のステップがありません +@msg an image couldn't be decoded and is missing from the picture 画像をデコードできなかったため、図に含まれていません +@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) {w} × {h} ピクセルの画像は大きすぎて変換できません (最大 {_1} ピクセル) +@msg apply was called on a value that is not a function apply が関数ではない値に対して呼び出されました +@msg area type in a thread cannot use Dynamic Text: {frames} スレッド内のエリア内文字ではダイナミックテキストを使用できません: {frames} +@msg array literal too long 配列リテラルが長すぎます +@msg assignment to a constant binding 定数バインディングへの代入です +@msg at least one row that isn't excluded needs a weight above 0 除外されていない行の少なくとも 1 つに 0 より大きいウェイトが必要です +@msg bad point {v}: expected [x, y] 点 {v} が正しくありません: [x, y] を指定してください +@msg blend must be a mode name blend には描画モードの名前を指定してください +@msg both sides of an XPath | must select nodes XPath の | の両辺でノードを選択する必要があります +@msg canceled キャンセルしました +@msg cannot convert the object to a primitive value オブジェクトをプリミティブ値に変換できません +@msg cannot convert undefined or null to an object undefined または null をオブジェクトに変換できません +@msg character dimensions cannot be represented with the source text transform 文字の寸法をソースのテキスト変形で表すことができません +@msg character dimensions cannot be represented with the target text transform 文字の寸法をターゲットのテキスト変形で表すことができません +@msg continuation must be an array of path chunks continuation にはパスチャンクの配列を指定してください +@msg continuation requires points continuation には点が必要です +@msg each path chunk must contain 1 to 4096 points 各パスチャンクには 1~4096 個の点が必要です +@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) 各指定は {{at: [x, y]}} または {{rect: [x0, y0, x1, y1]}} です (指定値: {v}) +@msg each weight must be a number of 0 or more (got {v}) 各ウェイトには 0 以上の数値を指定してください (指定値: {v}) +@msg finally table {t} out of range finally テーブル {t} が範囲外です +@msg flags cannot be given with a RegExp object RegExp オブジェクトと一緒にフラグを指定することはできません +@msg function declaration index out of range 関数宣言のインデックスが範囲外です +@msg give `char`, `text`, `code` or `unicode` `char`、`text`、`code`、`unicode` のいずれかを指定してください +@msg give `count` or `spacing`, not both `count` または `spacing` のどちらか一方を指定してください +@msg give `name`, an SVG filter's name SVG フィルターの名前を `name` に指定してください +@msg give `path`, an SVG file, or `svg`, SVG text SVG ファイルを `path` に、または SVG テキストを `svg` に指定してください +@msg give `xml`, one SVG element SVG の 要素を 1 つ `xml` に指定してください +@msg give exactly one of `at`, `rect` and `points` `at`、`rect`、`points` のうち 1 つだけを指定してください +@msg give exactly one of `path`, `data` and `dataBase64` `path`、`data`、`dataBase64` のうち 1 つだけを指定してください +@msg give index or indexes, not both index または indexes のどちらか一方を指定してください +@msg give one element 要素を 1 つ指定してください +@msg give opacity or blend opacity または blend を指定してください +@msg handler at {_1} expects {_2} values but its range starts with {d} {_1} のハンドラーは {_2} 個の値を想定していますが、範囲の開始時は {d} 個です +@msg handler depth too large ハンドラーの深さが大きすぎます +@msg handler range {_1}..{_2} out of range ハンドラーの範囲 {_1}..{_2} が範囲外です +@msg hiddenLayers must be true or false hiddenLayers には true または false を指定してください +@msg hvcC hvcC +@msg images in a spot ink whose samples couldn't be read were imported in RGB サンプルを読み取れなかった特色インキの画像は RGB で読み込みました +@msg images past the most pixels one file may hold were left out 1 つのファイルに含められる最大ピクセル数を超えた画像は除外しました +@msg images too large to read were left out 大きすぎて読み込めない画像は除外しました +@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes に指定できるエントリは最大 {MAX_GUIDE_INDEXES} 個です +@msg indexes must be an array of guide indexes indexes にはガイドのインデックスの配列を指定してください +@msg instruction out of range 命令が範囲外です +@msg instruction {pc} ({op}) pops below the stack 命令 {pc} ({op}) がスタックの底を超えて取り出しています +@msg instruction {pc} reached with stack depths {seen} and {d} 命令 {pc} にスタックの深さ {seen} と {d} で到達しました +@msg invalid array length 配列の長さが無効です +@msg invalid group グループが無効です +@msg invalid object id {v}: expected a non-negative integer オブジェクト ID {v} が無効です: 0 以上の整数を指定してください +@msg invalid regular expression flag '{_1}' 正規表現のフラグ '{_1}' が無効です +@msg invalid regular expression: {m} 正規表現が無効です: {m} +@msg invalid time value 時刻の値が無効です +@msg is a confirm's answer, whose `value` must be true or false は confirm の回答で、`value` には true または false を指定してください +@msg is a prompt's answer, whose `value` must be a string or null は prompt の回答で、`value` には文字列または null を指定してください +@msg is an alert's answer, whose `value` must be "ok" は alert の回答で、`value` には "ok" を指定してください +@msg is longer than 16 KiB が 16 KiB を超えています +@msg it draws its image through a clipping path クリッピングパスを通して画像を描画しています +@msg it draws no image 画像を描画していません +@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes {MAX_DICTIONARY_FILE_BYTES} バイトを超えています +@msg it isn't a file ファイルではありません +@msg it names {_1} plates, and at most {MAX_PLATES} are read {_1} 個の版が指定されていますが、読み込めるのは最大 {MAX_PLATES} 個です +@msg its art is too long or too detailed for a head アートが長すぎるか細かすぎるため、矢印にできません +@msg its art paints nothing アートが何も描画しません +@msg its image is too large to read 画像が大きすぎて読み込めません +@msg its plates are too large: more than {MAX_SAMPLES} pixels together 版が大きすぎます: 合計で {MAX_SAMPLES} ピクセルを超えています +@msg its plates are too large: {_1} plates of {w} × {h} pixels 版が大きすぎます: {w} × {h} ピクセルの版が {_1} 個あります +@msg its plates can't be kept together 版をまとめて保持できません +@msg its shapes could not be combined into one シェイプを 1 つに結合できませんでした +@msg jump to {pc}, past the end 末尾を超えた {pc} へのジャンプです +@msg legacyNameEncoding must be one of {_1} legacyNameEncoding には {_1} のいずれかを指定してください +@msg localize: the first argument must be a localization object or a string localize: 最初の引数にはローカライズオブジェクトまたは文字列を指定してください +@msg missing `picks` `picks` がありません +@msg missing `word` `word` がありません +@msg must be an object with `kind` and `value` には `kind` と `value` を持つオブジェクトを指定してください +@msg must have `kind` alert, confirm or prompt の `kind` には alert、confirm、prompt のいずれかを指定してください +@msg must have exactly `kind` and `value` には `kind` と `value` だけを指定してください +@msg needs a closing quote after the name 名前の後に閉じ引用符が必要です +@msg needs a name 名前が必要です +@msg needs a name of at most 64 bytes 64 バイト以内の名前が必要です +@msg needs a name of letters, digits and _, or a quoted name 文字、数字、_ からなる名前、または引用符で囲んだ名前が必要です +@msg needs a name without control characters 制御文字を含まない名前が必要です +@msg nesting too deep 入れ子が深すぎます +@msg no HP-GL format HP-GL 形式はありません +@msg no New Document profile called `{shown}` ({_1}) `{shown}` という新規ドキュメントプロファイルはありません ({_1}) +@msg no SVG filter at index {k} インデックス {k} に SVG フィルターはありません +@msg no arrowheads file here ここに矢印ファイルはありません +@msg no character has code {code} コード {code} の文字はありません +@msg no custom arrowhead {name} (see stroke.arrowheads.list) カスタム矢印 {name} はありません (stroke.arrowheads.list を参照) +@msg no file to open 開くファイルがありません +@msg no instructions 命令がありません +@msg no layer to rename 名前を変更するレイヤーがありません +@msg no random numbers ({e}) 乱数を取得できません ({e}) +@msg no selected or given object has a visible 3D effect 選択または指定したオブジェクトに、表示されている 3D 効果がありません +@msg no such artboard {v} アートボード {v} はありません +@msg no symbol named `{_1}` `{_1}` という名前のシンボルはありません +@msg node {_1} already has the text variable `{_2}` ノード {_1} にはすでにテキスト変数 `{_2}` があります +@msg not a DCS file DCS ファイルではありません +@msg not a RegExp object RegExp オブジェクトではありません +@msg not a regular expression 正規表現ではありません +@msg not an action set: the text doesn't start with /version and a number アクションセットではありません: テキストが /version と数値で始まっていません +@msg nothing to repeat 繰り返す対象がありません +@msg numbers out of order in {_1} quantifier {_1} 量指定子の数値の順序が正しくありません +@msg object {_1} needs a 3D effect オブジェクト {_1} には 3D 効果が必要です +@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic オブジェクト {_1}: 中空の「押し出しとベベル」にはキャップがありません: グラフィックをマッピングするには「キャップ」をオンにしてください +@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front オブジェクト {_1}: 奥行きが 0 のオブジェクトは面が 1 つです: グラフィックは前面にマッピングしてください +@msg object {_1}: {_2} オブジェクト {_1}: {_2} +@msg object {_1}: {e} オブジェクト {_1}: {e} +@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams オブジェクト {_1}: インデックス {index} の {name} には照明プリセットがありません。ライトは effect.setParams で設定してください +@msg opacity must be a number opacity には数値を指定してください +@msg operand too large オペランドが大きすぎます +@msg order is a whole number from 0 to {others}, not `{v}` order には 0 から {others} までの整数を指定してください (`{v}` は指定できません) +@msg path chunks must share their boundary point パスの分割部分は境界点を共有している必要があります +@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false pdfCompatible: false の場合、useArtboards が false のときは、編集可能なアートワークを保持するためにアートボードが必要です +@msg point x must be a number 点の x には数値を指定してください +@msg point y must be a number 点の y には数値を指定してください +@msg points must be [x,y] pairs points には [x,y] の組を指定してください +@msg points must be finite canvas points points にはキャンバス上の有限の点を指定してください +@msg range out of order in character class 文字クラス内の範囲の順序が正しくありません +@msg reduce of an empty array with no initial value 初期値のない空の配列に対する reduce です +@msg regular expression flag '{_1}' given twice 正規表現のフラグ '{_1}' が 2 回指定されています +@msg sampled text would exceed the canvas (coordinates out of range) サンプリングしたテキストがキャンバスを超えます (座標が範囲外) +@msg select an SVG filter SVG フィルターを選択してください +@msg select an object with a 3D effect 3D 効果のあるオブジェクトを選択してください +@msg select an object with a 3D effect, or give its id in ids 3D 効果のあるオブジェクトを選択するか、その ID を ids に指定してください +@msg select one layer, group or object to rename 名前を変更するレイヤー、グループ、オブジェクトのいずれかを 1 つ選択してください +@msg select the art to weed カス取りするアートを選択してください +@msg select type on a path or area type パス上文字またはエリア内文字を選択してください +@msg side is left or right, not `{v}` side には left または right を指定してください (`{v}` は指定できません) +@msg source text transform is collapsed or unrepresentable 元のテキストの変形がつぶれているか、表現できません +@msg table must be true or false table には true または false を指定してください +@msg takes one name 名前を 1 つ指定してください +@msg target text transform is collapsed or unrepresentable 対象のテキストの変形がつぶれているか、表現できません +@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer 分割数 (最大 100000) またはダッシュの段数が多くなりすぎます: 破線パターンを単純にするか、ダッシュを長くしてください +@msg the Layers panel is not shown レイヤーパネルが表示されていません +@msg the Layers panel's search, filter or Show Layers Only hides its row レイヤーパネルの検索、フィルター、「レイヤーのみを表示」のいずれかでその行が隠れています +@msg the Objects on Path group is hidden or locked 「パス上のオブジェクト」グループが非表示またはロックされています +@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes SVG ファイルが {MAX_IMPORT_BYTES} バイトを超えています +@msg the XML operation needs an XML or XMLList value XML 操作には XML または XMLList の値が必要です +@msg the XPath expression does not evaluate to a node-set XPath 式の評価結果がノードセットではありません +@msg the XPath expression ends early XPath 式が途中で終わっています +@msg the XPath expression has an unmatched ) XPath 式に対応していない ) があります +@msg the XPath expression has an unmatched ] XPath 式に対応していない ] があります +@msg the XPath expression has an unmatched closing bracket XPath 式に対応していない閉じ括弧があります +@msg the XPath expression selects a namespace node, which is not an XML value XPath 式が名前空間ノードを選択していますが、これは XML の値ではありません +@msg the XPath expression selects the root node, which is not an XML value XPath 式がルートノードを選択していますが、これは XML の値ではありません +@msg the XPath function {name} needs a node-set XPath 関数 {name} にはノードセットが必要です +@msg the XPath function {name} takes the wrong number of arguments XPath 関数 {name} の引数の数が正しくありません +@msg the XPath name functions need a node-set XPath の名前関数にはノードセットが必要です +@msg the XPath prefix {shown} is not bound to a namespace XPath の接頭辞 {shown} は名前空間に結び付けられていません +@msg the XPath variable ${shown} is not bound XPath 変数 ${shown} は束縛されていません +@msg the argument list is not an object 引数リストがオブジェクトではありません +@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out アートワークが EPS には大きすぎます: PostScript の 256 MB より後の部分は除外しました +@msg the bitmaps hold more pixels than one file may ビットマップのピクセル数が 1 つのファイルに含められる数を超えています +@msg the code runs past its last instruction コードが最後の命令を超えて実行されます +@msg the curve tolerance must be from 0.01 to 10 points 曲線の許容値には 0.01 から 10 ポイントまでの値を指定してください +@msg the custom dictionary couldn't be saved to {_1}: {e} カスタム辞書を {_1} に保存できませんでした: {e} +@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is カスタム辞書 {_1} を読み込めないため ({why})、そのまま残しました +@msg the document already has an SVG filter named {name} ドキュメントにはすでに {name} という名前の SVG フィルターがあります +@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it ドキュメントには {name} という名前の別の SVG フィルターがあります: svgFilter.apply を使用するか、編集するには `replace` を付けて svgFilter.define を使用してください +@msg the document has no SVG filter named {name} ドキュメントに {name} という名前の SVG フィルターはありません +@msg the document has no SVG filter named {r} ドキュメントに {r} という名前の SVG フィルターはありません +@msg the document has no color swatches ドキュメントにカラースウォッチがありません +@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 ドキュメントのスウォッチには {_1} 色ありますが、ドキュメント減色で使用できるのは最大 255 色です +@msg the file isn't valid SVG: {e} ファイルが有効な SVG ではありません: {e} +@msg the file names no plate ファイルに版が指定されていません +@msg the function's prototype is not an object 関数のプロトタイプがオブジェクトではありません +@msg the guide is hidden or locked ガイドが非表示またはロックされています +@msg the library's file is outside the read roots ライブラリのファイルが読み取りルートの外にあります +@msg the line is too long, or the paths it passes too many, to check 線が長すぎるか、通過するパスが多すぎるため、確認できません +@msg the method needs an XML value このメソッドには XML の値が必要です +@msg the paint is not a freeform gradient (apply one with paint.editGradient {kind}) ペイントがフリーフォームグラデーションではありません (paint.editGradient {kind} で適用してください) +@msg the parameters or body do not form one function パラメーターまたは本体が 1 つの関数になっていません +@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it ペーストした SVG フィルター {name} は、ドキュメントにある同名のフィルターと描画結果が異なり、使用できる空き名もありません +@msg the paths are too many or too tangled to find where they cross パスが多すぎるか複雑に絡み合っているため、交差する位置を見つけられません +@msg the pen table holds more than 255 pens ペンテーブルに 255 本を超えるペンがあります +@msg the plates can't be kept 版を保持できません +@msg the position table does not match the instructions 位置テーブルが命令と一致しません +@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes prompt のテキストが {MAX_PROMPT_BYTES} バイトを超えています +@msg the radix must be between 2 and 36 基数には 2 から 36 までの値を指定してください +@msg the rectangle or line reaches too many paths to check 長方形または線にかかるパスが多すぎるため、確認できません +@msg the right side of 'in' is not an object 'in' の右辺がオブジェクトではありません +@msg the right side of 'instanceof' is not a function 'instanceof' の右辺が関数ではありません +@msg the selected art is nested more than {MAX_DEPTH} levels deep 選択したアートの入れ子が {MAX_DEPTH} 階層を超えています +@msg the selected art is too large to measure 選択したアートが大きすぎて計測できません +@msg the selection has no outlines to weed around 選択範囲に、カス取りの対象となるアウトラインがありません +@msg the value bind was called on is not a function bind の呼び出し対象の値が関数ではありません +@msg the value is not a constructor 値がコンストラクターではありません +@msg the value is not a function 値が関数ではありません +@msg the value is not a valid XML name 値が有効な XML 名ではありません +@msg the value is not an XML attribute name 値が XML 属性名ではありません +@msg the value is not an XML name 値が XML 名ではありません +@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines カス取りラインがアートのアウトラインと交差する回数が多すぎて計測できません。ラインの数を減らしてください +@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap カス取りラインに近すぎるアウトラインのセグメントが多すぎて計測できません。ラインの数を減らすか、間隔を小さくしてください +@msg the weeding lines would be more than {max_pieces} pieces カス取りラインが {max_pieces} 個を超えます +@msg the {_1} plate lies outside the file {_1} 版がファイルの外にあります +@msg this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that これはバイナリ DXF ファイルのため読み込めません: CAD アプリで ASCII DXF として保存し、そのファイルを開いてください +@msg this warp's points can't be edited: use Reset with Mesh first このワープのポイントは編集できません: 先に「メッシュでリセット」を使用してください +@msg toExponential() digits must be between 0 and 20 toExponential() の桁数には 0 から 20 までの値を指定してください +@msg toFixed() digits must be between 0 and 20 toFixed() の桁数には 0 から 20 までの値を指定してください +@msg toISOString is not a function toISOString は関数ではありません +@msg toPrecision() argument must be between 1 and 21 toPrecision() の引数には 1 から 21 までの値を指定してください +@msg toSource: a getter cannot be written as source toSource: ゲッターはソースとして書き出せません +@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: 要素以外のプロパティ、列挙可能でないプロパティ、または別のプロトタイプを持つ配列は、ソースとして書き出せません +@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: 列挙可能でないプロパティを持つオブジェクトや、プロトタイプが Object.prototype ではないオブジェクトは、ソースとして書き出せません +@msg toSource: the source is too long toSource: ソースが長すぎます +@msg toSource: the value contains itself toSource: 値が自身を含んでいます +@msg toSource: the value has too many entries toSource: 値のエントリが多すぎます +@msg toSource: the value is nested too deep toSource: 値の入れ子が深すぎます +@msg toString is not a function toString は関数ではありません +@msg too many arguments 引数が多すぎます +@msg too many quantifiers 量指定子が多すぎます +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) `{t}` は不明な OpenType 機能です (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt。オフにするには先頭に - を付けます。swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt には、`salt=2` のように `tag=N` の形式で代替字形の番号を指定できます。N は 0 から {_1} まで) +@msg unknown dither `{d}` (diffusion, pattern, noise or none) `{d}` は不明なディザの指定です (diffusion、pattern、noise、none のいずれか) +@msg unknown panel `{panel}` `{panel}` は不明なパネルです +@msg unknown parameter `{key}` `{key}` は不明なパラメーターです +@msg unknown parameter `{within}settings.{format}`; {why} `{within}settings.{format}` は不明なパラメーターです。{why} +@msg unknown parameter `{within}{key}`; {why} `{within}{key}` は不明なパラメーターです。{why} +@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) `{reduction}` は不明な減色の指定です (selective、perceptual、adaptive、web、gray、blackWhite、uniform、document のいずれか) +@msg unmatched ')' 対応していない ')' があります +@msg unterminated character class 文字クラスが閉じられていません +@msg unterminated group グループが閉じられていません +@msg wordGap and lineGap need area type in the selection wordGap と lineGap を使用するには、選択範囲にエリア内文字が必要です +@msg xpath needs its variables as an object xpath の変数にはオブジェクトを指定してください +@msg xpath needs the expression as a string xpath の式には文字列を指定してください +@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED}。また、設定とキャッシュを {_1} から {_2} に戻せません: {e} +@msg {_1} is not a folder: {e} {_1} はフォルダーではありません: {e} +@msg {_1}: {_2} and {_3} are in the way {_1}: {_2} と {_3} が妨げになっています +@msg {code}: {0} {code}: {0} +@msg {dir}: {e} {dir}: {e} +@msg {file} names more than one linked JavaScript file {file} にリンクされた JavaScript ファイルが複数指定されています +@msg {k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip {k}: カスタム矢印のパスには塗りつぶせる領域が必要で、アンカーは最大 {most} 個、サブパスは最大 {most} 個、各アンカーは先端から矢印単位で {far} 以内に収めてください +@msg {k}: bad custom arrowhead path: {e} {k}: カスタム矢印のパスが正しくありません: {e} +@msg {k}: bad custom arrowhead: {e} {k}: カスタム矢印が正しくありません: {e} +@msg {k}: custom must be a name {k}: custom には名前を指定してください +@msg {name} must be a number {name} には数値を指定してください +@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: copy で DCS ファイルを使用できるのは、版がファイル内にある場合だけです。版ファイルのある場所でそのファイルに再リンクしてください +@msg {path}: larger than {_1} MB {path}: {_1} MB を超えています +@msg {shown} is not an XPath 1.0 function {shown} は XPath 1.0 の関数ではありません +@msg {v} names no layer or sublayer {v} に該当するレイヤーまたはサブレイヤーはありません +@msg {what} is not a function {what} は関数ではありません +@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what} では 1 枚の用紙に {MAX_COPIES_PER_SHEET} 部を超えてプリントされます: 拡大してください +@msg {what} {i} out of range {what} {i} が範囲外です +@msg A profile supports one Revolve effect. Edit the existing effect's options instead. 1 つのプロファイルに適用できる回転体効果は 1 つです。既存の効果のオプションを編集してください。 +@msg Add Points at Crossings: a path went missing 交差点にポイントを追加: パスが見つからなくなりました +@msg CSS options: {e} CSS オプション: {e} +@msg CSV has a header but no data rows CSV にヘッダーはありますが、データ行がありません +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark CSV に NUL バイトが含まれています: UTF-8、またはバイトオーダーマーク付きの UTF-16 で保存してください +@msg CSV is empty CSV が空です +@msg CSV is larger than {MAX_CSV_BYTES} bytes CSV が {MAX_CSV_BYTES} バイトを超えています +@msg CSV is not UTF-16 CSV が UTF-16 ではありません +@msg CSV is not UTF-8 CSV が UTF-8 ではありません +@msg CSV would be larger than {MAX_CSV_BYTES} bytes CSV が {MAX_CSV_BYTES} バイトを超えます +@msg Choose original artwork, not a dimension's generated paths 寸法から生成されたパスではなく、元のアートワークを選択してください +@msg Contour: select paths or shapes 輪郭: パスまたはシェイプを選択してください +@msg Contour: the Offset Path effect is not available 輪郭: 「パスのオフセット」効果を使用できません +@msg Create needs fills, strokes or both 「作成」には塗り、線、またはその両方が必要です +@msg DXF options: {e} DXF オプション: {e} +@msg DXF scale must be a positive number up to {_1}, not {scale} DXF の拡大・縮小率には {_1} 以下の正の数値を指定してください ({scale} は指定できません) +@msg DXF {field} `{s}`: {allowed} DXF {field} `{s}`: {allowed} +@msg Dimension is locked or hidden 寸法がロックされているか非表示です +@msg Dimension mode does not match its source 寸法のモードが元のオブジェクトと一致しません +@msg Draw Inside needs exactly one selected path, compound path or text 「内側描画」を使用するには、パス、複合パス、テキストのいずれかを 1 つだけ選択してください +@msg EPS Save writes all artwork: use document.export for selected-only EPS EPS の保存ではすべてのアートワークを書き出します: 選択部分だけの EPS には document.export を使用してください +@msg EPS flattener: {e} EPS の分割・統合: {e} +@msg EPS options: {e} EPS オプション: {e} +@msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} +@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft 「スクリーン用に書き出し」で書き出せるのは png、png8、jpg、webp、gif、tiff、bmp、tga、psd、svg、svgz、pdf、eps、ai、vectorcraft です +@msg Flare click coordinates must be finite numbers フレアのクリック座標には有限の数値を指定してください +@msg HTML Text applies to object slices of type only 「HTML テキスト」を適用できるのは文字のオブジェクトスライスだけです +@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) 「オブジェクトの識別方法」が XML ID になっています: 名前には有効な XML ID を指定してください (先頭は文字、アンダースコア、コロンのいずれか。スペースは使用できません) +@msg Image Trace failed: internal error: its thread stopped (please report this bug) 画像トレースに失敗しました: 内部エラー: スレッドが停止しました (このバグを報告してください) +@msg Image Trace failed: {e} 画像トレースに失敗しました: {e} +@msg Image Trace finished, but its document was closed 画像トレースは完了しましたが、ドキュメントが閉じられています +@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again 画像トレースは完了しましたが、その間に画像または設定が変更されました: もう一度トレースしてください +@msg Invalid dimension offset 寸法のオフセットが無効です +@msg Invalid point 点が無効です +@msg Join: a subpath went missing 連結: サブパスが見つからなくなりました +@msg Lock Workspace is not available in VectorCraft 「ワークスペースをロック」は VectorCraft では使用できません +@msg Move Panel to New Window is not available: floating panels stay in the main window 「パネルを新規ウィンドウに移動」は使用できません: フローティングパネルはメインウィンドウ内に表示されます +@msg Offset Path: the path is too complex to offset パスのオフセット: パスが複雑すぎてオフセットできません +@msg Offset Path: {e} パスのオフセット: {e} +@msg PDF content is incomplete or exceeds the reader's limits; no document was imported PDF の内容が不完全か、読み込み機能の上限を超えています。ドキュメントは読み込まれませんでした +@msg PDF flattener: {e} PDF の分割・統合: {e} +@msg PDF options: {e} PDF オプション: {e} +@msg Recolor Artwork is no longer open 「アートワークを再配色」はすでに閉じられています +@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG が UTF-8、UTF-16、ISO 8859-1 のいずれでもありません +@msg Save writes one artboard: name one, or export several with document.export 保存で書き出せるアートボードは 1 つです: 1 つ指定するか、複数の場合は document.export で書き出してください +@msg Select a dimension first 先に寸法を選択してください +@msg Select dimensions to update 更新する寸法を選択してください +@msg Select only dimensions 寸法だけを選択してください +@msg Select two or more objects, or a group 2 つ以上のオブジェクト、またはグループを選択してください +@msg Shaper needs 1–64 source paths Shaper には 1~64 個の元パスが必要です +@msg Switch x/y is for scatter graphs 「X/Y を入れ替え」は散布図用です +@msg Text options: {e} テキストオプション: {e} +@msg `align` is start, center or end `align` には start、center、end のいずれかを指定してください +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` には [subpath, anchor] の組の配列を指定してください +@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` にはアンカーのインデックス (0, 1, 2…) を指定してください ({k} は指定できません) +@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` にはアンカーのインデックス (0, 1, 2…) を指定してください ({v} は指定できません) +@msg `artboards` must be a list of artboard indices `artboards` にはアートボードのインデックスのリストを指定してください +@msg `asset` is the asset id (see assets.list) `asset` にはアセット ID を指定してください (assets.list を参照) +@msg `at` must be a byte offset `at` にはバイトオフセットを指定してください +@msg `autoLeading` must be a number `autoLeading` には数値を指定してください +@msg `background` must be "", "matte" or #rrggbb `background` には ""、"matte"、#rrggbb のいずれかを指定してください +@msg `burasagari` must be "none", "standard" or "forced" `burasagari` には "none"、"standard"、"forced" のいずれかを指定してください +@msg `center` must be [x, y] `center` には [x, y] を指定してください +@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign` には "romanBaseline"、"emBoxTop"、"emBoxCenter"、"emBoxBottom"、"icfTop"、"icfBottom" のいずれかを指定してください +@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection` には "default"、"leftToRight"、"rightToLeft" のいずれかを指定してください +@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` `clearStroke` には `stroke: false` と `ids: []` が必要で、`stop` は指定できません +@msg `clear` must be a list of type object ids, not {v} `clear` には文字オブジェクトの ID のリストを指定してください ({v} は指定できません) +@msg `clear` must list type objects; {_1} is not one `clear` には文字オブジェクトを指定してください。{_1} は文字オブジェクトではありません +@msg `clear` must not list `id` `clear` に `id` を含めることはできません +@msg `color` needs a `rule` to make the theme from `color` には、テーマの作成に使用する `rule` が必要です +@msg `colors` is empty `colors` が空です +@msg `columnType` is vertical, uniform, repeating or sliding `columnType` には vertical、uniform、repeating、sliding のいずれかを指定してください +@msg `columns` must be a whole number 1..50 `columns` には 1..50 の整数を指定してください +@msg `commands` must not be empty `commands` を空にすることはできません +@msg `contents` goes with `to` `contents` は `to` と一緒に指定してください +@msg `corners` must be sharp, round or square, not `{other}` `corners` には sharp、round、square のいずれかを指定してください (`{other}` は指定できません) +@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners` にはアンカーのインデックス (0, 1, 2…) を指定してください ({v} は指定できません) +@msg `count` must be a whole number from 1 to 100 `count` には 1 から 100 までの整数を指定してください +@msg `dataBase64` is larger than the module limit `dataBase64` がモジュールの上限を超えています +@msg `dataBase64` is not valid base64 `dataBase64` が有効な base64 ではありません +@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly` は `ids`、`reverse`、`append` と同時に使用できません +@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` と `childLimit` はサマリーを絞り込みます: `summary: true` を指定してください +@msg `design` is a design name, or null for plain columns `design` にはデザイン名を指定します。通常の棒にするには null を指定します +@msg `design` is a design name, or null for the default marker `design` にはデザイン名を指定します。初期設定のマーカーにするには null を指定します +@msg `destinationFolder` is required when destination is `folder` destination が `folder` の場合は `destinationFolder` が必要です +@msg `destination` must be `none`, `saveAndClose` or `folder` `destination` には `none`、`saveAndClose`、`folder` のいずれかを指定してください +@msg `diameter` must be from 0.1 to 2500 pt `diameter` には 0.1 から 2500 pt までの値を指定してください +@msg `digits` must be one of {_1} `digits` には {_1} のいずれかを指定してください +@msg `dimImages` is a percentage or false `dimImages` にはパーセント値または false を指定してください +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` には "auto"、"leftToRight"、"rightToLeft" のいずれかを指定してください +@msg `each` is the value one design stands for, more than 0 `each` にはデザイン 1 つが表す値を 0 より大きい数で指定してください +@msg `end` must be "start" or "end" `end` には "start" または "end" を指定してください +@msg `ends` applies to joining exactly two paths `ends` を使用できるのは、ちょうど 2 つのパスを連結する場合だけです +@msg `event` must be one of {_1} `event` には {_1} のいずれかを指定してください +@msg `every` must be "all", "odd" or "even", not {v} `every` には "all"、"odd"、"even" のいずれかを指定してください ({v} は指定できません) +@msg `expand` must be true or false `expand` には true または false を指定してください +@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` には、直前の text.findFontFiles の検索で見つかったフォントファイルのパスを 1 ~ 1000 個指定してください +@msg `folder` must be an absolute folder path `folder` にはフォルダーの絶対パスを指定してください +@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` には、{"wght"} のような軸タグと値のオブジェクトを指定してください +@msg `format` must be a string `format` には文字列を指定してください +@msg `format` must be one of {_1} `format` には {_1} のいずれかを指定してください +@msg `fractions` is chop or scale `fractions` には chop または scale を指定してください +@msg `from` and `to` must differ `from` と `to` には異なる値を指定してください +@msg `from` has an empty frame `from` に空のフレームがあります +@msg `from` must be a non-negative integer `from` には 0 以上の整数を指定してください +@msg `from` must be area type or type on a path `from` にはエリア内文字またはパス上文字を指定してください +@msg `gid` must be a glyph id, not {v} `gid` にはグリフ ID を指定してください ({v} は指定できません) +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate` は 1 つの文字に設定するもので、文字スタイルには設定できません (text.setGlyphAlternate で設定します) +@msg `guide` must be a guide index (guide.list) `guide` にはガイドのインデックスを指定してください (guide.list) +@msg `hAlign` must be default, left, center or right `hAlign` には default、left、center、right のいずれかを指定してください +@msg `id` must be a node id `id` にはノード ID を指定してください +@msg `id` must be an object id, not {v} `id` にはオブジェクト ID を指定してください ({v} は指定できません) +@msg `ids` must name Objects on Path groups or objects in them `ids` には「パス上のオブジェクト」グループ、またはその中のオブジェクトを指定してください +@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids` に指定できる ID は最大 {MAX_INSPECT_IDS} 個です +@msg `imageMap` must be none, rectangle or polygon `imageMap` には none、rectangle、polygon のいずれかを指定してください +@msg `includeArtboardFill` must be true or false `includeArtboardFill` には true または false を指定してください +@msg `includeSubdirs` must be true or false `includeSubdirs` には true または false を指定してください +@msg `index` must be a point index `index` にはポイントのインデックスを指定してください +@msg `index` must be a whole number or null `index` には整数または null を指定してください +@msg `indices` must be a non-empty array of item indices `indices` には項目のインデックスを 1 つ以上含む配列を指定してください +@msg `items` is a list of {kind, item} `items` には {kind, item} のリストを指定してください +@msg `items` is empty `items` が空です +@msg `items` must be a list of {id, points} `items` には {id, points} のリストを指定してください +@msg `items` must be a list of {{id, anchors?}}, not {v} `items` には {{id, anchors?}} のリストを指定してください ({v} は指定できません) +@msg `items` must be an array of {id, anchors} `items` には {id, anchors} の配列を指定してください +@msg `items` must be an array of {{id, corners?}}, not {v} `items` には {{id, corners?}} の配列を指定してください ({v} は指定できません) +@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` には "none"、"short"、"medium"、"long"、"stylistic" のいずれかを指定してください +@msg `kind` is one of {_1} `kind` には {_1} のいずれかを指定してください +@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` には "round"、"invertedRound"、"chamfer" のいずれかを指定してください ({v} は指定できません) +@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind` には `text`、`visibility`、`image`、`graph` のいずれかを指定してください +@msg `kind` must be a string `kind` には文字列を指定してください +@msg `kind` must be image, noImage or htmlText `kind` には image、noImage、htmlText のいずれかを指定してください +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` には "none"、"hard"、"soft" のいずれかを指定してください +@msg `label` must be a non-empty name of at most 200 characters `label` には 200 文字以内の空でない名前を指定してください +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` には、"Turkish" や "tr" のような言語名またはタグを指定してください +@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` には "romanBaseline" または "emBoxTop" を指定してください +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` には 0.1 から 5000 までの値を指定してください。自動にするには null を指定します +@msg `length` must be a number of at most 1000000 pt either way `length` には正負いずれの方向でも 1000000 pt 以内の数値を指定してください +@msg `line` must be true or false `line` には true または false を指定してください +@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°) を `maxAngle` ({max_angle}°) より大きくすることはできません +@msg `mode` must be "handles", "straight" or "smooth" `mode` には "handles"、"straight"、"smooth" のいずれかを指定してください +@msg `mode` must be "straight", "bezier" or "arc" `mode` には "straight"、"bezier"、"arc" のいずれかを指定してください +@msg `mode` must be one of {_1} `mode` には {_1} のいずれかを指定してください +@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi` には "none"、"yakumonoHankaku"、"gyomatsuYakumonoHankaku"、"gyomatsuYakumonoZenkaku"、"yakumonoZenkaku"、"lineEndHalf" のいずれかを指定してください +@msg `names` is a list of preset names `names` にはプリセット名のリストを指定してください +@msg `names` lists preset names `names` にはプリセット名を指定してください +@msg `names` must be a list of names `names` には名前のリストを指定してください +@msg `names` must be a list of swatch names `names` にはスウォッチ名のリストを指定してください +@msg `names` must be an array of style names `names` にはスタイル名の配列を指定してください +@msg `new` is a design name, or true `new` にはデザイン名または true を指定してください +@msg `path` must be a folder `path` にはフォルダーを指定してください +@msg `path` must be a path object `path` にはパスオブジェクトを指定してください +@msg `pivot` must be a whole number from 0 to 8 `pivot` には 0 から 8 までの整数を指定してください +@msg `points` must be a list of [x, y] points `points` には [x, y] の点のリストを指定してください +@msg `points` must be a list of point indexes `points` にはポイントのインデックスのリストを指定してください +@msg `points` must be an array of point indices `points` にはポイントのインデックスの配列を指定してください +@msg `points` must be point indexes `points` にはポイントのインデックスを指定してください +@msg `port` must be "in" or "out" `port` には "in" または "out" を指定してください +@msg `position` is above or below `position` には above または below を指定してください +@msg `preset` must be a string `preset` には文字列を指定してください +@msg `ratio` must be from 0.1 to 0.5 `ratio` には 0.1 から 0.5 までの値を指定してください +@msg `rect` must be [x, y, width, height] with a positive size `rect` には、サイズが正の [x, y, width, height] を指定してください +@msg `relation` must be overlap, inside or outside `relation` には overlap、inside、outside のいずれかを指定してください +@msg `reversed` must be true or false `reversed` には true または false を指定してください +@msg `rotate` must be a finite number `rotate` には有限の数値を指定してください +@msg `rule` must be nonZero or evenOdd `rule` には nonZero または evenOdd を指定してください +@msg `scale` must be a number in (0, 100] `scale` には (0, 100] の範囲の数値を指定してください +@msg `scope` must be "selection" or "document", not {other} `scope` には "selection" または "document" を指定してください ({other} は指定できません) +@msg `script` must be a string `script` には文字列を指定してください +@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` には系列のインデックスを 1 つ以上含むリストを指定してください +@msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes` には `type` または `valueAxis` が必要です +@msg `shift` must be a number of points `shift` にはポイント単位の数値を指定してください +@msg `showCenter` must be true or false `showCenter` には true または false を指定してください +@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` には "fullJustify"、"alignLeft"、"alignCenter"、"alignRight" のいずれかを指定してください +@msg `size` is a percentage of the base text's size, 1 to 100 `size` には、親文字のサイズに対する割合を 1 から 100 までのパーセント値で指定してください +@msg `source` and `target` are the same object `source` と `target` が同じオブジェクトです +@msg `source` must be `folder` or `datasets` `source` には `folder` または `datasets` を指定してください +@msg `spacing` must be a number `spacing` には数値を指定してください +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` には 0 以上のポイント単位の数値、または "auto" を指定してください +@msg `start` must be a finite number `start` には有限の数値を指定してください +@msg `starts` must be an array of anchor indices (or nulls) `starts` にはアンカーのインデックス (または null) の配列を指定してください +@msg `status` must be linked, embedded, missing or modified, or a list of them `status` には linked、embedded、missing、modified のいずれか、またはそのリストを指定してください +@msg `stop` must be a stop index `stop` には分岐点のインデックスを指定してください +@msg `story` styles the whole story: give no `start` or `end` `story` はストーリー全体にスタイルを設定します: `start` と `end` は指定しないでください +@msg `story` takes the whole story: give no `start` or `end` `story` はストーリー全体を対象にします: `start` と `end` は指定しないでください +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` は線に適用されます (stroke: true) +@msg `style` must be roman or japanese `style` には roman または japanese を指定してください +@msg `subpaths` must be two integer indices `subpaths` には整数のインデックスを 2 つ指定してください +@msg `symbol` must be a symbol name `symbol` にはシンボル名を指定してください +@msg `target` must be "object" or "contents", not {v} `target` には "object" または "contents" を指定してください ({v} は指定できません) +@msg `tint` applies to global and spot colours `tint` はグローバルカラーと特色に適用されます +@msg `tint` goes with a global or spot `swatch` `tint` はグローバルまたは特色の `swatch` と一緒に指定してください +@msg `tint` must be a number (%) `tint` には数値 (%) を指定してください +@msg `tint` needs a global or spot colour `tint` にはグローバルカラーまたは特色が必要です +@msg `to` is already threaded `to` はすでにスレッドされています +@msg `to` is the design's new name `to` にはデザインの新しい名前を指定してください +@msg `to` must be [x, y] `to` には [x, y] を指定してください +@msg `to` must be a closed path, area type or type on a path other than `from` `to` には、`from` 以外のクローズパス、エリア内文字、パス上文字のいずれかを指定してください +@msg `to` must be a whole number `to` には整数を指定してください +@msg `to` takes the rows of one object's stack, not aligned rows `to` には、整列した行ではなく、1 つのオブジェクトのスタック内の行を指定してください +@msg `useLab` must be true or false `useLab` には true または false を指定してください +@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign` には default、top、middle、baseline、bottom のいずれかを指定してください +@msg `values` must be a list `values` にはリストを指定してください +@msg `values` must be an object `values` にはオブジェクトを指定してください +@msg `values` must name hex, rgb, cmyk, lab or gray `values` には hex、rgb、cmyk、lab、gray のいずれかを指定してください +@msg `values` names a kind twice `values` に同じ種類が 2 回指定されています +@msg `{_1}` can't be edited or removed `{_1}` は編集も削除もできません +@msg `{_1}` is built in and always listed `{_1}` は組み込みで、常に一覧に表示されます +@msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}` は、直前の text.findFontFiles の検索で見つかったフォントファイルではありません +@msg `{_1}` would be overwritten by the package: choose a different folder or name `{_1}` がパッケージで上書きされます: 別のフォルダーまたは名前を選択してください +@msg `{file}` has no art to place `{file}` に配置できるアートがありません +@msg `{file}` is over {_1} MB `{file}` が {_1} MB を超えています +@msg `{f}` belongs to a VectorCraft that is running `{f}` は実行中の VectorCraft が使用しています +@msg `{f}` is the copy of a document open now `{f}` は現在開いているドキュメントのコピーです +@msg `{id}` is no writable format (see document.formats) `{id}` は書き出せる形式ではありません (document.formats を参照) +@msg `{id}` isn't a loaded library `{id}` は読み込まれているライブラリではありません +@msg `{key}` has no colours `{key}` にカラーがありません +@msg `{key}` is a design name `{key}` にはデザイン名を指定してください +@msg `{key}` is a design name or a list of them `{key}` にはデザイン名またはそのリストを指定してください +@msg `{key}` is longer than {MAX_NAME} characters `{key}` が {MAX_NAME} 文字を超えています +@msg `{key}` is out of range `{key}` が範囲外です +@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}` には [min, desired, max] または {{min?, desired?, max?}} をパーセントで指定してください +@msg `{key}` must be a non-negative integer `{key}` には 0 以上の整数を指定してください +@msg `{key}` must be a number {_1}..{_2} `{key}` には {_1}..{_2} の数値を指定してください +@msg `{key}` must be a whole number of at least 0, not {n} `{key}` には 0 以上の整数を指定してください ({n} は指定できません) +@msg `{key}` must be an appearance item index (paint order) or null `{key}` にはアピアランス項目のインデックス (ペイント順) または null を指定してください +@msg `{key}` must be an array of asset ids (see assets.list) `{key}` にはアセット ID の配列を指定してください (assets.list を参照) +@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}` には 0 から 180 度までの値を指定してください ({a} は指定できません) +@msg `{key}` must be from {_1} to {shift_max} `{key}` には {_1} から {shift_max} までの値を指定してください +@msg `{key}`: not a colour: {v} `{key}`: カラーではありません: {v} +@msg `{k}.family` is required `{k}.family` は必須です +@msg `{k}` is a number 0..1 `{k}` には 0..1 の数値を指定してください +@msg `{k}` is true or false `{k}` には true または false を指定してください +@msg `{k}` isn't an export setting: {_1} `{k}` は書き出し設定ではありません: {_1} +@msg `{k}` must be a byte offset `{k}` にはバイトオフセットを指定してください +@msg `{k}` must be a number `{k}` には数値を指定してください +@msg `{k}` must be a number of points `{k}` にはポイント単位の数値を指定してください +@msg `{k}` must be a string `{k}` には文字列を指定してください +@msg `{k}` must be a whole number `{k}` には整数を指定してください +@msg `{k}` must be from {lo} to {hi} for {key} {key} の `{k}` には {lo} から {hi} までの値を指定してください +@msg `{k}` must be {what}, not {v} `{k}` には {what} を指定してください ({v} は指定できません) +@msg `{l}` isn't a global or spot colour `{l}` はグローバルカラーでも特色でもありません +@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText `{m}` はビットマップではありません: clipboard.importSvg、importPdf、importText のいずれかを使用してください +@msg `{name}` can't be deleted `{name}` は削除できません +@msg `{name}` can't be duplicated `{name}` は複製できません +@msg `{name}` can't be edited `{name}` は編集できません +@msg `{name}` can't be edited this way `{name}` はこの方法では編集できません +@msg `{name}` can't be renamed `{name}` は名前を変更できません +@msg `{name}` can't name a folder `{name}` にフォルダーは指定できません +@msg `{name}` has no art to place `{name}` に配置できるアートがありません +@msg `{name}` has no text to place `{name}` に配置できるテキストがありません +@msg `{name}` in the Word document is not UTF-8 or UTF-16 text Word 文書内の `{name}` が UTF-8 または UTF-16 のテキストではありません +@msg `{name}` in the Word document is not well-formed XML: {e} Word 文書内の `{name}` が整形式の XML ではありません: {e} +@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep Word 文書内の `{name}` で要素の入れ子が {MAX_NESTING} 階層を超えています +@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` は組み込みのプリセットで、読み取り専用です: 別の名前で保存してください +@msg `{name}` is a built-in preset: save under another name `{name}` は組み込みのプリセットです: 別の名前で保存してください +@msg `{name}` is a built-in profile and stays `{name}` は組み込みのプロファイルのため、削除されません +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` は `{base}` の濃度違いです: `{base}` を編集してください (濃度スウォッチで変更できるのは名前だけです) +@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` は作業用スペースです: 先に edit.colorSettings で別のものを選択してください +@msg `{name}` is built in and stays `{name}` は組み込みのため、削除されません +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` には同じ種類のペイント (カラー、グラデーション、パターン) を指定してください +@msg `{n}` can't be deleted `{n}` は削除できません +@msg `{n}` can't be merged `{n}` は結合できません +@msg `{n}` can't be moved `{n}` は移動できません +@msg `{n}` is not a colour swatch `{n}` はカラースウォッチではありません +@msg `{n}` isn't a solid colour `{n}` は単色ではありません +@msg `{n}`: colour groups hold solid colours only `{n}`: カラーグループに入れられるのは単色だけです +@msg `{other}` doesn't trace an image `{other}` では画像をトレースできません +@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: このステップの参照でコピーされる値が {_1} MB を超えています +@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" `{tag}` は軸タグではありません: タグは "wght" のような 4 文字です +@msg `{to}` is empty or already used `{to}` が空か、すでに使用されています +@msg `{what}` is longer than {MAX_TEXT} bytes `{what}` が {MAX_TEXT} バイトを超えています +@msg `{what}` must be text `{what}` にはテキストを指定してください +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard DXF 図面の原点は 1 つです: アートボードごとに 1 つの図面にするには useArtboards: true を指定してください +@msg a PDF presets file with over {_1} presets PDF プリセットファイルに {_1} 個を超えるプリセットがあります +@msg a batch preview cannot switch documents バッチのプレビューではドキュメントを切り替えられません +@msg a brush named `{nn}` exists `{nn}` という名前のブラシがすでにあります +@msg a container of the listed objects is gone 指定したオブジェクトを含むコンテナがなくなりました +@msg a content stream can't be read コンテンツストリームを読み込めません +@msg a content stream isn't a stream コンテンツストリームがストリームではありません +@msg a design holds at most {_1} objects デザインに含められるオブジェクトは最大 {_1} 個です +@msg a design needs a name デザインには名前が必要です +@msg a dictionary grew too large 辞書が大きくなりすぎました +@msg a document keeps at most {_1} graph designs ドキュメントに保存できるグラフのデザインは最大 {_1} 個です +@msg a document keeps at most {_1} saved selections ドキュメントに保存できる選択範囲は最大 {_1} 個です +@msg a document keeps at most {_1} views ドキュメントに保存できる表示は最大 {_1} 個です +@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files ドキュメントにリンクできる JavaScript ファイルは最大 {MAX_SVG_SCRIPTS} 個です +@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes feConvolveMatrix フィルターの order が {x}×{y} で、usvg が行うカーネルサイズの乗算がオーバーフローします +@msg a gradient preset is already called {n} {n} という名前のグラデーションプリセットがすでにあります +@msg a graph has at most {_1} series, so it transposes with at most that many categories グラフの系列は最大 {_1} 個のため、行と列を入れ替えられるのは項目がその数以下の場合です +@msg a layer can't be merged into its own sublayer レイヤーを自身のサブレイヤーに結合することはできません +@msg a layer's form box isn't an array レイヤーのフォームのボックスが配列ではありません +@msg a layer's form can't be read レイヤーのフォームを読み込めません +@msg a layer's form has no box レイヤーのフォームにボックスがありません +@msg a layer's form has no dictionary レイヤーのフォームに辞書がありません +@msg a legend draws at most {MAX_CHIPS} swatches 凡例に描画できるスウォッチは最大 {MAX_CHIPS} 個です +@msg a name names one asset: give multiple: false, or one object 名前で指定できるアセットは 1 つです: multiple: false を指定するか、オブジェクトを 1 つにしてください +@msg a page can't be read ページを読み込めません +@msg a page object is used by something else ページオブジェクトが別のものに使用されています +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) ページのサムネールには、1 辺が 1 から {THUMBNAIL_SIZE} までの RGB ピクセルを指定してください (指定値: {w} × {h}) +@msg a pattern layer doesn't end what it starts パターンレイヤーで、開始したものが終了していません +@msg a pattern layer's colors hold something else パターンレイヤーのカラーに別のものが含まれています +@msg a pattern named `{n}` exists `{n}` という名前のパターンがすでにあります +@msg a plug-in with id {_1} is already installed ID が {_1} のプラグインはすでにインストールされています +@msg a preset named `{n}` exists `{n}` という名前のプリセットがすでにあります +@msg a print presets file with over {_1} presets プリントプリセットファイルに {_1} 個を超えるプリセットがあります +@msg a profile is already called {n} {n} という名前のプロファイルがすでにあります +@msg a row can't go into itself 行を自身の中に入れることはできません +@msg a series goes on the left or the right value axis 系列は左または右の数値軸に割り当てます +@msg a start must be an anchor index ≥ 0 or null 開始位置には 0 以上のアンカーのインデックスまたは null を指定してください +@msg a style named `{name}` already exists `{name}` という名前のスタイルがすでにあります +@msg a style named `{to}` already exists `{to}` という名前のスタイルがすでにあります +@msg a swatch needs a colour, gradient or pattern スウォッチにはカラー、グラデーション、パターンのいずれかが必要です +@msg a symbol named `{n}` already exists `{n}` という名前のシンボルがすでにあります +@msg a theme has 1 to {THEME_SIZE} colours テーマのカラーは 1 から {THEME_SIZE} 色までです +@msg a theme is already called {name} (replace: "{name}" overwrites it) {name} という名前のテーマがすでにあります (replace: "{name}" で上書きできます) +@msg a toolbar name has at most {MAX_NAME} characters ツールバー名は {MAX_NAME} 文字以内です +@msg a view named `{_1}` exists `{_1}` という名前の表示がすでにあります +@msg a view named `{name}` exists `{name}` という名前の表示がすでにあります +@msg a workspace keeps at most {MAX} toolbars ワークスペースに保存できるツールバーは最大 {MAX} 個です +@msg a {_1} series can't go in a {_2} graph {_1} の系列は {_2} グラフに入れられません +@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF 互換、初期設定) または "7" (旧形式の .ai、バージョン 7) を指定してください +@msg already editing an opacity mask すでに不透明マスクを編集中です +@msg already editing another object's opacity mask すでに別のオブジェクトの不透明マスクを編集中です +@msg already in pattern editing mode すでにパターン編集モードです +@msg an artboard is listed twice 同じアートボードが 2 回指定されています +@msg an empty artboard has no width or height to scale to 空のアートボードには、拡大・縮小の基準となる幅も高さもありません +@msg an image is too large for DXF and was left out 画像が DXF には大きすぎるため、除外しました +@msg an image of {_1} MB, at most {_2} MB 画像が {_1} MB あります (上限は {_2} MB) +@msg an image of {declared_w} × {declared_h} pixels is too large to draw {declared_w} × {declared_h} ピクセルの画像は大きすぎて描画できません +@msg an image of {width} × {height} pixels is too large to draw {width} × {height} ピクセルの画像は大きすぎて描画できません +@msg an index must be a whole number インデックスには整数を指定してください +@msg an inline graphic has no glyphs インライングラフィックにグリフがありません +@msg an invisible text form can't be read 非表示テキストのフォームを読み込めません +@msg an object is missing オブジェクトが見つかりません +@msg an object's header is missing オブジェクトのヘッダーがありません +@msg an overprinting form can't be read オーバープリントのフォームを読み込めません +@msg an unread object has no readable bounds 未読み込みのオブジェクトに、読み取れる境界がありません +@msg an unread object holds a frame of threaded type 未読み込みのオブジェクトに、スレッドテキストのフレームが含まれています +@msg an unread object is missing from its layer 未読み込みのオブジェクトがレイヤーにありません +@msg anchor `smooth` must be true or false アンカーの `smooth` には true または false を指定してください +@msg anchor `{key}` must be [x, y] アンカーの `{key}` には [x, y] を指定してください +@msg anchor `{key}` must be a number アンカーの `{key}` には数値を指定してください +@msg angle and roundness must be finite angle と roundness には有限の値を指定してください +@msg angles must be a list as long as pins (degrees or null) angles には pins と同じ長さのリスト (度数または null) を指定してください +@msg appearance must be flat|center|edge appearance には flat|center|edge のいずれかを指定してください +@msg apply is "fill" or "stroke", not `{other}` apply には "fill" または "stroke" を指定してください (`{other}` は指定できません) +@msg area must be a closed polygon of 3 to 100000 [x, y] points area には 3 から 100000 個の [x, y] の点からなる閉じた多角形を指定してください +@msg area type needs a path that encloses an area エリア内文字には、領域を囲むパスが必要です +@msg area type {_1} is named twice エリア内文字 {_1} が 2 回指定されています +@msg arrowAlign must be extend|tip, got {o} arrowAlign には extend|tip のいずれかを指定してください (指定値: {o}) +@msg arrowScale must be [start, end] arrowScale には [start, end] を指定してください +@msg artboard is a 0-based index artboard には 0 から始まるインデックスを指定してください +@msg artboard must be a 0-based index artboard には 0 から始まるインデックスを指定してください +@msg artboard {i} doesn't print アートボード {i} はプリントされません +@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout には {layout?, columns?, spacing?, rightToLeft?} を指定してください +@msg artboardLayout.columns must be 1 or more artboardLayout.columns には 1 以上の値を指定してください +@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout には gridByRow、gridByColumn、row、column のいずれかを指定してください +@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft には true または false を指定してください +@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing には 0–{MAX_SPACING} pt を指定してください +@msg artboards must be a non-empty list artboards には空でないリストを指定してください +@msg artboards must be a number artboards には数値を指定してください +@msg asset `{_1}` has no art to export アセット `{_1}` に書き出せるアートがありません +@msg at most {MAX_KEYWORDS} keywords キーワードは最大 {MAX_KEYWORDS} 個です +@msg at most {MAX_MATCHES} matches are kept 保持される一致は最大 {MAX_MATCHES} 件です +@msg at most {MAX_POINTS} points ポイントは最大 {MAX_POINTS} 個です +@msg at most {MAX_QUEUE} files ファイルは最大 {MAX_QUEUE} 個です +@msg at must be [x, y] at には [x, y] を指定してください +@msg at/rect lie outside the canvas at/rect がキャンバスの外にあります +@msg attribute must be size|leading|tracking|kerning|baselineShift attribute には size|leading|tracking|kerning|baselineShift のいずれかを指定してください +@msg attrs must be an object attrs にはオブジェクトを指定してください +@msg automation path rejected: the folder's name isn't valid text 自動処理のパスを拒否しました: フォルダー名が有効なテキストではありません +@msg automation path rejected: {_1} isn't valid text 自動処理のパスを拒否しました: {_1} が有効なテキストではありません +@msg automation path rejected: {why}: {path} 自動処理のパスを拒否しました: {why}: {path} +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis には "vertical"、"horizontal"、または度数の角度を指定してください (`{given}` は指定できません) +@msg backgroundContents must be transparent or white backgroundContents には transparent または white を指定してください +@msg bad AES key AES キーが正しくありません +@msg bad `runs`: {e} `runs` が正しくありません: {e} +@msg bad arrowScale arrowScale が正しくありません +@msg bad base64 base64 が正しくありません +@msg bad color {c} カラー {c} が正しくありません +@msg bad colour カラーが正しくありません +@msg bad colour in `colors` `colors` 内のカラーが正しくありません +@msg bad colour {v} カラー {v} が正しくありません +@msg bad colour {v} in groupColors groupColors 内のカラー {v} が正しくありません +@msg bad cross-reference entry 相互参照エントリが正しくありません +@msg bad cross-reference table 相互参照テーブルが正しくありません +@msg bad current colour {v} 現在のカラー {v} が正しくありません +@msg bad dataBase64 dataBase64 が正しくありません +@msg bad edit 編集内容が正しくありません +@msg bad fill {v} 塗り {v} が正しくありません +@msg bad fit: {e} fit が正しくありません: {e} +@msg bad model {other} (keep, new or document) model {other} が正しくありません (keep、new、document のいずれか) +@msg bad new colour {v} 新しいカラー {v} が正しくありません +@msg bad object オブジェクトが正しくありません +@msg bad object number オブジェクト番号が正しくありません +@msg bad offset オフセットが正しくありません +@msg bad path data: {e} パスデータが正しくありません: {e} +@msg bad point {v}: expected [x, y] or [x, y, pressure] 点 {v} が正しくありません: [x, y] または [x, y, pressure] を指定してください +@msg bad reference 参照が正しくありません +@msg bad trailer size トレーラーのサイズが正しくありません +@msg bad {k} colour {k} のカラーが正しくありません +@msg batches cannot nest バッチを入れ子にすることはできません +@msg bind something to a variable first 先に何かを変数にバインドしてください +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed には 0–{MAX_BLEED} pt を指定してください: 数値、[top, bottom, left, right]、{{top, bottom, left, right}} のいずれか +@msg brush must be a name or null brush には名前または null を指定してください +@msg by must be a number by には数値を指定してください +@msg can't add page thumbnails: {why} ページのサムネールを追加できません: {why} +@msg can't add the editing data for other apps: {why} 他のアプリ用の編集データを追加できません: {why} +@msg can't edit the written PDF: {why} 書き出した PDF を編集できません: {why} +@msg can't encrypt the PDF: {why} PDF を暗号化できません: {why} +@msg can't linearise the PDF: {why} PDF をリニアライズできません: {why} +@msg can't open `{_1}`: {_2} `{_1}` を開けません: {_2} +@msg can't open `{_1}`: {e} `{_1}` を開けません: {e} +@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) `{name}` を開けません: VectorCraft で読み込める形式ではありません (document.formats を参照) +@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) `{name}` を配置できません: VectorCraft で読み込める形式ではありません (document.formats を参照) +@msg can't read back the written PDF: {e} 書き出した PDF を読み戻せません: {e} +@msg can't read this Word document: {why} この Word 文書を読み込めません: {why} +@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp .{e} 画像は書き出せません: .png、.jpg、.gif、.webp、.tif、.bmp のいずれかを使用してください +@msg can't write PDF layers, overprint or invisible text: {why} PDF レイヤー、オーバープリント、非表示テキストを書き出せません: {why} +@msg can't write the file's copyright: {why} ファイルの著作権情報を書き出せません: {why} +@msg can't write the file's modification date: {why} ファイルの更新日を書き出せません: {why} +@msg can't write the pages' user units: {why} ページのユーザー単位を書き出せません: {why} +@msg cannot distort a zero-size or non-finite bounding box サイズが 0 または有限でないバウンディングボックスは変形できません +@msg cannot move an object into itself オブジェクトを自身の中に移動することはできません +@msg case must be upper|lower|title|sentence case には upper|lower|title|sentence のいずれかを指定してください +@msg category `{c}`: one of {_1} category `{c}`: {_1} のいずれかを指定してください +@msg center must be [x, y] center には [x, y] を指定してください +@msg choose at least one artboard アートボードを 1 つ以上選択してください +@msg choose at least one status 状態を 1 つ以上選択してください +@msg clip must be true or false clip には true または false を指定してください +@msg clippingMask must be true or false clippingMask には true または false を指定してください +@msg closed must be a boolean closed にはブール値を指定してください +@msg collapsed must be true or false collapsed には true または false を指定してください +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: 「{profile}」という名前の RGB または CMYK プロファイルはありません +@msg colorMode must be rgb or cmyk, not `{m}` colorMode には rgb または cmyk を指定してください (`{m}` は指定できません) +@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel には {own} (ドキュメントのモード)、grayscale、bitmap のいずれかを指定してください +@msg colour groups can't go into colour groups カラーグループをカラーグループに入れることはできません +@msg colour, mode and spot apply to solid-colour swatches only colour、mode、spot を適用できるのは単色のスウォッチだけです +@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read 列 {column}: `{cell}` はグラフデータファイルの `%` 列で、読み込まれません +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable 列 {column}: `{cell}` が指定している `{name}` は {_1} 変数です +@msg column {column}: `{cell}` names no variable 列 {column}: `{cell}` に該当する変数はありません +@msg column {column}: the variable name is longer than {MAX_NAME} characters 列 {column}: 変数名が {MAX_NAME} 文字を超えています +@msg column: true floats the whole column and takes no onto, above or below column: true は列全体をフローティングにするため、onto、above、below は指定できません +@msg command must be a shape.* command command には shape.* コマンドを指定してください +@msg commands[{i}]: missing `command` commands[{i}]: `command` がありません +@msg composer must be "singleLine" or "everyLine" composer には "singleLine" または "everyLine" を指定してください +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes 複合シェイプに含められるのは、パス、複合パス、グループ、複合シェイプ、文字、ブレンド、エンベロープです +@msg compound {_1} must contain exactly one open path to join 連結するには、複合パス {_1} にオープンパスがちょうど 1 つ含まれている必要があります +@msg connect end must be "start" or "end" connect の end には "start" または "end" を指定してください +@msg connect needs a path other than the one extended connect には、延長したパスとは別のパスが必要です +@msg connect needs id connect には id が必要です +@msg connect path has no open end connect のパスに開いた端点がありません +@msg copy needs path: the file to copy into the document's folder copy には path が必要です: ドキュメントのフォルダーにコピーするファイルを指定してください +@msg copy takes images only: relink placed documents where their files are copy を使用できるのは画像だけです: 配置したドキュメントは、ファイルのある場所で再リンクしてください +@msg copying a file isn't available on the web Web 版ではファイルをコピーできません +@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus には unknown、copyrighted、publicDomain のいずれかを指定してください +@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl には URL を指定してください (スペースなし、2048 文字以内) +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners には [x, y] の点を 4 つ指定してください (TL, TR, BR, BL) +@msg counterPer `{c}`: item or file counterPer `{c}`: item または file を指定してください +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: アート、仕上がり、裁ち落とし、メディアの各ボックスがあるのは PDF ページだけです。"crop" または "bounding" を使用してください +@msg crop rectangle doesn't overlap the image 切り抜きの長方形が画像と重なっていません +@msg cross-reference and object streams aren't supported 相互参照ストリームとオブジェクトストリームには対応していません +@msg damaged compressed data: {e} 圧縮データが破損しています: {e} +@msg dash lengths must be numbers, got {v} ダッシュの長さには数値を指定してください (指定値: {v}) +@msg dash must be a list of lengths or null, got {v} dash には長さのリストまたは null を指定してください (指定値: {v}) +@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) データキー `{k}`: 使用できるのは文字、数字、-、_、. です (name と vc-… は使用できません) +@msg data must be an object of key: value, not {v} data には key: value のオブジェクトを指定してください ({v} は指定できません) +@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare データセット `{name}` に、ライブラリで宣言されていない `{var}` の値があります +@msg data set `{name}` holds two values for `{var}` データセット `{name}` に `{var}` の値が 2 つあります +@msg data set `{name}` is declared twice データセット `{name}` が 2 回宣言されています +@msg data takes at most {MAX_DATA} keys at once data に一度に指定できるキーは最大 {MAX_DATA} 個です +@msg dataset `{name}` already exists データセット `{name}` はすでにあります +@msg dataset `{to}` already exists データセット `{to}` はすでにあります +@msg date `{d}`: YYYY-MM-DD date `{d}`: YYYY-MM-DD の形式で指定してください +@msg decoded data is too large デコードしたデータが大きすぎます +@msg define a variable first 先に変数を定義してください +@msg detect must be true or false detect には true または false を指定してください +@msg direct-select anchor points of a path パスのアンカーポイントをダイレクト選択してください +@msg distance must be > 0 distance には 0 より大きい値を指定してください +@msg dx and dy give the arrow's direction dx と dy には矢印の方向を指定してください +@msg dx and dy must be numbers dx と dy には数値を指定してください +@msg dxf fitTo must be [width, height], positive dxf の fitTo には正の値の [width, height] を指定してください +@msg dxf must be an object of DXF import options dxf には DXF 読み込みオプションのオブジェクトを指定してください +@msg dxf scale must be a positive number up to {_1}, not {s} dxf の scale には {_1} 以下の正の数値を指定してください ({s} は指定できません) +@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf の unit `{u}`: mm、cm、in、pt などの単位を指定してください +@msg dxf: {e} dxf: {e} +@msg each format is an object {format, scale?, suffix?} 各形式にはオブジェクト {format, scale?, suffix?} を指定してください +@msg each item needs an id 各項目には id が必要です +@msg each match needs `kind` 各一致には `kind` が必要です +@msg each match needs a `name` 各一致には `name` が必要です +@msg each of `fonts` is {family, style?}; 1 to 1000 of them `fonts` の各要素には {family, style?} を指定してください。要素数は 1 から 1000 個です +@msg each of `items` is {kind, item} `items` の各要素には {kind, item} を指定してください +@msg each of `items` is {{id, corners?}} with an object id, not {it} `items` の各要素には、オブジェクト ID を含む {{id, corners?}} を指定してください ({it} は指定できません) +@msg each row needs `from` 各行には `from` が必要です +@msg each stop needs a position 各分岐点には位置が必要です +@msg each subpath needs an anchors array 各サブパスには anchors 配列が必要です +@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType には easeIn、easeOut、easeInOut、easeOutIn のいずれかを指定してください +@msg editingData must be true or false editingData には true または false を指定してください +@msg editingData must be true, false or "always" editingData には true、false、"always" のいずれかを指定してください +@msg edits overlap 編集範囲が重なっています +@msg effects of objects whose appearances differ can't be moved together アピアランスが異なるオブジェクトの効果は、まとめて移動できません +@msg empty header in column {_1} 列 {_1} のヘッダーが空です +@msg encoding `{_1}`: utf8 or utf16 encoding `{_1}`: utf8 または utf16 を指定してください +@msg ends must be two of "first" and "last" ends には "first" と "last" から 2 つを指定してください +@msg every numbered name for the result is taken 結果に使用できる番号付きの名前がすべて使用されています +@msg expanded SVG styles exceed the import size limit 展開した SVG スタイルが読み込みサイズの上限を超えています +@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes 書き出しプリセットファイルが {MAX_PRESET_FILE_BYTES} バイトを超えています +@msg exportText must be editable or appearance exportText には editable または appearance を指定してください +@msg exportType must be movieClip or graphic, not `{s}` exportType には movieClip または graphic を指定してください (`{s}` は指定できません) +@msg extend needs id extend には id が必要です +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges に指定できるタグは最大 {MAX_FEATURE_CHANGES} 個です +@msg featureChanges must be a list of tags featureChanges にはタグのリストを指定してください +@msg features must be a list of tags features にはタグのリストを指定してください +@msg fidelity must be 0..100 fidelity には 0..100 の値を指定してください +@msg files must be {name, dataBase64} files には {name, dataBase64} を指定してください +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit には none|autoHeight|shrinkText|block のいずれかを指定してください (指定値: {id}) +@msg flattener: {e} 分割・統合: {e} +@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high、medium、low、または保存済みのプリセットを指定してください (flattener.presets.list を参照) +@msg flipProfile must be along|across, got {o} flipProfile には along|across のいずれかを指定してください (指定値: {o}) +@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none、rows、format、item、itemFormat のいずれかを指定してください +@msg folders deeper than {MAX_BATCH_DEPTH} levels フォルダーの階層が {MAX_BATCH_DEPTH} を超えています +@msg folders deeper than {MAX_DEPTH} levels フォルダーの階層が {MAX_DEPTH} を超えています +@msg font family `{family}` is not available フォントファミリー `{family}` は使用できません +@msg font family `{tf}` is not available フォントファミリー `{tf}` は使用できません +@msg format `{f}`: pdf or postscript format `{f}`: pdf または postscript を指定してください +@msg format `{f}`: text or json format `{f}`: text または json を指定してください +@msg format is `{format}` format は `{format}` です +@msg formats must be an array of rows, not {v} formats には行の配列を指定してください ({v} は指定できません) +@msg frequency must be between 1 and 300 lines per inch frequency には 1 から 300 線/インチまでの値を指定してください +@msg from and to must be finite from と to には有限の値を指定してください +@msg from must be [x0, y0, x1, y1] from には [x0, y0, x1, y1] を指定してください +@msg give `before` or `after`, not both `before` または `after` のどちらか一方を指定してください +@msg give `colors`, or `color` and `rule` `colors`、または `color` と `rule` を指定してください +@msg give `event`, such as onclick onclick などの `event` を指定してください +@msg give `file`, the JavaScript file's path or URL `file` に JavaScript ファイルのパスまたは URL を指定してください +@msg give `fill` and/or `stroke` `fill` と `stroke` の一方または両方を指定してください +@msg give `fill`, `stroke` or both `fill`、`stroke`、またはその両方を指定してください +@msg give `folder` `folder` を指定してください +@msg give `fonts`, or open a document `fonts` を指定するか、ドキュメントを開いてください +@msg give `from` or `indices`, not both `from` または `indices` のどちらか一方を指定してください +@msg give `id` (no text is being edited) `id` を指定してください (編集中のテキストがありません) +@msg give `id` and `anchors`, or `items`, not both `id` と `anchors`、または `items` のどちらか一方を指定してください +@msg give `id` or `guide`, not both `id` または `guide` のどちらか一方を指定してください +@msg give `length` or `to` `length` または `to` を指定してください +@msg give `matches`: [{kind, name, target}] `matches` を指定してください: [{kind, name, target}] +@msg give `name` (or edit a pattern) `name` を指定してください (またはパターンを編集してください) +@msg give `name` or `names` `name` または `names` を指定してください +@msg give `path` (a .wasm file) or `dataBase64` (the module) `path` (.wasm ファイル) または `dataBase64` (モジュール) を指定してください +@msg give `path`, `data` or `dataBase64` `path`、`data`、`dataBase64` のいずれかを指定してください +@msg give `path`, `data`, `dataBase64` or `csv` `path`、`data`、`dataBase64`、`csv` のいずれかを指定してください +@msg give `path`, `xml` or `dataBase64` `path`、`xml`、`dataBase64` のいずれかを指定してください +@msg give `script`, the JavaScript the event runs ("" removes the event) `script` に、イベントで実行する JavaScript を指定してください ("" を指定するとイベントを削除します) +@msg give `text` (the reading) or `remove` `text` (読み) または `remove` を指定してください +@msg give `x` and `y` (and a `tolerance` of 0 or more) `x` と `y` (および 0 以上の `tolerance`) を指定してください +@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments mode、または r/g/b、c/m/y/k、gray、tint の調整値を指定してください +@msg give a name, or paint the active proxy with a saved preset 名前を指定するか、保存済みのプリセットでアクティブなプロキシをペイントしてください +@msg give a name, or select a stroke with a saved profile 名前を指定するか、保存済みのプロファイルが適用された線を選択してください +@msg give a pattern name or select an object painted with a pattern パターン名を指定するか、パターンでペイントしたオブジェクトを選択してください +@msg give at least one of clip, invert, disabled, linked clip、invert、disabled、linked のうち 1 つ以上を指定してください +@msg give color, none, swatch or gradient color、none、swatch、gradient のいずれかを指定してください +@msg give dataBase64 dataBase64 を指定してください +@msg give either at (a click) or area (a path drawn around the overlaps) at (クリック位置) または area (重なりを囲むように描いたパス) のどちらかを指定してください +@msg give formats or a preset, not both formats またはプリセットのどちらか一方を指定してください +@msg give newName or delete newName または delete を指定してください +@msg give offset or by offset または by を指定してください +@msg give one of `to`, `rect` or `at` `to`、`rect`、`at` のいずれかを指定してください +@msg give one of assets, layers, selection or fullDocument assets、layers、selection、fullDocument のいずれかを指定してください +@msg give only one of onto, above, below, icon and a column place onto、above、below、icon、列の位置のうち 1 つだけを指定してください +@msg give origin: [x, y], or reset: true origin: [x, y] または reset: true を指定してください +@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note overprintFill、overprintStroke、showCenter、imageMap、url、note のいずれかを指定してください +@msg give path (a file) or folder path (ファイル) または folder を指定してください +@msg give path, or name and dataBase64 path、または name と dataBase64 を指定してください +@msg give paths or files paths または files を指定してください +@msg give rows, rowHeight, columns or columnWidth rows、rowHeight、columns、columnWidth のいずれかを指定してください +@msg give svg, or dataBase64 of an SVG or SVGZ file svg、または SVG か SVGZ ファイルの dataBase64 を指定してください +@msg give text text を指定してください +@msg give the asset a name アセットに名前を指定してください +@msg give the preset a `name` プリセットに `name` を指定してください +@msg give the preset a name プリセットに名前を指定してください +@msg give the preset a name (1–64 characters) プリセットに名前を指定してください (1–64 文字) +@msg give the settings to change, e.g. {"formats"}]} 変更する設定を指定してください (例: {"formats"}]}) +@msg give the style a name スタイルに名前を指定してください +@msg give two or more style `names` スタイルの `names` を 2 つ以上指定してください +@msg give two or more swatches スウォッチを 2 つ以上指定してください +@msg give x, y, or a handle's length or angle x、y、またはハンドルの length か angle を指定してください +@msg give {key, value} or {values} {key, value} または {values} を指定してください +@msg global must be true or false global には true または false を指定してください +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) この文字のスタイル (オールキャップス、スモールキャップ、数字の体系など) では、{ch} にグリフ {_1} は使用されません +@msg glyph {g} is not an alternate of {ch} in {_1} {_2} グリフ {g} は {_1} {_2} における {ch} の代替字形ではありません +@msg gradient must be "objects" or "mesh" gradient には "objects" または "mesh" を指定してください +@msg grays must be "profile" or "black", not `{g}` grays には "profile" または "black" を指定してください (`{g}` は指定できません) +@msg gridColors must be a preset name or two colours gridColors にはプリセット名または 2 つのカラーを指定してください +@msg gridSize must be small, medium or large gridSize には small、medium、large のいずれかを指定してください +@msg guide {g} is not selected ガイド {g} は選択されていません +@msg handle must be 0..3 handle には 0..3 の値を指定してください +@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (初期設定)、"omit"、"visible" のいずれかを指定してください +@msg highlight a layer to merge into 結合先のレイヤーをハイライトしてください +@msg highlight two or more rows in one layer or group 1 つのレイヤーまたはグループ内の行を 2 つ以上ハイライトしてください +@msg highlight two or more rows to merge 結合する行を 2 つ以上ハイライトしてください +@msg icon docks a panel in the icon column: use window.panel.dock icon はパネルをアイコン列にドッキングします: window.panel.dock を使用してください +@msg id must be a dimension id id には寸法の ID を指定してください +@msg image has no bounds 画像に境界がありません +@msg image {_1} is linked already 画像 {_1} はすでにリンクされています +@msg imageMap `{m}`: none, client or server imageMap `{m}`: none、client、server のいずれかを指定してください +@msg index out of range インデックスが範囲外です +@msg instances must be 1..1000 instances には 1..1000 の値を指定してください +@msg invalid `color` `color` が無効です +@msg invalid anchor index {v}: expected a non-negative integer アンカーのインデックス {v} が無効です: 0 以上の整数を指定してください +@msg invalid anchor reference {v}: expected [subpath, anchor] アンカーの参照 {v} が無効です: [subpath, anchor] を指定してください +@msg invalid angle 角度が無効です +@msg invalid brush: {e} ブラシが無効です: {e} +@msg invalid color カラーが無効です +@msg invalid colour {c} カラー {c} が無効です +@msg invalid delta 差分が無効です +@msg invalid point 点が無効です +@msg invalid size サイズが無効です +@msg invalid t t が無効です +@msg invalid tolerance 許容値が無効です +@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) アートボードがすべてキャンバスの外にあります (原点から {_1} pt を超えています) +@msg its clipping masks are nested too deeply クリッピングマスクの入れ子が深すぎます +@msg its metadata has no description メタデータに説明がありません +@msg its metadata has no direct length メタデータに直接指定された長さがありません +@msg join two different paths 異なる 2 つのパスを連結してください +@msg justify must be auto|left|center|right|justifyAll justify には auto|left|center|right|justifyAll のいずれかを指定してください +@msg kbps must be a positive number, not {kbps} kbps には正の数値を指定してください ({kbps} は指定できません) +@msg keywords must be a list of words or a comma-separated string keywords には単語のリストまたはカンマ区切りの文字列を指定してください +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords には制御文字を含まない {MAX_KEYWORD} 文字以内の値を指定してください +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind には 1、2、3 のいずれかを指定するか、プリセットの `name` を指定してください (perspective.presets.list を参照) +@msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind には graphic、fillColor、strokeColor、fillGradient、strokeGradient、fillPattern、strokePattern、charStyle、paraStyle のいずれかを指定してください +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout には "on"、"off"、"neutral"、またはブール値を指定してください ({v} は指定できません) +@msg layer `{name}` has no art to export レイヤー `{name}` に書き出せるアートがありません +@msg layer must name a layer or sublayer layer にはレイヤーまたはサブレイヤーを指定してください +@msg layers can't be intertwined: select the objects in them レイヤーを絡み合わせることはできません: レイヤー内のオブジェクトを選択してください +@msg layers go only at the top level or in layers, objects in layers and groups レイヤーを置けるのは最上位またはレイヤー内だけで、オブジェクトを置けるのはレイヤー内とグループ内です +@msg layers must be true or false layers には true または false を指定してください +@msg layout must be gridByRow, gridByColumn, row or column layout には gridByRow、gridByColumn、row、column のいずれかを指定してください +@msg leading must be a number or "auto" leading には数値または "auto" を指定してください +@msg left/right must be non-negative widths left/right には 0 以上の幅を指定してください +@msg level `{t}`: 2 or 3 level `{t}`: 2 または 3 を指定してください +@msg line {line}: more than {MAX_CSV_COLS} columns {line} 行目: 列が {MAX_CSV_COLS} 個を超えています +@msg line {line}: unclosed quote {line} 行目: 引用符が閉じられていません +@msg line {line}: value for `{var}` must match its variable kind {line} 行目: `{var}` の値は変数の種類に合わせてください +@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf または crlf を指定してください +@msg loading profiles from disk isn't available in the browser ブラウザーではディスクからプロファイルを読み込めません +@msg margin: a {m} pt margin leaves no imageable area on the paper margin: マージンが {m} pt では用紙にプリント可能な領域が残りません +@msg matrix must be finite and invertible, depth finite matrix には有限で逆行列を持つ値、depth には有限の値を指定してください +@msg matte: {e} matte: {e} +@msg member is for order front, forward, backward or back member は order が front、forward、backward、back のいずれかの場合に指定します +@msg missing `artboards` `artboards` がありません +@msg missing `at` [x, y] `at` [x, y] がありません +@msg missing `attrs` `attrs` がありません +@msg missing `color` or `opacity` `color` または `opacity` がありません +@msg missing `colors` `colors` がありません +@msg missing `commands` `commands` がありません +@msg missing `destinationFolder` `destinationFolder` がありません +@msg missing `effect` id `effect` の ID がありません +@msg missing `family` `family` がありません +@msg missing `find` `find` がありません +@msg missing `gid` (a glyph id, or null for the default glyph) `gid` がありません (グリフ ID、または初期設定のグリフにする場合は null) +@msg missing `group` `group` がありません +@msg missing `id` `id` がありません +@msg missing `id`: give one, or select one type object `id` がありません: 指定するか、文字オブジェクトを 1 つ選択してください +@msg missing `index` (a point index, or null to clear) `index` がありません (ポイントのインデックス、または消去する場合は null) +@msg missing `index` (a stop index, or null to clear) `index` がありません (分岐点のインデックス、または消去する場合は null) +@msg missing `index` (an item index or null) `index` がありません (項目のインデックスまたは null) +@msg missing `item` `item` がありません +@msg missing `library` `library` がありません +@msg missing `library` (see graphicStyle.libraries) `library` がありません (graphicStyle.libraries を参照) +@msg missing `library` (see swatch.library.list) `library` がありません (swatch.library.list を参照) +@msg missing `map` (rows [{from, to}] or {current}) `map` がありません (行 [{from, to}] または {current}) +@msg missing `matrix` [a,b,c,d,e,f] `matrix` [a,b,c,d,e,f] がありません +@msg missing `name` (see gradient.presets.list) `name` がありません (gradient.presets.list を参照) +@msg missing `offset` (a distance in points) `offset` がありません (ポイント単位の距離) +@msg missing `path` `path` がありません +@msg missing `path` (a path object id) `path` がありません (パスオブジェクトの ID) +@msg missing `rule` `rule` がありません +@msg missing `source` id `source` の ID がありません +@msg missing `start` (the byte offset of a character) `start` がありません (文字のバイトオフセット) +@msg missing `status`: linked, embedded, missing or modified `status` がありません: linked、embedded、missing、modified のいずれかを指定してください +@msg missing `target` id `target` の ID がありません +@msg missing `text` `text` がありません +@msg missing `to` `to` がありません +@msg missing `to` (an index) `to` がありません (インデックス) +@msg missing `to`: a non-negative integer `to` がありません: 0 以上の整数を指定してください +@msg missing `type` `type` がありません +@msg missing `value` `value` がありません +@msg missing `{key}` `{key}` がありません +@msg missing `{k}` `{k}` がありません +@msg missing anchor anchor がありません +@msg missing anchors anchors がありません +@msg missing anchors [[subpath, anchor]…] anchors [[subpath, anchor]…] がありません +@msg missing array `{key}` 配列 `{key}` がありません +@msg missing case case がありません +@msg missing color color がありません +@msg missing commands commands がありません +@msg missing destination folder 出力先フォルダーがありません +@msg missing from from がありません +@msg missing group group がありません +@msg missing id or ids id または ids がありません +@msg missing ids ids がありません +@msg missing indexes indexes がありません +@msg missing matrix [a,b,c,d,e,f] matrix [a,b,c,d,e,f] がありません +@msg missing name name がありません +@msg missing op op がありません +@msg missing or invalid `color` `color` がないか、無効です +@msg missing or unknown `tool` `tool` がないか、不明です +@msg missing point point がありません +@msg missing point [x,y] point [x,y] がありません +@msg missing point `{key}` ([x, y]) 点 `{key}` ([x, y]) がありません +@msg missing points points がありません +@msg missing points [[x,y]…] points [[x,y]…] がありません +@msg missing segment segment がありません +@msg missing stops stops がありません +@msg missing subpaths subpaths がありません +@msg missing target target がありません +@msg missing to to がありません +@msg missing x, y x, y がありません +@msg mode is set, add or toggle, not `{other}` mode には set、add、toggle のいずれかを指定してください (`{other}` は指定できません) +@msg mode must be "cmyk" or "rgb" mode には "cmyk" または "rgb" を指定してください +@msg mode must be area|onPath, got {o} mode には area|onPath のいずれかを指定してください (指定値: {o}) +@msg more than 10000 linked files: package dependencies in smaller parts リンクファイルが 10000 個を超えています: 依存ファイルを小分けにしてパッケージしてください +@msg more than 256 commands コマンドが 256 個を超えています +@msg more than {MAX_BATCH_FILES} data sets データセットが {MAX_BATCH_FILES} 個を超えています +@msg more than {MAX_BATCH_FILES} files ファイルが {MAX_BATCH_FILES} 個を超えています +@msg more than {MAX_CSV_ROWS} data rows データ行が {MAX_CSV_ROWS} 行を超えています +@msg more than {MAX_FILES} images 画像が {MAX_FILES} 個を超えています +@msg more than {MAX_IMPORT} presets プリセットが {MAX_IMPORT} 個を超えています +@msg moved must be [[x,y]…] moved には [[x,y]…] を指定してください +@msg name (a toolbar's name) is required name (ツールバー名) は必須です +@msg name (a toolbar's name) must be a string name (ツールバー名) には文字列を指定してください +@msg name must be a toolbar's name name にはツールバー名を指定してください +@msg names is empty names が空です +@msg names must be an array of symbol names names にはシンボル名の配列を指定してください +@msg need `anchor` or `segment` + `t` `anchor`、または `segment` + `t` が必要です +@msg need at least one anchor アンカーが 1 つ以上必要です +@msg need at least one point 点が 1 つ以上必要です +@msg need at least two points 点が 2 つ以上必要です +@msg needs points 点が必要です +@msg nineSlice must be true or false nineSlice には true または false を指定してください +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides には [left, top, right, bottom] を指定してください。各値は -16..16 で、left + right < 1 かつ top + bottom < 1 にしてください +@msg no PDF preset named `{n}` `{n}` という名前の PDF プリセットはありません +@msg no action set is current 現在のアクションセットがありません +@msg no active data set アクティブなデータセットがありません +@msg no appearance item {index} アピアランス項目 {index} はありません +@msg no artboard {_1} アートボード {_1} はありません +@msg no artboard {artboard} (0-based; the document has {_1}) アートボード {artboard} はありません (0 から始まるインデックス。ドキュメントのアートボードは {_1} 個) +@msg no asset {id} アセット {id} はありません +@msg no asset {id} (see assets.list) アセット {id} はありません (assets.list を参照) +@msg no assets to export 書き出すアセットがありません +@msg no assets yet: collect art for export first (assets.add) アセットがまだありません: 先に書き出すアートを収集してください (assets.add) +@msg no brush named `{n}` `{n}` という名前のブラシはありません +@msg no character at byte {start} バイト {start} の位置に文字はありません +@msg no colour カラーがありません +@msg no colour group `{group}` カラーグループ `{group}` はありません +@msg no colour group `{name}` カラーグループ `{name}` はありません +@msg no data sets データセットがありません +@msg no dataset named `{from}` `{from}` という名前のデータセットはありません +@msg no dataset named `{name}` `{name}` という名前のデータセットはありません +@msg no datasets データセットがありません +@msg no design named `{name}` `{name}` という名前のデザインはありません +@msg no design named `{n}` `{n}` という名前のデザインはありません +@msg no design named `{old}` `{old}` という名前のデザインはありません +@msg no encoder for {_1} yet {_1} のエンコーダーはまだありません +@msg no export preset `{name}` (see document.exportPresets.list) 書き出しプリセット `{name}` はありません (document.exportPresets.list を参照) +@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder フォルダーがありません: `path` を指定するか、「環境設定 › プラグイン & 仮想記憶ディスク › 追加プラグインフォルダー」を設定してください +@msg no fonts to look for: they are all available 検索するフォントがありません: すべて使用できます +@msg no formats to export 書き出す形式がありません +@msg no gradient preset `{name}` (see gradient.presets.list) グラデーションプリセット `{name}` はありません (gradient.presets.list を参照) +@msg no graphic style `{n}` in `{_1}` `{_1}` にグラフィックスタイル `{n}` はありません +@msg no graphic style library `{key}` (see graphicStyle.libraries) グラフィックスタイルライブラリ `{key}` はありません (graphicStyle.libraries を参照) +@msg no guide selected ガイドが選択されていません +@msg no guide selected (or give an index) ガイドが選択されていません (またはインデックスを指定してください) +@msg no guide {i} ガイド {i} はありません +@msg no images in the folder フォルダーに画像がありません +@msg no item at index {_1} インデックス {_1} に項目はありません +@msg no item at index {from} インデックス {from} に項目はありません +@msg no item at one of the indices {idx} インデックス {idx} のいずれかに項目がありません +@msg no layer レイヤーがありません +@msg no layer {id} (see layer.list) レイヤー {id} はありません (layer.list を参照) +@msg no layers to export 書き出すレイヤーがありません +@msg no library `{key}` ライブラリ `{key}` はありません +@msg no metafile format メタファイル形式はありません +@msg no object {_1} オブジェクト {_1} はありません +@msg no objects オブジェクトがありません +@msg no parent layer 親レイヤーがありません +@msg no perspective grid preset named `{b}` (see perspective.presets.list) `{b}` という名前の遠近グリッドプリセットはありません (perspective.presets.list を参照) +@msg no perspective grid preset named `{n}` `{n}` という名前の遠近グリッドプリセットはありません +@msg no point selected (give `index`) ポイントが選択されていません (`index` を指定してください) +@msg no preset called `{from}` (see file.newPresets) `{from}` というプリセットはありません (file.newPresets を参照) +@msg no preset called `{name}` (see file.newPresets) `{name}` というプリセットはありません (file.newPresets を参照) +@msg no preset named `{n}` `{n}` という名前のプリセットはありません +@msg no print preset named `{b}` (see print.presets.list) `{b}` という名前のプリントプリセットはありません (print.presets.list を参照) +@msg no print preset named `{name}` (presets: {_1}) `{name}` という名前のプリントプリセットはありません (プリセット: {_1}) +@msg no print preset named `{n}` `{n}` という名前のプリントプリセットはありません +@msg no profile named `{name}` `{name}` という名前のプロファイルはありません +@msg no readable files in the folder フォルダーに読み込めるファイルがありません +@msg no recovery copy `{f}` (see file.recovery.list) 復元用コピー `{f}` はありません (file.recovery.list を参照) +@msg no rows 行がありません +@msg no saved PDF preset named `{name}` `{name}` という名前の保存済み PDF プリセットはありません +@msg no saved export preset `{name}` 保存済みの書き出しプリセット `{name}` はありません +@msg no saved gradient preset called {name} {name} という保存済みのグラデーションプリセットはありません +@msg no saved gradient presets 保存済みのグラデーションプリセットがありません +@msg no saved preset called `{name}` `{name}` という保存済みのプリセットはありません +@msg no saved presets to export (name [Default] in `names` to export it) 書き出す保存済みプリセットがありません ([Default] を書き出すには `names` に指定してください) +@msg no saved presets to export (name built-in ones in `names`) 書き出す保存済みプリセットがありません (組み込みのプリセットは `names` に指定してください) +@msg no saved print preset named `{name}` `{name}` という名前の保存済みプリントプリセットはありません +@msg no saved profile called {name} {name} という保存済みのプロファイルはありません +@msg no saved selection `{name}` 保存した選択範囲 `{name}` はありません +@msg no saved theme called {name} (see colorTheme.list) {name} という保存済みのテーマはありません (colorTheme.list を参照) +@msg no saved views 保存した表示がありません +@msg no series {i} 系列 {i} はありません +@msg no slice {n} in the image (see slice.list) 画像にスライス {n} はありません (slice.list を参照) +@msg no source art 元のアートがありません +@msg no stop {i} (the gradient has {n}) 分岐点 {i} はありません (グラデーションの分岐点は {n} 個) +@msg no style `{name}` スタイル `{name}` はありません +@msg no such assets (see assets.list) 該当するアセットはありません (assets.list を参照) +@msg no such frame anchor, or the frame would reach past the canvas 該当するフレームのアンカーがないか、フレームがキャンバスからはみ出します +@msg no such objects 該当するオブジェクトはありません +@msg no such path anchor, or the path would reach past the canvas 該当するパスのアンカーがないか、パスがキャンバスからはみ出します +@msg no such rows 該当する行はありません +@msg no swatch `{keep}` スウォッチ `{keep}` はありません +@msg no swatch `{n}` スウォッチ `{n}` はありません +@msg no swatch `{n}` (swatches and colour groups move separately) スウォッチ `{n}` はありません (スウォッチとカラーグループは別々に移動します) +@msg no swatch library `{key}` (see swatch.library.list) スウォッチライブラリ `{key}` はありません (swatch.library.list を参照) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) スウォッチライブラリ `{key}` はありません (swatch.library.list を参照。"document" はドキュメントのスウォッチ) +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) スウォッチライブラリ `{key}` はありません (swatch.library.list を参照。"{DOCUMENT_SWATCHES}" はドキュメントのスウォッチ) +@msg no swatch named `{n}` `{n}` という名前のスウォッチはありません +@msg no swatch or colour group `{n}` スウォッチまたはカラーグループ `{n}` はありません +@msg no swatch or colour group `{n}` in `{_1}` `{_1}` にスウォッチまたはカラーグループ `{n}` はありません +@msg no symbol named `{name}` `{name}` という名前のシンボルはありません +@msg no text objects selected テキストオブジェクトが選択されていません +@msg no toolbar named `{_1}` `{_1}` という名前のツールバーはありません +@msg no toolbar named `{name}` `{name}` という名前のツールバーはありません +@msg no tracing preset `{preset}` (see imageTrace.presets) トレースプリセット `{preset}` はありません (imageTrace.presets を参照) +@msg no user library folder here (save with a path, or without one to get the data) ここにはユーザーライブラリフォルダーがありません (パスを指定して保存するか、パスを指定せずにデータを取得してください) +@msg no variable named `{from}` `{from}` という名前の変数はありません +@msg no variable named `{name}` `{name}` という名前の変数はありません +@msg no variable named `{var}` `{var}` という名前の変数はありません +@msg no variable named `{v}` `{v}` という名前の変数はありません +@msg no view named `{name}` `{name}` という名前の表示はありません +@msg node {_1} is not a graph ノード {_1} はグラフではありません +@msg node {_1} is not an image ノード {_1} は画像ではありません +@msg node {_1} is not type ノード {_1} は文字ではありません +@msg not a PDF presets file (format `{_1}`) PDF プリセットファイルではありません (形式 `{_1}`) +@msg not a PDF presets file: {e} PDF プリセットファイルではありません: {e} +@msg not a Word document: its WordDocument stream doesn't start with a file information block Word 文書ではありません: WordDocument ストリームがファイル情報ブロックで始まっていません +@msg not a Word document: its compound file has no WordDocument stream Word 文書ではありません: 複合ファイルに WordDocument ストリームがありません +@msg not a Word document: its main part is not a WordprocessingML document Word 文書ではありません: メインパートが WordprocessingML 文書ではありません +@msg not a Word document: its package has no main document part Word 文書ではありません: パッケージにメイン文書パートがありません +@msg not a closed path クローズパスではありません +@msg not a flattener presets file (format `{_1}`) 分割・統合プリセットファイルではありません (形式 `{_1}`) +@msg not a flattener presets file: {e} 分割・統合プリセットファイルではありません: {e} +@msg not a layer or group レイヤーでもグループでもありません +@msg not a library file: {e} ライブラリファイルではありません: {e} +@msg not a mesh envelope point メッシュエンベロープのポイントではありません +@msg not a perspective grid presets file (format `{_1}`) 遠近グリッドプリセットファイルではありません (形式 `{_1}`) +@msg not a perspective grid presets file: {e} 遠近グリッドプリセットファイルではありません: {e} +@msg not a print presets file (format `{_1}`) プリントプリセットファイルではありません (形式 `{_1}`) +@msg not a print presets file: {e} プリントプリセットファイルではありません: {e} +@msg not a stored or deflated central record 無圧縮または Deflate 圧縮のセントラルレコードではありません +@msg not a valid SVGZ file: {e} 有効な SVGZ ファイルではありません: {e} +@msg not an RTF file: it doesn't start with {\\rtf RTF ファイルではありません: {\\rtf で始まっていません +@msg not an image VectorCraft reads (see document.formats) VectorCraft で読み込める画像ではありません (document.formats を参照) +@msg not area type (text in a frame) outside perspective 遠近グリッド外のエリア内文字 (フレーム内のテキスト) ではありません +@msg not editing an opacity mask 不透明マスクを編集中ではありません +@msg not type on a path outside perspective 遠近グリッド外のパス上文字ではありません +@msg nothing prints プリントされるものがありません +@msg nothing to bind バインドする対象がありません +@msg nothing to change 変更する内容がありません +@msg nothing to change: give dx, dy, hScale, vScale or rotation 変更する内容がありません: dx、dy、hScale、vScale、rotation のいずれかを指定してください +@msg nothing to crop 切り抜く対象がありません +@msg nothing to export 書き出す対象がありません +@msg nothing to export: the document has no visible art 書き出す対象がありません: ドキュメントに表示されているアートがありません +@msg nothing to mark マークする対象がありません +@msg nothing to print: every artboard picked is blank プリントする対象がありません: 選択したアートボードがすべて空白です +@msg nothing to print: the document has no art that prints プリントする対象がありません: ドキュメントにプリントされるアートがありません +@msg object is not a path オブジェクトがパスではありません +@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object オブジェクトの透明は、ハイライトした文字には適用できません。「スポイトオプション」の「透明」をオフにするか、オブジェクト全体を選択してください +@msg object {_1} can't be set as point type オブジェクト {_1} はポイント文字にできません +@msg object {_1} has lost its file's contents: relink it オブジェクト {_1} のファイルの内容が失われています: 再リンクしてください +@msg object {_1} has no path オブジェクト {_1} にパスがありません +@msg object {_1} has no size オブジェクト {_1} にサイズがありません +@msg object {_1} is hidden or locked, or on a locked artboard オブジェクト {_1} は非表示かロックされているか、ロックされたアートボード上にあります +@msg object {_1} is not a placed document オブジェクト {_1} は配置したドキュメントではありません +@msg object {_1} is not an Intertwine group オブジェクト {_1} は織り交ぜグループではありません +@msg object {_1} is not an image オブジェクト {_1} は画像ではありません +@msg object {_1} is not an image or a linked file オブジェクト {_1} は画像でもリンクファイルでもありません +@msg object {_1} is not an image or a placed document オブジェクト {_1} は画像でも配置したドキュメントでもありません +@msg object {_1} is not in this Intertwine group オブジェクト {_1} はこの織り交ぜグループに含まれていません +@msg object {_1} needs a 3D effect at index {index} オブジェクト {_1} にはインデックス {index} に 3D 効果が必要です +@msg object {id} has anchors 0–{_1} for `corners`, not {past} オブジェクト {id} で `corners` に指定できるアンカーは 0–{_1} です ({past} は指定できません) +@msg object {id} has {n} anchors, so no anchor {past} オブジェクト {id} のアンカーは {n} 個のため、アンカー {past} はありません +@msg objects must be none, move or copy objects には none、move、copy のいずれかを指定してください +@msg objects were left out 除外されたオブジェクトがあります +@msg offset out of range オフセットが範囲外です +@msg on must be true or false on には true または false を指定してください +@msg only groups, layers, type and images have a Contents (Characters) row 「内容 (文字)」行があるのは、グループ、レイヤー、文字、画像だけです +@msg only interior mesh lines can be deleted 削除できるのは内側のメッシュラインだけです +@msg only solid colours can be added (not gradients or patterns) 追加できるのは単色だけです (グラデーションとパターンは追加できません) +@msg only solid-colour swatches can be spot colours 特色にできるのは単色のスウォッチだけです +@msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first この Affinity ファイルは埋め込みプレビューしか読み込めませんでした: 「ファイル › 開く」で警告とともに表示するか、先に Affinity から SVG または PDF を書き出してください +@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning この Affinity ファイルは埋め込みプレビューしか読み込めませんでした。「ファイル › 開く」で警告とともに表示できます +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto、above、below、icon、列の位置は window.panel.float と window.panel.dock で指定します +@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto、above、below、icon、列の位置を指定する場合、anchor、side、zone、before は指定できません +@msg onto, above, below, icon and a column place take no x, y, width or height onto、above、below、icon、列の位置を指定する場合、x、y、width、height は指定できません +@msg open a document to look for its linked files リンクファイルを検索するには、ドキュメントを開いてください +@msg options must be an object (see file.formatOptions) options にはオブジェクトを指定してください (file.formatOptions を参照) +@msg order must be leftToRight or rightToLeft order には leftToRight または rightToLeft を指定してください +@msg orientation must be portrait or landscape, not `{o}` orientation には portrait または landscape を指定してください (`{o}` は指定できません) +@msg outlineText {v}: true or false outlineText {v}: true または false を指定してください +@msg output intent profile: {e} 出力インテントのプロファイル: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: 「{name}」という名前の RGB または CMYK プロファイルはありません +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: {_1} ファイルは CMYK ですが、「{name}」は RGB プロファイルです +@msg output.destination: {e} output.destination: {e} +@msg output.inks: no ink is set to print output.inks: プリントするように設定されたインキがありません +@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: 「{name}」という名前のプロファイルはありません。{std} の出力インテントにはプロファイルを埋め込みます +@msg output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) output.outputIntent: 「{name}」という名前のプロファイルはありません: {std} の出力インテントには、プロファイルを埋め込むか、登録済みの印刷条件 (outputConditionId と registry) を指定します +@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: {std} ファイルは CMYK でプリントされますが、「{name}」は RGB プロファイルです +@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: 「{name}」はグレープロファイルのため、{std} の出力インテントにはできません +@msg overlap: {o} pt is half a tile or more overlap: {o} pt はタイルの半分以上です +@msg overlapping Shaper paths must be in the same layer or group 重なり合う Shaper パスは、同じレイヤーまたはグループ内にある必要があります +@msg overlapping edits 編集範囲が重なっています +@msg page art conflicts with image data for {key} ページのアートが {key} の画像データと一致しません +@msg page art conflicts with pattern {name} ページのアートがパターン {name} と一致しません +@msg page art conflicts with swatch {name} ページのアートがスウォッチ {name} と一致しません +@msg page art is missing image data for {key} ページのアートに {key} の画像データがありません +@msg page art is missing pattern {name} ページのアートにパターン {name} がありません +@msg page art is missing swatch {name} ページのアートにスウォッチ {name} がありません +@msg page must be a whole number from 1 page には 1 以上の整数を指定してください +@msg page thumbnail: {e} ページのサムネール: {e} +@msg page {page} of the PDF has no objects PDF の {page} ページにオブジェクトがありません +@msg page {page}: `{file}` has {n} {artboards} {page} ページ: `{file}` には {n} {artboards} あります +@msg page {page}: `{name}` has {n} {pages} {page} ページ: `{name}` には {n} {pages} あります +@msg pages must be a range such as "2-3" or a page number, not {v} pages には "2-3" のような範囲またはページ番号を指定してください ({v} は指定できません) +@msg pages: {e} pages: {e} +@msg panel position must be finite パネルの位置には有限の値を指定してください +@msg parameters must be an object parameters にはオブジェクトを指定してください +@msg params must be an object params にはオブジェクトを指定してください +@msg passwords can have up to {MAX_PASSWORD} bytes パスワードは {MAX_PASSWORD} バイト以内です +@msg path has no open end パスに開いた端点がありません +@msg paths must be strings paths には文字列を指定してください +@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false の場合、編集可能なアートワークを保持するためにすべてのアートボードが必要です +@msg pdfs `{m}`: single or multiple pdfs `{m}`: single または multiple を指定してください +@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom には "artwork" または "swatches" を指定してください ({v} は指定できません) +@msg pins and moved must be non-empty lists of the same length pins と moved には、同じ長さの空でないリストを指定してください +@msg pins must be [[x,y]…] pins には [[x,y]…] を指定してください +@msg pins must be finite pins には有限の値を指定してください +@msg place is above, below or inside, not `{other}` place には above、below、inside のいずれかを指定してください (`{other}` は指定できません) +@msg plane must be left|right|ground plane には left|right|ground のいずれかを指定してください +@msg plane must be left|right|ground|none plane には left|right|ground|none のいずれかを指定してください +@msg point is outside the mesh 点がメッシュの外にあります +@msg points must be [t, left, right] with non-negative widths points には、幅が 0 以上の [t, left, right] を指定してください +@msg points must be a non-empty [[x,y,pressure?]…] list points には空でない [[x,y,pressure?]…] のリストを指定してください +@msg points must be a non-empty list of finite [x,y] points には有限の [x,y] からなる空でないリストを指定してください +@msg points must be an array or null points には配列または null を指定してください +@msg points must be finite points には有限の値を指定してください +@msg points must be finite [x,y] pairs points には有限の [x,y] の組を指定してください +@msg points must contain 1–4096 coordinates points には 1–4096 個の座標を指定してください +@msg position must be normal, superscript or subscript position には normal、superscript、subscript のいずれかを指定してください +@msg position must be topLeft, topRight, bottomLeft or bottomRight position には topLeft、topRight、bottomLeft、bottomRight のいずれかを指定してください +@msg positions must be between 0 and 100000 pt positions には 0 から 100000 pt までの値を指定してください +@msg ppi must be a positive number, not {ppi} ppi には正の数値を指定してください ({ppi} は指定できません) +@msg ppi must be between 1 and 2400 ppi には 1 から 2400 までの値を指定してください +@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}` は transforms|bounds|fileDimensions|fit|fill のいずれでもありません +@msg preserveShape must be clippingMask|transparency preserveShape には clippingMask|transparency のいずれかを指定してください +@msg preset `{id}`: mobile or density preset `{id}`: mobile または density を指定してください +@msg preset must be standard, diffuse, topLeft or right preset には standard、diffuse、topLeft、right のいずれかを指定してください +@msg preset must be the name of a print preset preset にはプリントプリセットの名前を指定してください +@msg previewMode must be default, pixel or overprint previewMode には default、pixel、overprint のいずれかを指定してください +@msg print settings: {e} プリント設定: {e} +@msg procedures nest too deeply プロシージャの入れ子が深すぎます +@msg procedures nested too deeply プロシージャの入れ子が深すぎます +@msg quotes must be {double?, single?} quotes には {double?, single?} を指定してください +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} には開き引用符と閉じ引用符を指定してください (例: "“”") +@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi には 1–2400 を指定してください (72 がスクリーン、150 が標準、300 が高解像度) +@msg rating must be a whole number from 0 to {MAX_RATING} rating には 0 から {MAX_RATING} までの整数を指定してください +@msg rect and {k} contradict each other: give the area or the artboards rect と {k} が矛盾しています: 領域またはアートボードのどちらかを指定してください +@msg rect applies to raster formats, not {_1} rect はラスター形式に適用されます ({_1} には適用されません) +@msg rect is [x, y, width, height] in points rect には [x, y, width, height] をポイント単位で指定してください +@msg rect must be [x, y, width, height] rect には [x, y, width, height] を指定してください +@msg rect must be [x, y, width, height] with a positive width and height rect には、幅と高さが正の [x, y, width, height] を指定してください +@msg rect needs finite numbers and a positive width and height rect には有限の数値と、正の幅と高さが必要です +@msg redraw needs id redraw には id が必要です +@msg redraw within must be a finite distance of 0 or more redraw の within には 0 以上の有限の距離を指定してください +@msg registration must be 0..8 registration には 0..8 の値を指定してください +@msg release a layer or a group レイヤーまたはグループを解除してください +@msg replace keeps the replaced object's place and transform: drop template, at and rect replace では置き換えられるオブジェクトの位置と変形が保持されます: template、at、rect は指定しないでください +@msg replace: select the one object to replace replace: 置き換えるオブジェクトを 1 つ選択してください +@msg resolution must be a number or screen|medium|high resolution には数値、または screen|medium|high のいずれかを指定してください +@msg resolution must be between 1 and 2400 ppi resolution には 1 から 2400 ppi までの値を指定してください +@msg rotation must be a number of degrees rotation には度数の数値を指定してください +@msg rotation must be finite rotation には有限の値を指定してください +@msg rows and cols must be 1..50 rows と cols には 1..50 の値を指定してください +@msg rows and columns must be 1..500 rows と columns には 1..500 の値を指定してください +@msg rows of objects whose appearances differ can't be reordered together アピアランスが異なるオブジェクトの行は、まとめて並べ替えられません +@msg save the document first: Package collects a saved document 先にドキュメントを保存してください: パッケージは保存済みのドキュメントを収集します +@msg save the document first: copy puts the file in its folder 先にドキュメントを保存してください: copy はファイルをドキュメントのフォルダーに置きます +@msg savedPreset: {e} savedPreset: {e} +@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) scale `{s}`: 2x のような倍率、幅 (100w)、高さ (100h)、解像度 (72ppi) のいずれかを指定してください +@msg scale must be a number above 0, at most 64 scale には 0 より大きく 64 以下の数値を指定してください +@msg scale must be non-zero scale には 0 以外の値を指定してください +@msg scope must be selection or all (got {other}) scope には selection または all を指定してください (指定値: {other}) +@msg scribble is too long 落書きが長すぎます +@msg segment does not exist セグメントが存在しません +@msg select a Shaper group Shaper グループを選択してください +@msg select a blend to set easing or color shift イージングまたはカラーシフトを設定するブレンドを選択してください +@msg select a compound shape 複合シェイプを選択してください +@msg select a graph グラフを選択してください +@msg select a group or layer with visible, unlocked members 表示されていてロックされていないメンバーを含むグループまたはレイヤーを選択してください +@msg select a layer レイヤーを選択してください +@msg select a text object テキストオブジェクトを選択してください +@msg select an image (or an Image Trace group) 画像 (または画像トレースグループ) を選択してください +@msg select an image, or pass id 画像を選択するか、id を指定してください +@msg select an object (or pass `id`) オブジェクトを選択してください (または `id` を指定してください) +@msg select an object with a freeform gradient フリーフォームグラデーションのあるオブジェクトを選択してください +@msg select an object with an opacity mask 不透明マスクのあるオブジェクトを選択してください +@msg select area type エリア内文字を選択してください +@msg select area type (text in a frame) エリア内文字 (フレーム内のテキスト) を選択してください +@msg select area type and closed paths (at least one area type object) エリア内文字とクローズパスを選択してください (エリア内文字オブジェクトが 1 つ以上必要です) +@msg select area type whose first line can fit its frame 1 行目がフレームに収まるエリア内文字を選択してください +@msg select artwork to add its colours カラーを追加するアートワークを選択してください +@msg select artwork to make a group of its colours カラーのグループを作成するアートワークを選択してください +@msg select artwork to recolour, or give a colour `group` 再配色するアートワークを選択するか、カラーの `group` を指定してください +@msg select exactly two path ends to join 連結するパスの端点をちょうど 2 つ選択してください +@msg select members of a compound shape 複合シェイプのメンバーを選択してください +@msg select objects or give ids オブジェクトを選択するか、ids を指定してください +@msg select objects to align to the guide ガイドに整列するオブジェクトを選択してください +@msg select one Intertwine group 織り交ぜグループを 1 つ選択してください +@msg select one image or pass id 画像を 1 つ選択するか、id を指定してください +@msg select paths パスを選択してください +@msg select paths or compound paths パスまたは複合パスを選択してください +@msg select rows or objects to collect 収集する行またはオブジェクトを選択してください +@msg select something to export 書き出す対象を選択してください +@msg select something to export (or turn off selectedOnly) 書き出す対象を選択してください (または selectedOnly をオフにしてください) +@msg select text to redefine the style from スタイルの再定義に使用するテキストを選択してください +@msg select the art and, on top of it, the mask object アートと、その前面にあるマスクオブジェクトを選択してください +@msg select the art for the design デザインにするアートを選択してください +@msg select the art for the pattern tile パターンタイルにするアートを選択してください +@msg select the art to add first 先に追加するアートを選択してください +@msg select the art to collect (or give its ids) 収集するアートを選択してください (またはその ids を指定してください) +@msg select the content and a path on top 内容と、その前面にあるパスを選択してください +@msg select the images to relink, or pass ids 再リンクする画像を選択するか、ids を指定してください +@msg select the images, or pass ids 画像を選択するか、ids を指定してください +@msg select the linked images or placed documents, or pass ids リンク画像または配置したドキュメントを選択するか、ids を指定してください +@msg select the object to copy from コピー元のオブジェクトを選択してください +@msg select the objects (or give their ids) オブジェクトを選択してください (またはその ids を指定してください) +@msg select the objects to attach to the path パスに配置するオブジェクトを選択してください +@msg select the objects to bind バインドするオブジェクトを選択してください +@msg select two or more objects to intertwine 織り交ぜるオブジェクトを 2 つ以上選択してください +@msg select two or more slices to combine 結合するスライスを 2 つ以上選択してください +@msg select type on a path パス上文字を選択してください +@msg selection `{m}`: one or each selection `{m}`: one または each を指定してください +@msg settings is an object of document.exportForScreens params settings には document.exportForScreens のパラメーターのオブジェクトを指定してください +@msg settings must be an object settings にはオブジェクトを指定してください +@msg show `{show}`: all, missing, modified or embedded show `{show}`: all、missing、modified、embedded のいずれかを指定してください +@msg show must be true or false show には true または false を指定してください +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size には "small"、"medium"、"large"、または 0 より大きく {_1} 以下のポイント数を指定してください +@msg size must be positive size には正の値を指定してください +@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode には "fixed" または "pressure" を指定してください (`{other}` は指定できません) +@msg sizeMode is "fixed" or "pressure", not {other} sizeMode には "fixed" または "pressure" を指定してください ({other} は指定できません) +@msg sizeVariation must be 0 or more sizeVariation には 0 以上の値を指定してください +@msg smallCaps must be true or false smallCaps には true または false を指定してください +@msg sort `{x}`: name, kind or status sort `{x}`: name、kind、status のいずれかを指定してください +@msg source paths are too large for Shaper 元パスが Shaper には大きすぎます +@msg spot, mode and group apply to solid colours only spot、mode、group を適用できるのは単色だけです +@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked、embedded、missing、modified のいずれかを指定してください +@msg steps must be 1..1000 steps には 1..1000 の値を指定してください +@msg steps must be 1..{MAX_STEPS} steps には 1..{MAX_STEPS} の値を指定してください +@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) 分岐点 {i}: `plates` には 0 から 1 までの数値を 4 つ指定してください (C, M, Y, K) +@msg stopped before the archive was finished アーカイブが完成する前に停止しました +@msg strokeMode must be within|along|across, got {m} strokeMode には within|along|across のいずれかを指定してください (指定値: {m}) +@msg strokeOptions must be an object of stroke.set options strokeOptions には stroke.set のオプションのオブジェクトを指定してください +@msg strokeWidth must be a positive number of pixels strokeWidth には正のピクセル数を指定してください +@msg subpath {si} is not an open subpath サブパス {si} はオープンなサブパスではありません +@msg subpaths required when joining the same path 同じパスを連結する場合は subpaths が必要です +@msg swatchConflict must be "merge" or "add", not {v} swatchConflict には "merge" または "add" を指定してください ({v} は指定できません) +@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text には {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} を指定してください +@msg text transform is collapsed or unrepresentable テキストの変形がつぶれているか、表現できません +@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces には 2 から 100 までの整数を指定してください +@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: "{_1}" または "{_2}" を使用してください +@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: 画像ファイルの {path} または {name, dataBase64}、あるいは effect.list が返す形式のテクスチャを指定してください +@msg textureImage: {e} textureImage: {e} +@msg that makes more than {MAX_PIECES} slices スライスが {MAX_PIECES} 個を超えます +@msg that object is not in the overlap そのオブジェクトは重なりの中にありません +@msg the Affinity document has no page with a size Affinity ドキュメントにサイズのあるページがありません +@msg the CMYK image couldn't be written CMYK 画像を書き出せませんでした +@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected PDF のセキュリティ設定はパスワードなしで保存されています: openPassword または permissionsPassword をもう一度指定するか、保護なしで書き出すには passwordRequired のない security を指定してください +@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) RTF ファイルの段落または書式の変更が多すぎて、文字として配置できません (最大 {MAX_PARAGRAPHS} 段落、{MAX_RUNS} ラン) +@msg the RTF file is too large to place as type (at most {_1} MB) RTF ファイルが大きすぎて、文字として配置できません (最大 {_1} MB) +@msg the SVG expands to more than {0} nested SVG images SVG を展開すると、入れ子の SVG 画像が {0} 個を超えます +@msg the SVG expands to more than {0} objects SVG を展開すると、オブジェクトが {0} 個を超えます +@msg the SVG has no drawable objects SVG に描画できるオブジェクトがありません +@msg the SVGZ file unpacks to more than {_1} MB SVGZ ファイルを展開すると {_1} MB を超えます +@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place Word 文書のプロパティが {MAX_PAGES} ページを超えており、多すぎて配置できません +@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place Word 文書の文字または段落プロパティのランが {MAX_RUNS} 個を超えており、多すぎて配置できません +@msg the Word document has more than {_1} MB of formatting properties, too many to place Word 文書の書式プロパティが {_1} MB を超えており、多すぎて配置できません +@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) Word 文書のテキストまたは書式が多すぎて、文字として配置できません (最大 {MAX_PARAGRAPHS} 段落、{MAX_RUNS} ラン、テキスト {_1} MB) +@msg the Word document has too much text to place as type (at most {_1} MB) Word 文書のテキストが多すぎて、文字として配置できません (最大 {_1} MB) +@msg the Word document is encrypted or protected with a password; save it without one to place it Word 文書が暗号化されているか、パスワードで保護されています。配置するには、パスワードなしで保存してください +@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) Word 文書が大きすぎて配置できません ({name} ストリームが {_1} MB を超えています) +@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off Word 文書は高速保存で保存されていますが、VectorCraft はこの形式を読み込めません。高速保存をオフにして保存し直してください +@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document Word 文書は Word 6.0 または Word 95 で保存されていますが、VectorCraft はこの形式を読み込めません。Word 97-2003 文書として保存してください +@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place Word 文書のテキストが {MAX_PIECES} 個を超えるピースに分かれており、多すぎて配置できません +@msg the action set ids are not a list アクションセットの ID がリストではありません +@msg the active paint is not a gradient アクティブなペイントがグラデーションではありません +@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size アートボードがキャンバスに収まりません: アートボードの数を減らすか、間隔またはサイズを小さくしてください +@msg the character's size can't be measured 文字のサイズを計測できません +@msg the compound shape is too complex to expand 複合シェイプが複雑すぎて拡張できません +@msg the container of the listed objects is gone 指定したオブジェクトを含むコンテナがなくなりました +@msg the copy `{user_id}` can't be read as a library コピー `{user_id}` をライブラリとして読み込めません +@msg the copy would leave the canvas コピーがキャンバスからはみ出します +@msg the cut cannot be made on this art: {e} このアートはカットできません: {e} +@msg the data isn't an EMF or WMF picture データが EMF または WMF の画像ではありません +@msg the data isn't an EPS or PostScript picture データが EPS または PostScript の画像ではありません +@msg the deferred paste is no longer available 遅延ペーストは使用できなくなりました +@msg the design is too big to draw at every data point of this graph デザインが大きすぎて、このグラフのすべてのデータポイントには描画できません +@msg the design is too big to draw in every column of this graph デザインが大きすぎて、このグラフのすべての棒には描画できません +@msg the dialog it was opened over is no longer open 元のダイアログはすでに閉じられています +@msg the dictionary stack overflowed 辞書スタックがオーバーフローしました +@msg the editing data's hash has no place 編集データのハッシュを書き込む場所がありません +@msg the entities the document declares expand to too much text ドキュメントで宣言されたエンティティを展開すると、テキストが多くなりすぎます +@msg the envelope's content has no bounds エンベロープの内容に境界がありません +@msg the envelope's mesh is malformed エンベロープのメッシュの形式が正しくありません +@msg the file holds no `variableSets` element ファイルに `variableSets` 要素がありません +@msg the file is larger than {_1} MB ファイルが {_1} MB を超えています +@msg the file is too large ファイルが大きすぎます +@msg the file was updated or encrypted already ファイルはすでに更新または暗号化されています +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) 行の先頭の文字には、カーニングの対象となる前の文字がありません: 上下 (dy) にのみ移動します +@msg the font {_1} is not available フォント {_1} は使用できません +@msg the format has no file extension この形式にはファイル拡張子がありません +@msg the graphic can't be read: {e} グラフィックを読み込めません: {e} +@msg the graphic is empty グラフィックが空です +@msg the graphic's data is damaged グラフィックのデータが破損しています +@msg the graphic's document has no artboard グラフィックのドキュメントにアートボードがありません +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them `{name}` のヘッダー `{cell}` は、読み戻すと変数 `{other}` として扱われます: どちらかの名前を変更してください +@msg the image `{path}` isn't followed: {r} 画像 `{path}` は参照しません: {r} +@msg the image has no embedded pixels 画像に埋め込みピクセルがありません +@msg the image is empty 画像が空です +@msg the image is fully transparent 画像が完全に透明です +@msg the image is not on an artboard; pass rect 画像がアートボード上にありません。rect を指定してください +@msg the image's pixels are missing 画像のピクセルがありません +@msg the image's pixels aren't in the document 画像のピクセルがドキュメント内にありません +@msg the image's pixels can't be read: {e} 画像のピクセルを読み込めません: {e} +@msg the image's transparency doesn't fit it 画像の透明情報が画像と合っていません +@msg the job would print more than {MAX_PAGES} pages このジョブでは {MAX_PAGES} ページを超えてプリントされます +@msg the job would print more than {_1} pages このジョブでは {_1} ページを超えてプリントされます +@msg the layers hold no art to mask レイヤーにマスクするアートがありません +@msg the layout moved レイアウトが移動しました +@msg the library has a group `{name}` already ライブラリにはすでにグループ `{name}` があります +@msg the library has no group `{name}` ライブラリにグループ `{name}` はありません +@msg the library has no item `{item}` ライブラリに項目 `{item}` はありません +@msg the library has no item `{other}` ライブラリに項目 `{other}` はありません +@msg the library holds {MAX_GROUPS} groups already ライブラリにはすでに {MAX_GROUPS} 個のグループがあります +@msg the library holds {MAX_ITEMS} items of this kind already ライブラリにはこの種類の項目がすでに {MAX_ITEMS} 個あります +@msg the name is empty 名前が空です +@msg the object has no fill, stroke or effect オブジェクトに塗り、線、効果のいずれもありません +@msg the object has no graphic style (pass `name`) オブジェクトにグラフィックスタイルがありません (`name` を指定してください) +@msg the objects don't match the plan オブジェクトが計画と一致しません +@msg the open and permissions passwords must differ 文書を開くパスワードと権限パスワードには異なるものを指定してください +@msg the operand stack overflowed オペランドスタックがオーバーフローしました +@msg the page has no area ページに面積がありません +@msg the page's type in an unread object's box isn't in its page art 未読み込みオブジェクトのボックス内にあるページの文字が、ページのアートにありません +@msg the paint behind the proxy is not a gradient プロキシのペイントがグラデーションではありません +@msg the parent must be a layer or sublayer 親にはレイヤーまたはサブレイヤーを指定してください +@msg the path can't be extended パスを延長できません +@msg the path can't be inside one of the objects to attach パスを、配置するオブジェクトの中に置くことはできません +@msg the path has no segments to measure パスに計測できるセグメントがありません +@msg the path is empty パスが空です +@msg the path is too short to shorten that much パスが短すぎて、その長さだけ短くできません +@msg the picture has no objects 画像にオブジェクトがありません +@msg the program runs too long プログラムの実行時間が長すぎます +@msg the program uses too much memory プログラムのメモリ使用量が多すぎます +@msg the rectangle must have a width and a height 長方形には幅と高さが必要です +@msg the rectangle needs a size and must lie on the canvas 長方形にはサイズが必要で、キャンバス上にある必要があります +@msg the selected art has no width 選択したアートに幅がありません +@msg the selection draws nothing 選択範囲は何も描画しません +@msg the selection has no area 選択範囲に面積がありません +@msg the selection has no art to export 選択範囲に書き出せるアートがありません +@msg the selection has no bounds 選択範囲に境界がありません +@msg the selection has no graphic style 選択範囲にグラフィックスタイルがありません +@msg the slice would leave the canvas スライスがキャンバスからはみ出します +@msg the spot colour couldn't be added 特色を追加できませんでした +@msg the subpath has no ends to extend (it is closed or a single point) サブパスに延長できる端点がありません (閉じているか、点が 1 つだけです) +@msg the symbols are nested too deeply to expand their instances シンボルの入れ子が深すぎて、インスタンスを拡張できません +@msg the text file is too large to place as type (at most {_1} MB) テキストファイルが大きすぎて、文字として配置できません (最大 {_1} MB) +@msg the text is empty テキストが空です +@msg the text is over {_1} MB テキストが {_1} MB を超えています +@msg the top object must be a path 最前面のオブジェクトはパスである必要があります +@msg the trace was cancelled トレースがキャンセルされました +@msg the trailer has no size トレーラーにサイズがありません +@msg the type object is flattened to nothing 文字オブジェクトを分割・統合すると何も残りません +@msg the value of axis `{tag}` must be a number or null 軸 `{tag}` の値には数値または null を指定してください +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets 変数ライブラリで宣言されているデータセットが {MAX_LIBRARY_DATASETS} 個を超えています +@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities 変数ライブラリで宣言されているエンティティが {MAX_LIBRARY_ENTITIES} 個を超えています +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables 変数ライブラリで宣言されている変数が {MAX_LIBRARY_VARIABLES} 個を超えています +@msg the variable library declares no variables 変数ライブラリで変数が宣言されていません +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes 変数ライブラリが {MAX_LIBRARY_BYTES} バイトを超えています +@msg the variable library is not UTF-8 変数ライブラリが UTF-8 ではありません +@msg the variable library is not well-formed XML: {e} 変数ライブラリが整形式の XML ではありません: {e} +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep 変数ライブラリの要素の入れ子が {MAX_LIBRARY_NESTING} 階層を超えています +@msg the variable library's document type declaration holds something other than entities of plain text 変数ライブラリの文書型宣言に、プレーンテキストのエンティティ以外のものが含まれています +@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes 変数ライブラリのエンティティを展開すると {MAX_LIBRARY_BYTES} バイトを超えます +@msg the window has no dock ウィンドウにドックがありません +@msg the written PDF has no catalog 書き出した PDF にカタログがありません +@msg the written PDF has no cross-reference table 書き出した PDF に相互参照テーブルがありません +@msg the written PDF's metadata can't be edited 書き出した PDF のメタデータを編集できません +@msg there are no colour swatches to draw 描画するカラースウォッチがありません +@msg there is already a design named `{name}` `{name}` という名前のデザインがすでにあります +@msg there is already a design named `{to}` `{to}` という名前のデザインがすでにあります +@msg there is already a toolbar named `{name}` `{name}` という名前のツールバーがすでにあります +@msg there is already a toolbar named `{to}` `{to}` という名前のツールバーがすでにあります +@msg there is no library: create one first ライブラリがありません: 先に作成してください +@msg these paths are too degenerate for Shaper これらのパスは縮退しているため、Shaper では使用できません +@msg thumbnail: no page {v} (the {what} has {count}) thumbnail: ページ {v} はありません ({what} のページ数は {count}) +@msg tileRange of {what}: {e} {what} の tileRange: {e} +@msg title can't be empty title を空にすることはできません +@msg to (the new name) is required to (新しい名前) は必須です +@msg to must be fill or stroke to には fill または stroke を指定してください +@msg to must be smooth or corner to には smooth または corner を指定してください +@msg too large for a zip archive: package to a folder zip アーカイブには大きすぎます: フォルダーにパッケージしてください +@msg too many Shaper edits Shaper の編集が多すぎます +@msg too many `points` (at most 100000) `points` が多すぎます (最大 100000) +@msg too many characters: at most {MAX_CHARS} 文字数が多すぎます: 最大 {MAX_CHARS} 文字 +@msg too many dots for this size: lower the frequency このサイズにはドットが多すぎます: 線数を下げてください +@msg too many filters over one file 1 つのファイルにかかるフィルターが多すぎます +@msg too many overlapping paths for Shaper 重なり合うパスが Shaper には多すぎます +@msg too many saved clips 保存されたクリップが多すぎます +@msg too many saved graphics states 保存されたグラフィックス状態が多すぎます +@msg toolbar (a toolbar's name) must be a string toolbar (ツールバー名) には文字列を指定してください +@msg toolbar name can't be empty ツールバー名を空にすることはできません +@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (初期設定) または "omit" を指定してください +@msg two saved selections would be named `{_1}` 保存した選択範囲のうち 2 つが `{_1}` という名前になります +@msg type dimension cannot be represented with this text transform このテキストの変形では文字の寸法を表現できません +@msg type dimension must be finite 文字の寸法には有限の値を指定してください +@msg unexpected resources 予期しないリソースがあります +@msg unknown CMYK profile `{name}` `{name}` は不明な CMYK プロファイルです +@msg unknown File Info field `{k}` `{k}` は不明なファイル情報フィールドです +@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) featureChanges 内の `{t}` は不明な OpenType 機能です (`features` で使用できるタグを指定してください) +@msg unknown PDF preset `{name}` (presets: {_1}) `{name}` は不明な PDF プリセットです (プリセット: {_1}) +@msg unknown Warp style 不明なワープスタイルです +@msg unknown alignToPath `{a}` `{a}` は不明な alignToPath の指定です +@msg unknown arrowhead {v} {v} は不明な矢印です +@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) `{k}` は不明な artboardLayout のキーです (layout、columns、spacing、rightToLeft のいずれか) +@msg unknown attribute `{attribute}` `{attribute}` は不明な属性です +@msg unknown attribute `{k}` `{k}` は不明な属性です +@msg unknown axis `{other}` (vertical|horizontal|free) `{other}` は不明な軸の指定です (vertical|horizontal|free) +@msg unknown blend mode `{b}` `{b}` は不明な描画モードです +@msg unknown blend mode {b} {b} は不明な描画モードです +@msg unknown brush type `{o}` `{o}` は不明なブラシの種類です +@msg unknown category `{c}` `{c}` は不明なカテゴリです +@msg unknown category `{c}` ({_1}) `{c}` は不明なカテゴリです ({_1}) +@msg unknown character `{c}` `{c}` は不明な文字です +@msg unknown character style number field 文字スタイルの数値フィールドが不明です +@msg unknown effect `{e}` `{e}` は不明な効果です +@msg unknown end {e} {e} は不明な end の指定です +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) `{name}` は不明な flattenerPreset です (High Resolution、Medium Resolution、Low Resolution、または保存済みのプリセット: flattener.presets.list を参照) +@msg unknown format `{f}` (vcswatches, gpl, ase or css) `{f}` は不明な形式です (vcswatches、gpl、ase、css のいずれか) +@msg unknown from {f} {f} は不明な from の指定です +@msg unknown graph type `{t}` `{t}` は不明なグラフの種類です +@msg unknown grid colours `{name}` (one of {_1}) `{name}` は不明なグリッドカラーです ({_1} のいずれか) +@msg unknown gridType `{t}` `{t}` は不明な gridType です +@msg unknown highlight `{v}` `{v}` は不明なハイライトの指定です +@msg unknown intent `{i}` `{i}` は不明なインテントです +@msg unknown issue kind {id} {id} は不明な問題の種類です +@msg unknown issue kind {v} {v} は不明な問題の種類です +@msg unknown knockout state {k} {k} は不明な抜きの状態です +@msg unknown language `{language}` (one of {_1}) `{language}` は不明な言語です ({_1} のいずれか) +@msg unknown language `{l}` (one of {_1}) `{l}` は不明な言語です ({_1} のいずれか) +@msg unknown method `{m}` ({_1}) `{m}` は不明な方法です ({_1}) +@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) `{other}` は不明なモードです (gray、rgb、hsb、lab、cmyk、web のいずれか) +@msg unknown mode `{other}` (mono|cmyk) `{other}` は不明なモードです (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) `{other}` は不明なモードです (rgb|cmyk|gray|global) +@msg unknown mode `{other}` (set, add, subtract) `{other}` は不明なモードです (set、add、subtract のいずれか) +@msg unknown mode `{o}` `{o}` は不明なモードです +@msg unknown mode {m} {m} は不明なモードです +@msg unknown model `{other}` `{other}` は不明な model です +@msg unknown model `{other}` (keep, new or document) `{other}` は不明な model です (keep、new、document のいずれか) +@msg unknown op {o} {o} は不明な op です +@msg unknown order `{o}`: toggle, front, forward, backward or back `{o}` は不明な order です: toggle、front、forward、backward、back のいずれかを指定してください +@msg unknown overprints `{v}` (preserve, simulate or discard) `{v}` は不明な overprints の指定です (preserve、simulate、discard のいずれか) +@msg unknown panel 不明なパネルです +@msg unknown pieLegend `{v}` (none, standard or wedges) `{v}` は不明な pieLegend の指定です (none、standard、wedges のいずれか) +@msg unknown piePosition `{v}` (even, ratio or stacked) `{v}` は不明な piePosition の指定です (even、ratio、stacked のいずれか) +@msg unknown pieSort `{v}` (none, all or first) `{v}` は不明な pieSort の指定です (none、all、first のいずれか) +@msg unknown plate `{n}` `{n}` は不明な版です +@msg unknown preference `{k}` `{k}` は不明な環境設定です +@msg unknown profile {id} (see stroke.widthProfile.list) {id} は不明なプロファイルです (stroke.widthProfile.list を参照) +@msg unknown proof target `{t}` `{t}` は不明な校正ターゲットです +@msg unknown quotes key `{k}` (double, single) `{k}` は不明な quotes のキーです (double、single のいずれか) +@msg unknown resolution `{v}` `{v}` は不明な解像度です +@msg unknown rule `{rule}` ({_1}) `{rule}` は不明なルールです ({_1}) +@msg unknown rule `{r}` (see color.harmony) `{r}` は不明なルールです (color.harmony を参照) +@msg unknown setting `{k}` `{k}` は不明な設定です +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) `{v}` は不明なシェイプです (circle|ellipse|square|diamond|line) +@msg unknown spacing `{o}` (smooth|steps|distance) `{o}` は不明な間隔の指定です (smooth|steps|distance) +@msg unknown tileType `{t}` ({_1}) `{t}` は不明な tileType です ({_1}) +@msg unknown units 不明な単位です +@msg unknown units `{u}` `{u}` は不明な単位です +@msg unknown value axis `{v}` (left, right or both) `{v}` は不明な数値軸の指定です (left、right、both のいずれか) +@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) `{v}` は不明なバリエーションです (tintsShades|warmCool|vividMuted) +@msg unknown warp style `{s}` `{s}` は不明なワープスタイルです +@msg unknown {key} `{v}` (none, short or full) `{v}` は不明な {key} の指定です (none、short、full のいずれか) +@msg unknown {key} profile `{n}` `{n}` は不明な {key} プロファイルです +@msg unknown {k} key `{key}` (size, position) `{key}` は不明な {k} のキーです (size、position のいずれか) +@msg unreadable object 読み込めないオブジェクトです +@msg unsafe destination file name 出力先のファイル名が安全ではありません +@msg use paths or compound paths パスまたは複合パスを使用してください +@msg useArtboards must be true or false, not {v} useArtboards には true または false を指定してください ({v} は指定できません) +@msg value for `{var}` must match its variable kind `{var}` の値は変数の種類に合わせてください +@msg variable `{_1}` is already defined 変数 `{_1}` はすでに定義されています +@msg variable `{name}` has no `trait` 変数 `{name}` に `trait` がありません +@msg variable `{name}` is already defined 変数 `{name}` はすでに定義されています +@msg variable `{name}` is declared twice 変数 `{name}` が 2 回宣言されています +@msg variable `{to}` already exists 変数 `{to}` はすでにあります +@msg view is required view は必須です +@msg view must be one of {_1} view には {_1} のいずれかを指定してください +@msg warpStyle can only edit an applied Warp effect warpStyle で編集できるのは、適用済みのワープ効果だけです +@msg width and height must be more than 0 and at most {limit} width と height には 0 より大きく {limit} 以下の値を指定してください +@msg width factors must be finite 幅の係数には有限の値を指定してください +@msg width must be positive width には正の値を指定してください +@msg width out of range 幅が範囲外です +@msg width/height must be > 0 width/height には 0 より大きい値を指定してください +@msg x and y must be finite numbers x と y には有限の数値を指定してください +@msg x and y must be given together x と y は一緒に指定してください +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone には anchor が必要です: anchor でパネルの横にドッキングするか、side (Start または End) で新しいドック列にドッキングしてください +@msg zoom must be positive zoom には正の値を指定してください +@msg {C}: no effect at index {list} {C}: インデックス {list} に効果はありません +@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} は保護されているため、削除されません +@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} は保護されています: 設定を別の名前で保存してください +@msg {LABEL} produced an empty result {LABEL} の結果が空になりました +@msg {_1} MB of document, at most {_2} MB ドキュメントが {_1} MB あります (上限は {_2} MB) +@msg {_1} MB of images, at most {_2} MB 画像が {_1} MB あります (上限は {_2} MB) +@msg {_1} bytes of RGBA data for a {width} × {height} image {width} × {height} の画像に対して RGBA データが {_1} バイトあります +@msg {_1} can't be read, or changed since it was read: relink or update it first {_1} を読み込めないか、読み込み後に変更されています: 先に再リンクまたは更新してください +@msg {_1} can't go into {into} {_1} を {into} に入れることはできません +@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1} ではプリントを低解像度に制限できません: PDF 1.4 以降を選択するか、高解像度プリントを許可してください +@msg {_1} files can't be opened yet {_1} ファイルはまだ開けません +@msg {_1} files can't be password-protected: remove the passwords or choose no standard {_1} ファイルはパスワードで保護できません: パスワードを削除するか、規格なしを選択してください +@msg {_1} files can't be {_2} {_1} ファイルは {_2} にできません +@msg {_1} files can't carry editing data: turn off preserveEditing {_1} ファイルには編集データを含められません: preserveEditing をオフにしてください +@msg {_1} files can't have PDF layers: turn off createLayers {_1} ファイルには PDF レイヤーを含められません: createLayers をオフにしてください +@msg {_1} files need a creation date, and this platform has no clock to give one {_1} ファイルには作成日が必要ですが、このプラットフォームには日時を取得できる時計がありません +@msg {_1} isn't a path {_1} はパスではありません +@msg {_1} options: {e} {_1} オプション: {e} +@msg {_1} paints nothing to rasterize {_1} にはラスタライズできる描画内容がありません +@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) {_1} のパスワードは ASCII 文字で {MAX_LEGACY_PASSWORD} 文字以内です (PDF 1.7 以降では任意の文字を使用できます) +@msg {_1} with linked images or an image map writes several files: use document.export リンク画像またはイメージマップのある {_1} は複数のファイルを書き出します: document.export を使用してください +@msg {_1} writes one file per artboard here: use document.export, or name one artboard ここでは {_1} はアートボードごとに 1 つのファイルを書き出します: document.export を使用するか、アートボードを 1 つ指定してください +@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} ピクセルは {_3} には大きすぎます (1 辺あたり最大 {max} ピクセル): 拡大・縮小率を下げてください +@msg {_1}: belongs to another account {_1}: 別のアカウントのものです +@msg {_1}: not a folder of its own {_1}: 専用のフォルダーではありません +@msg {_1}: not a plain file {_1}: 通常のファイルではありません +@msg {dest}: {e} {dest}: {e} +@msg {doc} has no folder {doc} にフォルダーがありません +@msg {file} isn't linked {file} はリンクされていません +@msg {folder} is not a package folder {folder} はパッケージフォルダーではありません +@msg {folder}: more than {max} files and folders {folder}: ファイルとフォルダーが {max} 個を超えています +@msg {folder}: {e} {folder}: {e} +@msg {g} is not a Live Paint group {g} はライブペイントグループではありません +@msg {id} is not a slice {id} はスライスではありません +@msg {key} must be 0–{MAX_ADD_AROUND} pt {key} には 0–{MAX_ADD_AROUND} pt を指定してください +@msg {key} must be [x,y] {key} には [x,y] を指定してください +@msg {key} must be a percentage {min}..100 {key} には {min}..100 のパーセント値を指定してください +@msg {key} must be a positive number, not {n} {key} には正の数値を指定してください ({n} は指定できません) +@msg {key} must be a positive number, not {v} {key} には正の数値を指定してください ({v} は指定できません) +@msg {key} must be one of {_1} {key} には {_1} のいずれかを指定してください +@msg {key} must be {_1}, got {s} {key} には {_1} を指定してください (指定値: {s}) +@msg {key} must name a floating panel {key} にはフローティングパネルを指定してください +@msg {key} must name a panel in another group {key} には別のグループにあるパネルを指定してください +@msg {key} must name a panel that shows {key} には表示されているパネルを指定してください +@msg {key} must name a registered panel {key} には登録済みのパネルを指定してください +@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} ファイルは透明に対応していませんが、ファイルに {what} が含まれます: 先に分割・統合してください (object.flattenTransparency) +@msg {kind} images can't be opened (see document.formats) {kind} 画像は開けません (document.formats を参照) +@msg {k} `{v}` is not one of the choices {k} `{v}` は選択肢にありません +@msg {k} has control characters{_1} {k} に制御文字{_1} が含まれています +@msg {k} is too long (at most {MAX_TEXT} characters) {k} が長すぎます (最大 {MAX_TEXT} 文字) +@msg {k} must be > 0 {k} には 0 より大きい値を指定してください +@msg {k} must be a number (pt) or a length such as "210 mm" {k} には数値 (pt)、または "210 mm" のような長さを指定してください +@msg {k} must be a number from {min} to {max} {k} には {min} から {max} までの数値を指定してください +@msg {k} must be a number or "auto" {k} には数値または "auto" を指定してください +@msg {k} must be a string {k} には文字列を指定してください +@msg {k} must be text {k} にはテキストを指定してください +@msg {k} must be true or false {k} には true または false を指定してください +@msg {k} must be {{size?, position?}} {k} には {{size?, position?}} を指定してください +@msg {k}: can't read `{s}` as a length {k}: `{s}` を長さとして読み取れません +@msg {label} must be a number at most {max} {label} には {max} 以下の数値を指定してください +@msg {label} must be a number from -1296 to 1296 {label} には -1296 から 1296 までの数値を指定してください +@msg {label} must be a number from 1 to 10000 {label} には 1 から 10000 までの数値を指定してください +@msg {name} `{s}`: {ids} {name} `{s}`: {ids} +@msg {name} is built in and can't be deleted {name} は組み込みのため、削除できません +@msg {name} must be {lo}–{hi}{unit} (got {v}) {name} には {lo}–{hi}{unit} を指定してください (指定値: {v}) +@msg {name}: CRC or size mismatch {name}: CRC またはサイズが一致しません +@msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: この Affinity ファイルは埋め込みプレビューしか読み込めませんでした。「ファイル › 開く」で警告とともに表示するか、配置する前に Affinity から SVG または PDF を書き出してください +@msg {name}: {e} {name}: {e} +@msg {native}; its embedded preview can't be read either ({e}) {native}。埋め込みプレビューも読み込めません ({e}) +@msg {std} files are CMYK, and the file would have colours in {_1} {std} ファイルは CMYK ですが、ファイルに {_1} のカラーが含まれます +@msg {std} files embed their fonts, and the file would have one that isn't {std} ファイルにはフォントを埋め込みますが、ファイルに埋め込まれないフォントが含まれます +@msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway {target} はこのドキュメントの読み込み元のファイルで、読み込み時に除外された内容があります ({what}): 上書きすると、それらは完全に失われます。別のファイルに書き出すか、それでも置き換える場合は acknowledgeLoss: true を指定してください +@msg {ty} brushes need selected art (or params.{k}) {ty} ブラシには選択したアート (または params.{k}) が必要です +@msg {v} is not an asset id {v} はアセット ID ではありません +@msg {what} can't be intertwined {what} は織り交ぜられません +@msg {what} doesn't fit the paper with its bleed and marks {what} は、裁ち落としとトンボを含めると用紙に収まりません +@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} は {MAX_TILES} 枚を超えるタイルにプリントされます: 縮小してください Playback Options 再生オプション Accelerated 高速 Step by Step ステップ実行 diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index d43d17220c..52c8c2e7bf 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -261,7 +261,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "type.convertListToText", "Convert to Text", "", - "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", + "{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs, in every frame of a threaded story (or of those the Type tool's selection touches), become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type", ), ( "align.horizontalLeft", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0b0435b3c6..bbe91da5a6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -54,7 +54,7 @@ pub mod variables; use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{LiveCorners, Node, StrokeLayer}; +use vectorcraft_doc::{LiveCorners, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_engine::inspect::StrokeMixed; use vectorcraft_geom::shapes::CornerKind; @@ -322,11 +322,13 @@ pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) { } /// Whether the selected objects' fills and strokes differ (the proxies show "?"), cached per -/// document revision and Appearance panel item. +/// document revision, Appearance panel item and picked gradient mesh points (picking points +/// changes no revision). pub(crate) fn mixed_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (bool, bool) { + type Key = (u64, u64, Option, Vec<(NodeId, Vec)>); let Some(st) = app.session.active() else { return (false, false) }; - let key = (st.uid, st.revision, app.session.appearance_item()); - match pstate::), (bool, bool))>>(ctx, "proxy-mixed") { + let key: Key = (st.uid, st.revision, app.session.appearance_item(), app.session.fill_mesh_points()); + match pstate::>(ctx, "proxy-mixed") { Some((k, mixed)) if k == key => mixed, _ => { let mixed = app.session.proxy_mixed(); @@ -365,13 +367,23 @@ pub(crate) fn stroke_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> StrokeM } /// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance -/// panel's active item's, else the first selected object's. +/// panel's active item's, else the first picked gradient mesh point's opacity (which +/// `transparency.set` changes then) with its object's blend mode, else the first selected object's. pub(crate) fn current_transparency(app: &VectorcraftApp) -> Option<(f32, BlendMode)> { let n = first_node(app)?; - Some(match app.session.appearance_item().and_then(|i| n.appearance.items.get(i)) { - Some(it) => (it.opacity(), it.blend()), - None => (n.opacity, n.blend), - }) + if let Some(it) = app.session.appearance_item().and_then(|i| n.appearance.items.get(i)) { + return Some((it.opacity(), it.blend())); + } + let picked = app.session.mesh_points_picked().and_then(|points| { + let (id, v) = points.first()?; + let d = &app.session.active()?.doc; + let m = match &d.node(*id)?.kind { + NodeKind::Mesh(m) => m, + _ => return None, + }; + Some((m.points.get(*v.first()?)?.opacity, d.node(*id)?.blend)) + }); + Some(picked.unwrap_or((n.opacity, n.blend))) } /// Is the active proxy "?" (the selected objects' paints differ)? diff --git a/crates/ui-egui/src/panels/color/tests.rs b/crates/ui-egui/src/panels/color/tests.rs index cd7b88cdd0..bd605eef01 100644 --- a/crates/ui-egui/src/panels/color/tests.rs +++ b/crates/ui-egui/src/panels/color/tests.rs @@ -949,6 +949,60 @@ fn a_mixed_selection_has_no_colour_to_edit() { assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false), "a new revision recomputes it"); } +/// With gradient mesh points picked, the panel edits and shows their color (a solid fill +/// recolors just them), not the mesh object's fill, so the sliders keep the value they set, +/// through a drag too; "?" when the picked points differ, which picking alone (no new revision) +/// updates. The Control bar's opacity is the picked point's. +#[test] +fn picked_mesh_points_are_the_color_the_panel_shows_and_edits() { + use vectorcraft_doc::NodeKind; + use vectorcraft_engine::ViewInfo; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = app(); + let ctx = egui::Context::default(); + let id = app.session.active().unwrap().selection.objects[0]; + app.run("paint.setFill", json!({"color": "#ff8500"})).unwrap(); + app.run("object.mesh.create", json!({"rows": 2, "cols": 2})).unwrap(); + app.run("object.mesh.setPointColor", json!({"id": id.0, "index": 0, "color": "#0000ff", "opacity": 0.5})).unwrap(); + let v = ViewInfo::default(); + app.session.select_tool("mesh", v).unwrap(); + let click = |app: &mut VectorcraftApp, x: f64, y: f64, shift: bool| { + for k in [PointerKind::Down, PointerKind::Up] { + let mut ev = PointerEvent::new(k, x, y); + ev.mods.shift = shift; + app.session.pointer(&ev, v).unwrap(); + } + }; + let picked = |app: &VectorcraftApp| app.session.fill_mesh_points().into_iter().flat_map(|(_, v)| v).collect::>(); + // The top-left corner point (index 0). + click(&mut app, 10.0, 10.0, false); + assert_eq!(picked(&app), vec![0], "the click picks the top-left point"); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false)); + let tgt = target_with(&app, false); + assert_eq!(tgt.color().map(|c| c.to_hex()), Some("#0000ff".into()), "the picked point's color, not the object's fill"); + assert_eq!(crate::panels::current_transparency(&app).map(|t| t.0), Some(0.5), "the picked point's opacity"); + // A slider drag: each step shows the color it set, and the release keeps it. + for c in [Color::rgb8(250, 0, 120), Color::rgb8(240, 10, 140)] { + let tgt = target_with(&app, false); + apply(&mut app, &tgt, c, Live::Dragging, false); + assert_eq!(target_with(&app, false).color(), Some(c), "the drag shows the color it set"); + } + let orange = Color::rgb8(255, 128, 0); + let tgt = target_with(&app, false); + apply(&mut app, &tgt, orange, Live::Released, false); + assert_eq!(target_with(&app, false).color(), Some(orange), "the panel keeps the color it set"); + let NodeKind::Mesh(m) = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("still a gradient mesh") }; + assert_eq!(m.points[0].color, orange); + let middle = m.idx(0, 1); + assert_ne!(m.points[middle].color, orange, "only the picked point"); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false)); + // Shift-click the top middle point (still the object's color): the two differ. + click(&mut app, 35.0, 10.0, true); + assert_eq!(picked(&app), vec![0, middle], "the Shift-click adds the top middle point"); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (true, false), "picking recomputes it"); +} + #[test] fn rgb_roundtrip() { let c = Color::rgb8(230, 120, 40); diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 3b55d34e11..9c10281783 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -30,6 +30,7 @@ use std::collections::HashMap; use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_color::Paint; +use vectorcraft_doc::resample::{Resample, fitted}; use vectorcraft_engine::cmd::library::{ItemKind, LIBRARY_EXT, LIBRARY_EXTS, Library, LibraryGraphic}; use crate::theme::Tokens; @@ -831,8 +832,7 @@ fn thumbnail(ctx: &egui::Context, library: &str, item: &str, png: &str, px: u32) } let tex = vectorcraft_format::base64_decode(png).and_then(|png| image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok()).map(|img| { - let img = if img.width().max(img.height()) > px { img.resize(px, px, image::imageops::FilterType::Triangle) } else { img }; - let rgba = img.to_rgba8(); + let rgba = shrunk_thumbnail(img, px); let image = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()); ctx.load_texture(format!("library-{library}-{item}-{px}"), image, egui::TextureOptions::LINEAR) }); @@ -846,6 +846,13 @@ fn thumbnail(ctx: &egui::Context, library: &str, item: &str, png: &str, px: u32) tex } +/// Thumbnail `img` shrunk smoothly to at most `px` pixels a side, its colors weighted by its alpha +/// so that clear pixels don't darken the edges of the art, as RGBA. +fn shrunk_thumbnail(img: image::DynamicImage, px: u32) -> image::RgbaImage { + let img = if img.width().max(img.height()) > px { fitted(img, px, px, Resample::Filter(image::imageops::FilterType::Triangle)) } else { img }; + img.into_rgba8() +} + /// Graphic `item` of `library`'s thumbnail in `r` (the chip a dragged graphic shows at the pointer). pub(crate) fn chip(app: &VectorcraftApp, ui: &Ui, r: Rect, library: &str, item: &str) { if let Some(g) = app.session.libraries.get(library).and_then(|l| l.graphics.iter().find(|g| g.id == item)) { diff --git a/crates/ui-egui/src/panels/libraries/tests.rs b/crates/ui-egui/src/panels/libraries/tests.rs index 931a91a444..00ff1bf2cd 100644 --- a/crates/ui-egui/src/panels/libraries/tests.rs +++ b/crates/ui-egui/src/panels/libraries/tests.rs @@ -525,3 +525,16 @@ fn libraries_are_exported_and_imported_from_the_panel_menu() { assert_eq!(names, ["Brand", "Brand (2)"]); assert_eq!(app.ui.status, "Imported library Brand (2)"); } + +/// A graphic's thumbnail shrunk for its tile keeps the color of the art beside clear pixels: the +/// clear black beside it does not darken it. +#[test] +fn a_shrunk_thumbnail_keeps_its_color_beside_clear_pixels() { + let img = image::RgbaImage::from_fn(64, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }); + let small = shrunk_thumbnail(image::DynamicImage::ImageRgba8(img), 16); + assert_eq!(small.dimensions(), (16, 1)); + let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8; + for p in small.pixels() { + assert!(p[3] > 0 && [200, 30, 30].iter().zip(p.0).all(|(&want, c)| m(c, p[3]).abs_diff(m(want, p[3])) <= 1), "{p:?}"); + } +} diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 64daf1ac76..5718aea0ab 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -1,6 +1,6 @@ //! OpenType panel: per-character OpenType features (ligatures, alternates, swashes, small caps, -//! ordinals, fractions, figure style, position) for the selected text or the Type tool's selected -//! characters. +//! ordinals, fractions, figure style, position) for the selected text (every frame of each +//! selected threaded story) or the Type tool's selected characters. use egui::Ui; use serde_json::json; @@ -112,6 +112,22 @@ fn position_label(shown: Option) -> &'static str { shown.and_then(|i| POSITIONS.get(i).copied()).unwrap_or("Mixed") } +/// The Figure menu entry for the characters the panel acts on, or `None` when they differ. +fn shown_figure(app: &VectorcraftApp) -> Option { + shared(app, |s| figure(&OtFeatures::from_tags(s.features.iter().map(String::as_str)))) +} + +/// Whether `flag` is on for every character the panel acts on (`Some(true)`), for none of them +/// (`Some(false)`), or only for some (`None`). +fn shown_flag(app: &VectorcraftApp, flag: Flag) -> Option { + shared(app, |s| flag.get(&OtFeatures::from_tags(s.features.iter().map(String::as_str)))) +} + +/// The Figure menu's text for entry `shown`: Mixed when the characters differ. +fn figure_label(shown: Option) -> &'static str { + shown.and_then(|i| FIGURES.get(i).copied()).unwrap_or("Mixed") +} + /// A change one of the panel's controls makes to the features of each character it acts on. #[derive(Clone, Copy)] enum Change { @@ -161,7 +177,8 @@ fn set(app: &mut VectorcraftApp, f: OtFeatures) { } /// Send `tags` as the `key` param (`features` or `featureChanges`) for the Type tool's range or -/// caret, or else for the selected type. +/// caret, or else for every frame of the selected stories +/// ([`super::character::selected_stories`]). fn run_features(app: &mut VectorcraftApp, key: &str, tags: Vec) { // After Select All in threaded text: every frame of the story. let story = super::character::text_story(app).and_then(|story| story.first().copied()); @@ -169,7 +186,11 @@ fn run_features(app: &mut VectorcraftApp, key: &str, tags: Vec) { (Some(_), Some(first)) => ("text.setRangeStyle", json!({ "id": first.0, "story": true })), (Some((id, a, b)), None) if b > a => ("text.setRangeStyle", json!({ "id": id.0, "start": a, "end": b })), (Some((id, _, _)), None) => ("text.setRangeStyle", json!({ "id": id.0 })), - (None, _) => ("text.setStyle", json!({})), + (None, _) => { + let mut p = json!({}); + super::character::target_selected_stories(app, &mut p); + ("text.setStyle", p) + } }; p[key] = json!(tags); if let Err(e) = app.run(cmd, p) { @@ -184,10 +205,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }; let cur = OtFeatures::from_tags(st.features.iter().map(String::as_str)); let mut change = None; + let fig = shown_figure(app); ui.horizontal(|ui| { widgets::dim_label(ui, tl!("Figure:")); - let fig = figure(&cur); - if let Some(i) = widgets::dropdown(ui, "ot-figure", FIGURES[fig], &FIGURES, 170.0) { + if let Some(i) = widgets::dropdown(ui, "ot-figure", figure_label(fig), &FIGURES, 170.0) { change = Some(Change::Figure(i)); } }); @@ -199,26 +220,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } }); widgets::divider(ui); - let mut next = cur; - if let Some(c) = change { - c.apply(&mut next); - } + // A row whose characters differ shows a dash, and a click turns the feature on for all of them. for (label, flag) in rows() { - let on = flag.get(&next); - if widgets::check(ui, label, on, true) { - change = Some(Change::Toggle(flag, !on)); + let shown = shown_flag(app, flag); + if widgets::check3(ui, label, shown, true) { + change = Some(Change::Toggle(flag, shown != Some(true))); } } // The font, not the text, decides which numbered sets can be offered. Each set is an // independent feature, so the menu permits several at once. A font's own label wins over - // the numbered fallback. See the public Stylistic Sets help page. - let sets = offered_sets(&st); + // the numbered fallback. See the public Stylistic Sets help page. A set is checked when every + // character has it. + let sets: Vec<(StylisticSet, Flag, bool)> = offered_sets(&st) + .into_iter() + .map(|set| { + let flag = Flag::Extra(EXTRA_FEATURES[usize::from(set.number - 1)]); + let on = shown_flag(app, flag) == Some(true); + (set, flag, on) + }) + .collect(); ui.add_enabled_ui(!sets.is_empty(), |ui| { ui.menu_button(tl!("Stylistic Sets"), |ui| { widgets::menu_scroll(ui, |ui| { - for set in &sets { - let flag = Flag::Extra(EXTRA_FEATURES[usize::from(set.number - 1)]); - let on = flag.get(&next); + for (set, flag, on) in &sets { + let (flag, on) = (*flag, *on); if widgets::check_name(ui, &set_label(set), on) { change = Some(Change::Toggle(flag, !on)); } @@ -481,6 +506,90 @@ mod tests { } } + /// The features of every run of `id`, each as its tags. + fn frame_features(app: &VectorcraftApp, id: u64) -> Vec> { + match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| r.style.features.clone()).collect(), + _ => panic!("not text"), + } + } + + /// With the Selection tool a selected frame of threaded text stands for its whole story: the + /// Figure and Position menus show Mixed when a locked or hidden frame differs, and a choice + /// reaches every frame in one undo step, each keeping its other features, while other type keeps + /// its own. A story selected through two frames and a group is changed once. + #[test] + fn selection_tool_features_reach_every_frame_of_the_story() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog 1234.\n".repeat(12); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other 12", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("text.setStyle", json!({"ids": [c], "features": ["onum", "smcp", "sups", "-liga"]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + for id in [a, b, c] { + assert!(!frame_features(&app, id).is_empty(), "frame {id} holds text"); + } + assert_eq!(shown_figure(&app), None, "the hidden frame's figures differ"); + assert_eq!(figure_label(shown_figure(&app)), "Mixed"); + assert_eq!(shown_position(&app), None, "the hidden frame's position differs"); + let hidden = frame_features(&app, c); + let has = |app: &VectorcraftApp, id: u64, tag: &str| frame_features(app, id).iter().all(|f| f.iter().any(|t| t == tag)); + + let undo = undo_steps(&app); + edit(&mut app, Change::Figure(1)); + assert_eq!(undo_steps(&app), undo + 1, "one undo step"); + assert_eq!(shown_figure(&app), Some(1)); + for id in [a, b] { + assert!(frame_features(&app, id).iter().all(|f| *f == ["tnum"]), "frame {id}: {:?}", frame_features(&app, id)); + } + assert!( + ["smcp", "sups", "-liga", "tnum"].iter().all(|t| has(&app, c, t)) + && frame_features(&app, c).iter().all(|f| !f.iter().any(|t| t == "onum")), + "{:?}", + frame_features(&app, c) + ); + assert!(frame_features(&app, other).iter().all(Vec::is_empty), "other type keeps its features"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(frame_features(&app, c), hidden, "undo restores the hidden frame"); + + // Standard Ligatures is off in the hidden frame only: its row shows a dash, and a click on + // it turns ligatures on in every frame. + let liga = rows().into_iter().find(|(l, _)| *l == "Standard Ligatures").map(|(_, f)| f); + let Some(liga) = liga else { panic!("no Standard Ligatures row") }; + assert_eq!(shown_flag(&app, liga), None); + let ctx = egui::Context::default(); + let draw = |app: &mut VectorcraftApp, ui: &mut Ui| show(app, ui); + super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], draw); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], draw); + let at = super::super::tests_appearance::text_rect(&texts, "Standard Ligatures").center(); + let undo = undo_steps(&app); + super::super::tests_appearance::click(&ctx, &mut app, at, draw); + assert_eq!(undo_steps(&app), undo + 1, "one undo step"); + assert_eq!(shown_flag(&app, liga), Some(true)); + for id in [a, b, c] { + assert!(frame_features(&app, id).iter().all(|f| !f.iter().any(|t| t == "-liga")), "frame {id} has ligatures"); + } + assert!(has(&app, c, "smcp"), "the hidden frame keeps its other features"); + + // Two frames of the story and the other type in a group: each story once. + app.run("object.unlockAll", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a, other]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group, b]})).unwrap(); + edit(&mut app, Change::Position(1)); + for id in [a, b, c, other] { + assert!(frame_features(&app, id).iter().all(|f| f.iter().filter(|t| *t == "sups").count() == 1), "frame {id} has sups once"); + } + } + /// Type with two positions turned on shows the Position menu as mixed, and the entry of one /// of them turns the other off. #[test] diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index e02d81e14c..e594052f60 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -79,13 +79,7 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) { /// Run `cmd` on the paragraphs the Paragraph panel acts on ([`format`]) and return its result. With /// the Selection tool these are the paragraphs of every frame of the selected stories /// ([`super::character::selected_stories`]). -pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Result { - run_on(app, cmd, p, true) -} - -/// [`run_on_paragraphs`], with the Selection tool on the selected type objects alone unless -/// `stories`. -fn run_on(app: &mut VectorcraftApp, cmd: &str, mut p: Value, stories: bool) -> Result { +pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Value) -> Result { if let Some((id, a, b)) = super::character::text_editing(app) { super::character::end_typing(app); match super::character::text_story(app) { @@ -97,7 +91,7 @@ fn run_on(app: &mut VectorcraftApp, cmd: &str, mut p: Value, stories: bool) -> R p["end"] = json!(b); } } - } else if stories { + } else { super::character::target_selected_stories(app, &mut p); } app.run(cmd, p) @@ -260,7 +254,7 @@ pub(crate) fn shared_single_word_justify(app: &VectorcraftApp) -> Option(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option { +pub(crate) fn shared_para(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option { let mut acc = Shared::None; let mut any = false; visit_paragraphs(app, |p| { @@ -474,12 +468,13 @@ pub(crate) fn number_sample(style: vectorcraft_doc::text::NumberStyle, n: u32) - } /// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`): the markers of the -/// list paragraphs of the selected type, or of those the Type tool's selection touches, become -/// characters of the text and the lists are turned off (`text.convertListToText`, which also moves -/// the Type tool's anchor and caret past the characters inserted at or before them). +/// list paragraphs the Paragraph panel acts on ([`run_on_paragraphs`]: with the Selection tool, +/// every frame of the selected stories) become characters of the text and the lists are turned +/// off (`text.convertListToText`, which also moves the Type tool's anchor and caret past the +/// characters inserted at or before them). pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result { text_style(app).ok_or("select some type first")?; - run_on(app, "text.convertListToText", json!({}), false) + run_on_paragraphs(app, "text.convertListToText", json!({})) } /// The Bullets button and the arrow beside it (Paragraph panel). The button makes the paragraphs diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 180f0b1fa1..f6c9f064dc 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -678,6 +678,50 @@ fn convert_to_text_keeps_the_caret_by_its_character_in_threaded_type() { assert_eq!(super::super::character::text_editing(&app).map(|(id, _, _)| id), Some(NodeId(a))); } +/// With the Selection tool, Convert to Text takes the same story as Apply Numbers: the numbers +/// of every frame of the selected story, its locked and hidden frames included, become characters +/// of the text in one undo step, and other type keeps its list. +#[test] +fn selection_tool_convert_to_text_takes_every_frame_of_the_story() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setFormat", json!({"ids": [other], "list": "bullet"})).unwrap(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let frame = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (t.plain_text(), (0..t.paragraph_count()).map(|i| t.para_at(i).list.is_some()).collect::>()), + _ => panic!("not text"), + }; + let undo_steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + app.run("type.applyNumbers", json!({})).unwrap(); + for id in [a, b, c] { + let (_, lists) = frame(&app, id); + assert!(!lists.is_empty() && lists.iter().all(|l| *l), "Apply Numbers numbers frame {id}"); + } + let before = frame(&app, c); + + let steps = undo_steps(&app); + app.run("type.convertListToText", json!({})).unwrap(); + assert_eq!(undo_steps(&app), steps + 1, "one undo step"); + for id in [a, b, c] { + let (plain, lists) = frame(&app, id); + assert!(lists.iter().all(|l| !*l), "frame {id} leaves the list"); + assert!(plain.contains(".\tThe quick"), "frame {id} holds its numbers as text: {plain:?}"); + } + assert_eq!(frame(&app, other), ("Other".to_string(), vec![true]), "other type keeps its list"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(frame(&app, c), before, "undo restores the hidden frame"); +} + /// Reset Panel turns Hyphenate and Roman Hanging Punctuation off and sets the Hyphenation and /// Justification options back to their defaults in every paragraph it acts on, so the paragraphs /// save neither. diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 880de94c16..4f8ba904de 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -1,10 +1,11 @@ //! Character Styles and Paragraph Styles panels (one implementation, two kinds). //! -//! Click a style to apply it to the selected text (or the Type tool's selected characters); -//! Alt-click applies it and clears overrides. Double-clicking a character style's row opens its -//! Character Style Options (`ui.charStyleOptions`), as the panel menu does; a paragraph style's row -//! opens its Paragraph Style Options (`ui.paraStyleOptions`). The style of the -//! selection is highlighted, with a "+" when the text overrides some of its attributes. New makes +//! Click a style to apply it to the selected text (every frame of each selected threaded story) or +//! the Type tool's selected characters; Alt-click applies it and clears overrides. Double-clicking a +//! character style's row opens its Character Style Options (`ui.charStyleOptions`), as the panel +//! menu does; a paragraph style's row opens its Paragraph Style Options (`ui.paraStyleOptions`). +//! The style all of that text has is highlighted, with a "+" when some of the text overrides some +//! of its attributes; text in several styles highlights none. New makes //! a style from the selected text; Alt-click on New first asks for its name (`ui.newCharStyle`, //! `ui.newParaStyle`). @@ -15,8 +16,9 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::Document; use vectorcraft_engine::cmd::{fileio, textstyles}; -use super::character::{end_typing, text_editing, text_style}; +use super::character::{end_typing, shared, text_editing, text_style}; use super::memo::Memo; +use super::paragraph::shared_para; use super::{alt_held, pstate, selection_len, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; @@ -172,14 +174,25 @@ thread_local! { static LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// The selection's style name (Normal when none) and every one of its attributes, written as a -/// style holds them, if text is selected. -fn current(app: &VectorcraftApp, kind: Kind) -> Option<(Option, Map)> { - let (c, p) = text_style(app)?; - Some(match kind { - Kind::Char => (c.style_name.clone(), textstyles::char_attrs(&c)), - Kind::Para => (p.style_name.clone(), textstyles::para_attrs(&p)), - }) +/// The style name (`None`: the Normal style) that every character the panel acts on has (paragraph +/// styles: every paragraph), with the Selection tool in every frame of the selected stories; +/// `None` when they differ or no type is selected. +fn current(app: &VectorcraftApp, kind: Kind) -> Option> { + match kind { + Kind::Char => shared(app, |s| s.style_name.clone()), + Kind::Para => shared_para(app, |p| p.style_name.clone()), + } +} + +/// Whether any character (paragraph styles: paragraph) the panel acts on differs from the style +/// attributes `attrs` in one of them. +fn has_overrides(app: &VectorcraftApp, kind: Kind, attrs: &Map) -> bool { + let differs = |m: Map| attrs.iter().any(|(k, v)| m.get(k) != Some(v)); + let same = match kind { + Kind::Char => shared(app, |s| differs(textstyles::char_attrs(s))), + Kind::Para => shared_para(app, |p| differs(textstyles::para_attrs(p))), + }; + same != Some(false) } /// Parameters targeting the selection: the Type tool's selected characters (paragraph styles: the @@ -196,14 +209,19 @@ fn target(app: &VectorcraftApp, kind: Kind, mut p: Value) -> Value { } /// [`target`] for applying a style: after Select All in threaded text, all the text of every -/// frame of the story. +/// frame of the story; with the Selection tool, every frame of the selected stories +/// ([`super::character::selected_stories`]). fn apply_target(app: &VectorcraftApp, kind: Kind, mut p: Value) -> Value { + if text_editing(app).is_none() { + super::character::target_selected_stories(app, &mut p); + return p; + } match super::character::text_story(app) { - Some(story) if text_editing(app).is_some() => { + Some(story) => { p["ids"] = json!(story.iter().map(|f| f.0).collect::>()); p } - _ => target(app, kind, p), + None => target(app, kind, p), } } @@ -242,9 +260,8 @@ fn open_style_options(app: &mut VectorcraftApp, kind: Kind, name: &str) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let t = Tokens::get(ui.ctx()); let all = styles(app, kind); - let cur = current(app, kind); let normal = all.first().map(|s| s.0.clone()).unwrap_or_default(); - let cur_name = cur.as_ref().map(|(n, _)| n.clone().unwrap_or_else(|| normal.clone())); + let cur_name = current(app, kind).map(|n| n.unwrap_or_else(|| normal.clone())); let mut clicked: Option<(String, egui::Modifiers)> = None; // A double-clicked style's row, whose options open after the list is laid out. let mut options: Option = None; @@ -270,8 +287,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { widgets::list_box(ui, |ui| { for (name, attrs, _) in &all { let is_cur = cur_name.as_deref() == Some(name.as_str()); - // Overrides: an attribute the style sets that the selection doesn't match. - let overridden = is_cur && cur.as_ref().is_some_and(|(_, m)| attrs.iter().any(|(k, v)| m.get(k) != Some(v))); + // Overrides: an attribute the style sets that some of the text doesn't match. + let overridden = is_cur && has_overrides(app, kind, attrs); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); if resp.drag_started_by(egui::PointerButton::Primary) { egui::DragAndDrop::set_payload(ui.ctx(), RowDrag { kind, name: name.clone() }); @@ -310,7 +327,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { if scroll_to.is_some() { set_pstate::>(ui.ctx(), kind.scroll_key(), None); } - let has_text = cur.is_some(); + let has_text = text_style(app).is_some(); if let Some((name, m)) = clicked { sel = super::swatches::click_selection(ui, kind.anchor_key(), sel, &listed, &name, m); // Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel. The second @@ -381,7 +398,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let normal = if kind == Kind::Char { "[Normal Character Style]" } else { "[Normal Paragraph Style]" }; let user = sel.as_ref().is_some_and(|s| s != normal); let doomed: Vec<&String> = many.iter().filter(|s| *s != normal).collect(); - let has_text = current(app, kind).is_some() || selection_len(app) > 0; + let has_text = text_style(app).is_some() || selection_len(app) > 0; let p = kind.prefix(); if menu_item(ui, &crate::i18n::fmt(tl!("New {kind} Style…"), &[("kind", kind.what_label())]), true, false) { // A later typing preview must not restore the document from before this capture. diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index a83103aec8..e550f82ab4 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -29,8 +29,8 @@ fn panels_draw_and_apply_headless() { out.textures_delta.clear(); } app.run("charStyle.apply", target(&app, Kind::Char, json!({"name": "Big"}))).unwrap(); - let (name, attrs) = current(&app, Kind::Char).unwrap(); - assert_eq!((name.as_deref(), attrs["size"].as_f64()), (Some("Big"), Some(30.0))); + let (style, _) = text_style(&app).unwrap(); + assert_eq!((current(&app, Kind::Char), style.size), (Some(Some("Big".to_string())), 30.0)); } #[test] @@ -818,6 +818,74 @@ fn the_styles_are_listed_once_per_document_version() { } } +/// The style names of `id`'s runs (`Kind::Para`: of its paragraphs). +fn frame_style_names(app: &VectorcraftApp, kind: Kind, id: u64) -> Vec> { + match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) if kind == Kind::Char => t.runs.iter().map(|r| r.style.style_name.clone()).collect(), + NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).style_name.clone()).collect(), + _ => panic!("not text"), + } +} + +/// With the Selection tool a selected frame of threaded text stands for its whole story, in both +/// panels: no row shows as the current style while a locked or hidden frame has another style, a +/// row click applies the style to every frame in one undo step and leaves other type alone, the +/// row shows "+" while a hidden frame overrides the style, and an Alt-click clears that override. +#[test] +fn selection_tool_styles_reach_every_frame_of_the_story() { + use egui::Modifiers; + for kind in [Kind::Char, Kind::Para] { + let what = kind.what(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run(&format!("{}.new", kind.prefix()), json!({"name": "Plain"})).unwrap(); + app.run(&format!("{}.new", kind.prefix()), json!({"name": "Second", "attrs": {}})).unwrap(); + app.run(&format!("{}.apply", kind.prefix()), json!({"name": "Second", "ids": [c]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + for id in [a, b, c] { + assert!(!frame_style_names(&app, kind, id).is_empty(), "frame {id} holds text"); + } + assert_eq!(current(&app, kind), None, "{what} Styles: the hidden frame has another style"); + + let ctx = egui::Context::default(); + let rows = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + ui.set_width(280.0); + show(app, ui, kind); + }; + let undo = app.session.doc().unwrap().history.undo.len(); + click_text_held(&ctx, &mut app, "Plain", Modifiers::NONE, rows); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "{what} Styles: one undo step"); + for id in [a, b, c] { + assert!(frame_style_names(&app, kind, id).iter().all(|n| n.as_deref() == Some("Plain")), "{what} Styles: frame {id}"); + } + assert!(frame_style_names(&app, kind, other).iter().all(Option::is_none), "{what} Styles: other type keeps its style"); + assert_eq!(current(&app, kind), Some(Some("Plain".to_string()))); + + app.run("text.setFormat", json!({"ids": [c], "kerning": 40, "composer": "singleLine"})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, kind)); + assert!(shown.lines().any(|line| line == "Plain+"), "{what} Styles: the hidden frame's override shows: {shown}"); + // A new context, so that this click does not count as the second click of a double-click. + click_text_held(&egui::Context::default(), &mut app, "Plain+", Modifiers::ALT, rows); + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, kind)); + assert!(shown.lines().any(|line| line == "Plain"), "{what} Styles: Alt-click clears the override: {shown}"); + app.run("edit.undo", json!({})).unwrap(); + let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, kind)); + assert!(shown.lines().any(|line| line == "Plain+"), "{what} Styles: undo brings the override back: {shown}"); + } +} + /// A row dragged from the highlighted styles carries all of them: dropped on Create New Style it /// duplicates each in one undo step and highlights the copies, and dropped on Delete it deletes each /// but the Normal style in one undo step and clears the highlight. A row from outside the highlight @@ -1201,7 +1269,7 @@ fn alt_clicking_new_paragraph_style_asks_for_its_name() { assert!(app.session.doc().unwrap().doc.para_styles.is_empty(), "no style is added before OK"); set_name(&mut app, "Body Copy"); crate::dialogs::confirm(&mut app).unwrap(); - let (_, attrs) = current(&app, Kind::Para).unwrap(); + let attrs = textstyles::para_attrs(&text_style(&app).unwrap().1); let doc = &app.session.doc().unwrap().doc; assert_eq!(doc.para_styles.iter().map(|s| (s.name.as_str(), &s.attrs)).collect::>(), [("Body Copy", &attrs)]); diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 229a23c411..4279ac11e4 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -20,12 +20,12 @@ pub(super) const MASK_THUMB: &str = "tr-mask-thumb"; /// The panel's values ([`vectorcraft_engine::Session::transparency_info`]) and its first target /// (the selection, or the object whose mask is being edited), recomputed only when the document -/// or the selection changes. +/// or the selection changes, or other gradient mesh points are picked. fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (TransparencyInfo, Option) { - type Key = (u64, u64, usize, Option, Option); + type Key = (u64, u64, usize, Option, Option, Option)>>); let Some(st) = app.session.active() else { return Default::default() }; let sel = &st.selection.objects; - let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied()); + let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied(), app.session.mesh_points_picked()); let id = egui::Id::unique("tr-state"); if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, (TransparencyInfo, Option))>(id)) && k == key @@ -351,6 +351,50 @@ mod tests { assert!(app.session.active().unwrap().doc.mask_edit.is_none()); } + /// Picking other gradient mesh points (which gives the document no new revision) shows their + /// opacity, the one `transparency.set` changes then. + #[test] + fn picking_mesh_points_shows_their_opacity() { + use vectorcraft_engine::ViewInfo; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let a = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}))["id"].clone(); + run(&mut app, "object.mesh.create", json!({"rows": 2, "cols": 2})); + run(&mut app, "object.mesh.setPointColor", json!({"id": a, "index": 0, "opacity": 0.3})); + run(&mut app, "paint.setStroke", json!({"color": "#000000"})); + let v = ViewInfo::default(); + app.session.select_tool("mesh", v).unwrap(); + let click = |app: &mut VectorcraftApp, x: f64, y: f64| { + for k in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + }; + let picked = |app: &VectorcraftApp| app.session.mesh_points_picked().map(|p| p.into_iter().flat_map(|(_, v)| v).collect::>()); + let ctx = egui::Context::default(); + click(&mut app, 35.0, 10.0); + assert_ne!(picked(&app), Some(vec![0]), "the click picks the top middle point"); + assert_eq!(picked(&app).map(|v| v.len()), Some(1)); + assert_eq!(state(&app, &ctx).0.opacity, Some(100.0)); + click(&mut app, 10.0, 10.0); + assert_eq!(picked(&app), Some(vec![0]), "the click picks the top-left point"); + assert_eq!(state(&app, &ctx).0.opacity, Some(30.0), "the newly picked point's opacity"); + // An active Appearance row takes the opacity, so the values are the object's again. + let rows = app + .session + .active() + .unwrap() + .selection + .objects + .first() + .map(|id| app.session.active().unwrap().doc.node(*id).unwrap().appearance.items.len()); + assert!(rows.is_some_and(|n| n > 0), "the mesh has its stroke row"); + run(&mut app, "appearance.setActiveItem", json!({"index": 0})); + assert_eq!(picked(&app), None); + assert_eq!(app.session.transparency_info().opacity, Some(100.0), "the object's opacity, not the point's"); + assert_eq!(run(&mut app, "transparency.info", json!({}))["opacity"], json!(100.0)); + } + #[test] fn mixed_values_show_blank() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/docs/dimensions.md b/docs/dimensions.md index 377ab9d698..de48bdb776 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -15,13 +15,17 @@ The **Dimension Tool** is in the Modify section of the toolbar. Its strip offers path's length. A press on an anchor picks it when the pointer moves less than 8 pixels before the release; a longer drag drags out the dimension of the segment under the pointer. A curved segment is measured from end to end. With Smart Guides on, both points also snap into line with other objects' - anchors and centres, and the second onto the construction guides through the first; the guides + anchors and centres. With Snap to Perpendicular or Snap to Tangent on, the second point also + snaps onto a segment where the line from the first meets it square or grazes a curve. When the + first point was picked on an anchor, the second also snaps onto the construction guides through + it; any other first point has no construction guides through it. The guides show while hovering, before each click. VectorCraft's own rules decide where a point lands: an anchor within 8 pixels takes it first; a point on a guide stays on the guide where the pointer - is, except that it moves onto a segment within the Smart Guides snapping tolerance, or onto the - place where a construction guide crosses another object's alignment line within twice that - tolerance; a point that no anchor, guide, grid or pixel snapping places lands on the closest - point of a segment within 8 pixels. + is, except that it moves onto a segment within the Smart Guides snapping tolerance, or, on a + construction guide or a Snap to Glyph guide that runs at an angle, onto the place where that + guide crosses another object's alignment line within twice that tolerance; a point that no + anchor, guide, grid or pixel snapping places lands on the closest point of a segment within 8 + pixels. A linear dimension is aligned, horizontal or vertical, as the [linear dimensions help](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/plot-linear-dimensions-of-objects.html) describes: after the second click, clicking again places an aligned dimension, and moving the diff --git a/docs/localization-hi.md b/docs/localization-hi.md index 3d304a5d50..d73e78676b 100644 --- a/docs/localization-hi.md +++ b/docs/localization-hi.md @@ -98,8 +98,12 @@ main added paragraph labels, spine-index validation and two error messages: six Main's workspace version is still 0.21.0; this PR does not change release versioning. `cargo test -p vectorcraft-ui-egui --lib i18n::tests` passes all 55 tests in Ubuntu 24.04 (WSL). -The untranslated report has no Hindi section, meaning it found no missing Hindi rows among the -categories it checks. This does not check dynamically assembled strings that the scanners miss. +On 2026-11-14 the untranslated report on main (`f793044f83`) has a Hindi section with 608 +missing rows: 28 menu strings, 30 `tl!` strings, 11 undo labels and 539 messages. Its +`every language` section lists 68 more that every checked language lacks, Hindi included: 10 menu +strings, 2 undo labels and 56 messages. These include labels for newer features and command +parameter, script and file import or export errors, which show in English until a catalog row +is added. The report does not check dynamically assembled strings that the scanners miss. Additional checks compare placeholders, backtick-delimited identifiers and numeric values. The native Hindi UI fallback test also passes with the installed Nirmala UI font. A running desktop preview was checked in Hindi, including the New Document dialog and File menu. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 3ba87b2d94..29e3f23038 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -31,7 +31,7 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a | English | `en` | source, 100% | all | Latin | — | full | — | | Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all (3,554 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 55 missing messages for it, which open #7116, #7211, #7330, #7378 and #7405 translate) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) | | Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | -| Hindi | `hi` | 8,782 rows (2026-11-13): 3,014 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font | +| Hindi | `hi` | 8,782 rows (2026-11-13): 3,014 `@msg`, 47 `@msgplural`, 41 `@plural`; the untranslated report on main `f793044f83` (2026-11-14) lists 676 missing rows for it: 608 in its own section and 68 that every checked language lacks | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | reported missing rows, native review, layout and web font | | Gujarati | `gu` | 4,673 rows (2026-11-14): original 4,286 unchanged; all 387 additions contributor-reviewed and approved | menus and dialogs; status/error messages partial | installed Gujarati fonts on desktop; optional `Gujr` craft-fonts fallback; no Gujarati face in the pinned CI/release fonts or web startup list | original catalog reviewed by a native speaker; all 387 later additions approved by the contributor, with 11 wording corrections | contributor-reviewed catalog | remaining messages and a bundled Gujarati web font | | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 9,166 rows, 3,436 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 178 missing messages for it | all | Latin | no | full | — (review) | @@ -44,8 +44,8 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a **Historical 12-language score: ~82%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 100 + 100 + 100 + 98 + 100 + 100, over 12). **To `full` for all twelve: ~50–85 h** (historical estimate, not re-scored here). Hindi now has -a complete catalog and UI shaping; its remaining work is native review, layout checking and the -web font. Arabic still needs its catalog and right-to-left interface. Each new Latin-script catalog is ~4,000 rows; the existing ones were written in a few agent hours each +a catalog that the tests count as complete, and UI shaping; its remaining work is the rows the +untranslated report lists for it, native review, layout checking and the web font. Arabic still needs its catalog and right-to-left interface. Each new Latin-script catalog is ~4,000 rows; the existing ones were written in a few agent hours each plus review. Native-speaker review is human work and isn't in the hours. ## Other shipped languages @@ -53,12 +53,12 @@ plus review. Native-speaker review is human work and isn't in the hours. | Language | Code | Translated | Status | |---|---|---|---| | 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 | +| Italian | `it` | 8,924 rows, 3,560 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 54 missing messages for it, which open #7116, #7211, #7378 and #7405 translate | 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 | -| Hungarian | `hu` | 5,253 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | full | +| Hungarian | `hu` | 9,166 rows, 3,444 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it, and lists 47 menu strings, 25 `tl!` strings, 11 undo labels and 45 swatch names without a row; one plural form | full | | Polish | `pl` | 9,044 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 46 missing messages for it, and 47 menu strings, 26 `tl!` literals, 11 undo labels and 45 swatch names; one/few/many plurals | full; native review pending | | Finnish | `fi` | 5,318 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals, 797 `@msg` rows | full; AI-assisted, native proofreading pending | | Czech | `cs` | 9,273 rows, 3,560 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 2 missing Czech messages, and 54 messages that no language has a row for | catalog complete; render-site gaps reduced; native review pending | diff --git a/docs/mcp.md b/docs/mcp.md index 37ffd74300..2e43c14d0e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,6 @@ # VectorCraft MCP server -> **Last updated:** 2026-11-13 · **Change:** Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. +> **Last updated:** 2026-11-14 · **Change:** Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; no response-shape change. Previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. `vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio (newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to @@ -3224,8 +3224,12 @@ already walks Image Trace. Short agent entry points: is rejected with an error. The Pencil and the Paintbrush give Fidelity in screen pixels, 0.01 to 100 px, and send it divided by the zoom (0.0313 to 640) and kept within that range, so a stroke is fitted at the same tolerance on screen at every zoom. A stroke - that continues a path (`extend`) closes it when it ends within four times the Fidelity of the path's other end, at - least 6 pt and at most 400 pt. The Blob Brush's `path.blob` Fidelity stays in points, 0.01 to 100 pt. + that continues a path (`extend`) closes it when `closed` is true: the Pencil and the Paintbrush send it when the + stroke ends within Close Paths When Ends Are Within of the path's other end (the cursor shows the closing ring there), + a straight Pencil line too, or with Alt held at the release. The stroke's end then joins the other end when it is + within four times the Fidelity of it (at least 6 pt and at most 400 pt), and a straight segment closes the path from + farther away. Without `closed`, the stroke closes the path when it ends within that four-times-Fidelity distance. The + Blob Brush's `path.blob` Fidelity stays in points, 0.01 to 100 pt. - Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`, `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic` (`variable.bind` and `variable.makeTextDynamic` refuse type that already has another text variable and name it), @@ -3269,8 +3273,9 @@ already walks Image Trace. Short agent entry points: byte order that reads a line break (`\n` or `\r`). It is refused when fewer of its units hold a NUL byte (UTF-8 or Windows-1252 text with stray NUL bytes, or UTF-16 text made mostly of other characters, which needs a byte-order mark), when both byte orders read a line break (the text holds U+0A00 or U+0D00), when it has an odd length, and - when it isn't valid UTF-16 in that order or holds a NUL character. A file with no line break has no data rows and is - refused for that. `csv` and `data` are read as the text they hold. + when it isn't valid UTF-16 in that order or holds a NUL character. A file with no line break is read big-endian when + more of its two-byte units start with a NUL byte than end with one, and little-endian otherwise; it has no data rows, + so the import is refused. `csv` and `data` are read as the text they hold. - Variable libraries: `variable.loadLibrary {path|xml|dataBase64}` (the Variables panel menu's Load Variable Library…) replaces the document's variables and data sets with a variable library's in one undo step. A load, like a `dataset.import`, adds no undo step when it leaves the variables and data sets as they were. The library @@ -3646,6 +3651,16 @@ already walks Image Trace. Short agent entry points: a locked artboard, or in isolation mode outside the isolated art. In the app, the tool's cursor shows what a click at the pointer selects under these rules: the arrow with a filled square for a face, with a short line for an edge, and with an x for nothing. It keeps the look it had at the press until the release. + A double-click (`pointer_gesture` kind `doubleclick`, after the two clicks' `down` and `up`) on the face a click there + picks runs `select.set {ids}` with that face and the faces of its group that a flood from it reaches across edges + that are hidden or draw no stroke (an edge that is fully transparent, or whose strokes are each hidden, without + paint, 0 pt wide or fully transparent); a stroked edge and a Gap Detection gap stop the flood, and hidden faces are + left out. On the edge a click + there picks, it runs `select.set {ids}` with that edge and the edges of its group, not hidden, joined to it end to + end through edges that draw with the stroke paint it draws with (the paint of an edge's topmost stroke that is + shown, with paint, above 0 pt and not fully transparent; none for an edge that draws no stroke). With Shift held it runs `select.toggle {ids}` with the same faces or edges, + and where a click selects no face or edge it runs nothing. The same options, locks and isolation rules apply as for + a click. The Live Paint Bucket's options (`tool.setOption {tool: "livePaintBucket", values}`; double-clicking the tool opens them, dialog `livePaintBucketOptions`) are `paintFills` (on), `paintStrokes` (off), `cursorSwatchPreview` (on), `highlight` (on), `color` (the @@ -5135,7 +5150,8 @@ The Attributes panel (`window.panel {panel: "attributes"}`, Cmd+F11) reads `attr objects differ) and `recentUrls`. `attributes.set {overprintFill?, overprintStroke?, showCenter?, imageMap?, url?, note?, ids?}` sets them in one undo step; `path.setFillRule {rule: "nonZero"|"evenOdd"}` sets the fill rule of the paths and compound paths, and `path.reverse {reversed?}` makes subpaths run counter-clockwise (true) or clockwise -(false). SVG export wraps an object with a URL in ``, and SVG import reads `` links back. +(false). When every subpath of a path turns alike, its strokes' width profiles (left and right swapped) and pen +pressure, tilt, bearing and rotation turn with it, so the strokes draw as before. SVG export wraps an object with a URL in ``, and SVG import reads `` links back. ```json {"name":"run_command","arguments":{"command":"attributes.set","params":{"ids":[12],"url":"https://example.com","imageMap":"rectangle"}}} @@ -7228,9 +7244,14 @@ the document; so does `object.expand`. Placing a text file (`.txt`) sets it as area type: `file.place {path | name+dataBase64, text?: {characterSet?: "unicode" | "ansi", platform?: "windows" | "mac", removeLineReturns?, removeParagraphReturns?, replaceSpaces?: n}}` (Text Import Options). Unicode reads UTF-8, or UTF-16 with a byte-order mark; ANSI reads Windows-1252 (Mac Roman on -`mac`). `removeLineReturns` joins the lines of each block into one paragraph, `removeParagraphReturns` drops blank -lines, `replaceSpaces: 3` turns runs of 3 or more spaces into tabs. The frame fills `rect`, the replaced object's -bounds, or the artboard less a 36 pt margin. +`mac`). Unicode reads a file without a byte-order mark as UTF-16 by the rule `dataset.import` uses: at least half of +its two-byte units hold a NUL byte, in the byte order that reads a line break, or for a file with one line, big-endian +when more of its units start with a NUL byte than end with one, and little-endian otherwise. Any other file is read as +UTF-8, else as the platform's 8-bit set: one with fewer NUL bytes, one with an odd length, one where both byte orders +read a line break, and one that isn't valid UTF-16 in that order or holds a NUL character. `removeLineReturns` joins +the lines of each block into one paragraph, `removeParagraphReturns` drops blank lines, `replaceSpaces: 3` turns runs +of 3 or more spaces into tabs. The frame fills `rect`, the replaced object's bounds, or the artboard less a 36 pt +margin. ```json {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/notes.txt","text":{"removeLineReturns":true,"removeParagraphReturns":true}}}} @@ -9144,7 +9165,9 @@ send an Alignment or Level change for paragraphs that stay in no list. With no t with an unknown `tab`, `ui.bulletsAndNumbering` is an error. Type › Bullets and Numbering › Convert to Text runs `type.convertListToText` on the selected type's paragraphs, or on -those the Type tool's selection touches, and that runs `text.convertListToText {ids?, start?, end?}`. The marker of +those the Type tool's selection touches, and that runs `text.convertListToText {ids?, start?, end?}`. With the +Selection tool, a selected frame of threaded text names every frame of its story in `ids`, locked and hidden frames +included, as Apply Bullets and Apply Numbers do. The marker of each list paragraph becomes characters at the start of the paragraph: the bullet, or the number and its period as the paragraph showed it, then a tab. They are set in the marker's font, size and color: the style of the paragraph's first character without the attributes the marker leaves out. The paragraph leaves its list. A paragraph at a list level @@ -10655,6 +10678,10 @@ threaded frame, which composes them again at its own widths. Such a paragraph fa frame has no room for its first line, or when the search for its breaks passes VectorCraft's own limits (1,000,000 lines tried and 2,000,000 baseline steps for one paragraph, 2,000,000 lines and 4,000,000 steps for all the paragraphs of a frame). +Single-line measures each line's room the same way: a line that takes in a taller character is placed again across its +new height and broken again in the room there. When that leaves the taller character for the next line, the line is +set at its own height where the room there is no narrower, with the words before that character that fit there, and +otherwise stays where it was placed. The UI command `type.lineComposer` (Cmd+Alt+Shift+C, in remote mode `press_key {key: "C", mods: {cmd: true, alt: true, shift: true}}` or `invoke_menu`) sets `singleLine` when every paragraph of the selected type (or every paragraph the Type tool's selection touches) uses Every-line, else `everyLine`, and returns the composer it set. @@ -10713,7 +10740,12 @@ and the arrow keys nudge them by the keyboard increment. The active tool lists t (`tool.setOption {}` returns them as `meshPoints: [{id, index}…]`). On gradient meshes a solid fill colour (`paint.setFill`, from the Color panel, Swatches or the Eyedropper) recolours just the picked points, and when every target of `transparency.set` is a gradient mesh with points picked, its `opacity` fades those points instead of the objects -(`transparency.info` then shows their opacity, `null` when it differs). Both are one undo step. +(`transparency.info` then shows their opacity, `null` when it differs). Both are one undo step, and neither goes to the +points while the Appearance panel's active row takes the fill or the opacity. When the first selected object is a +gradient mesh with points picked, the Fill proxy, the Color panel, the Color Wheel and `paint.proxies` show its first +picked point's color, and `fillMixed` compares the picked points' colors with each other and with the fills of the other +selected objects. The Transparency panel shows the picked points' opacity (blank when it differs), and the Control +bar shows the first picked point's opacity. `object.mesh.movePoints {id, points: [index…], dx, dy}` (or `{items: [{id, points}…], dx, dy}` over several meshes) moves points by (dx, dy), their handles with them; `object.mesh.setPointColor` takes `points` or `items` the same @@ -11842,6 +11874,7 @@ the error text. The interactive fallback applies only to ordinary File > Save, n | Date | Change | |---|---| +| 2026-11-13 | `path.freehand`: `closed`, when given, decides whether a stroke that continues a path (`extend`) closes it; the Pencil and the Paintbrush send it by Close Paths When Ends Are Within. | | 2026-11-13 | Snap to Point with Smart Guides off draws no Smart Guides labels or lines (#7250). | | 2026-11-13 | Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097). | | 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 98acbedf2c..244d956e6f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (G5: Weather Icons joins built-in symbol libraries (Sun, Moon, Cloud, Rain, Thunder, Snow); scores and estimates unchanged; previously: a closed Pencil stroke closes with a straight segment; previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; previously: 3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: `.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; previously: G5: Weather Icons joins built-in symbol libraries (Sun, Moon, Cloud, Rain, Thunder, Snow); scores and estimates unchanged; previously: a closed Pencil stroke closes with a straight segment; previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; previously: 3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: `.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -60,6 +60,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard |---|---|---| | 2026-11-14 | minor | G5: Weather Icons joins Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons as a sixth built-in symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow; original procedural vector symbols generated in code, no default 9-slice); area score and estimate unchanged. | | 2026-11-14 | minor | Pencil and Paintbrush: a closed stroke (Alt at the release, or ending within Close Paths When Ends Are Within of its start) is fitted as the open stroke would be and closed by a straight segment back to its start, instead of curving on past both ends (`pencil_alt_closes_a_stroke_straight_back_to_its_start` in `crates/engine/src/tests/draw2.rs`). Scores and estimates unchanged. | +| 2026-11-13 | minor | Pencil cursors: the continuation cursor (a short rising line) shows over an open end of a selected path before any press, freehand too, and while a continuing stroke nears another selected path's open end it connects to; the straight-line cursor is a short level line; a continued path, a straight line too, shows the closing cursor and closes within Close Paths When Ends Are Within of its other end, and with Alt at the release (`a_continued_stroke_shows_where_it_connects_or_closes` in `crates/tools/src/draw2/gesture/tests.rs`, `pencil_extend_closes_as_closed_says` in `crates/engine/src/tests/draw2.rs`). Scores and estimates unchanged. | + | 2026-11-13 | minor | Snap to Point: with Smart Guides off it still pulls drawn and dragged points onto anchors and ruler guides, but draws none of the Smart Guides labels ("anchor") or lines (#7250; `guides::tests::snap_to_point_without_smart_guides_shows_no_guides`). Scores and estimates unchanged. | | 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. | @@ -205,11 +207,8 @@ tests and the CI gate. See [Calibration](#calibration). - **From the code (measured, origin/main `01e165af`):** - Menu tree (`crates/ui-egui/src/menus.rs`, laid out in Illustrator's menu order): 348 items bound to commands, 26 stubs (`todo(…)`/`todos(…)`), so 93.0% of menu items do something (348 and 26 after Snap to Glyph shipped in #3124; 348 and 27 after the - Revolve merge; 350 and 25, 93.3%, after Graph › Design and Marker; 353 and 22, 94.1%, after Extrude & Bevel, Inflate and Rotate). The stubs: Effect › 3D and Materials › Materials, Effect › SVG Filters, Edit › Spelling (3), Graph › Column, Live Paint - Gap Options, Optical Margin Alignment, global and video rulers (2), - Window › Arrange (4), SVG Interactivity, Other Script…, Help. - Gap Options, Optical Margin Alignment, Start Global Edit, global and video rulers (2), - Window › Arrange (4), SVG Interactivity, Other Script…, Help (the Brush and Symbol Libraries placeholders are gone: Window › Symbol Libraries works since 2026-10-23, Window › Brush Libraries since 2026-10-24). + Revolve merge; 350 and 25, 93.3%, after Graph › Design and Marker; 353 and 22, 94.1%, after Extrude & Bevel, Inflate and Rotate). The menu stubs on `a47d7eec` are Effect › 3D and Materials › Materials, + Edit › Spelling › Check Spelling and Help › VectorCraft Help. - Engine commands: ~710 ids registered with `cmd!` across `crates/engine/src/cmd` (plus macro-generated families), and 85 UI-only commands (`UI_COMMANDS`). - Tools: 91 in `crates/tools/src/catalog.rs`; Touch Type is listed but not implemented, and the Dimension @@ -595,7 +594,8 @@ since 2026-10-01. **Drawing tools, Star inner radius held with Ctrl/Cmd:** While a star is dragged out, Ctrl/Cmd keeps the inner radius the star showed when the key went down, also when the key is pressed with the pointer still. `a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt` and `a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down` cover it, with Alt's straight shoulders and a drag canceled with Escape. **Drawing tools, Dimension tool horizontal and vertical linear dimensions:** A linear dimension between two clicked points or dragged out from a segment is aligned, horizontal or vertical, chosen by where the pointer moves while it is placed, and a click again by the second point gives aligned; `dimension.create` and `dimension.update` take `orientation`, and the dimension stays linked to the anchors it measures, keeps its orientation when it or the art turns, with a line that lay outside the points kept outside them; an unlinked horizontal or vertical dimension that is reflected has its line on the reflected side. `the_pointer_chooses_aligned_horizontal_or_vertical`, `two_points_measure_horizontally_vertically_or_aligned`, `clicking_again_by_the_second_point_places_an_aligned_dimension`, `a_segment_drag_measures_horizontally_vertically_or_aligned`, `points_on_a_vertical_line_measure_aligned`, `horizontal_and_vertical_dimensions_draw_lines_along_their_axis`, `turned_horizontal_dimensions_stay_horizontal`, `a_linked_horizontal_dimension_keeps_its_gap_from_turned_art`, `a_linked_horizontal_dimension_below_or_between_its_points_follows_turned_art`, `a_reflected_horizontal_dimension_keeps_its_line_on_the_reflected_side`, `a_dimension_without_an_orientation_reads_as_aligned`, `dimension_orientation_is_checked` and `a_turned_dimension_keeps_its_line_on_its_side` cover it. **Drawing tools, Dimension tool points that are not anchors follow the art:** A clicked point on a segment stays at its place along the segment, also when the segment curves, and a point off the art is linked to anchors (or, when the anchors lie on one line, anchors and handles) of the path, so both ends of the dimension follow when the art is moved, scaled, turned or reflected, also when the point was placed by a Smart Guide and when the dimension is saved and opened again; a linked linear dimension of reflected art (other than art on one line) has its line on the reflected side; deleting the art, changing its anchor count or whether it is closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular leaves the dimension where it was, no longer linked, instead of removing it. `a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends`, `a_dimension_to_a_point_off_the_art_moves_with_the_art`, `a_dimension_whose_art_changes_its_anchors_stays_where_it_was`, `a_dimension_whose_art_is_opened_stays_where_it_was`, `angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were`, `a_dimension_that_cannot_be_drawn_again_keeps_its_art`, `a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side`, `a_dimension_to_a_point_off_a_line_follows_the_line`, `a_point_off_an_arch_is_reflected_with_the_arch`, `a_point_beside_an_almost_straight_curve_follows_it`, `a_point_off_a_loop_with_its_anchors_in_one_place_follows_it`, `a_point_beside_a_reflected_line_moves_as_a_half_turn_would`, `a_point_on_a_segment_stays_on_it_when_the_segment_curves`, `a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side`, `a_dimension_of_art_reflected_across_its_points_changes_sides`, `a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset`, `free_points_survive_a_native_file`, `dimension_free_points_are_checked`, `points_off_anchors_are_held_against_the_path` and `a_point_smart_guides_place_is_held_against_the_path` cover it. -**Drawing tools, Dimension tool points on a Smart Guide stay on the guide:** By VectorCraft's own rule, a clicked point that lies on a Smart Guide already, such as a point straight below the first one on the vertical construction guide through it, or one in line with an object's center, stays where the pointer is when no anchor is within 8 pixels and no segment is within the Smart Guides snapping tolerance, instead of moving onto a segment farther than that tolerance and within 8 pixels; with Snap to Grid or Snap to Pixel on, a point stays on the grid or the whole pixel those options give. `a_point_on_a_guide_stays_on_the_guide` covers it. +**Drawing tools, Dimension tool points on a Smart Guide stay on the guide:** By VectorCraft's own rule, a clicked point that lies on a Smart Guide already, such as one in line with an object's center, stays where the pointer is when no anchor is within 8 pixels and no segment is within the Smart Guides snapping tolerance, instead of moving onto a segment farther than that tolerance and within 8 pixels; with Snap to Grid or Snap to Pixel on, a point stays on the grid or the whole pixel those options give. `a_point_on_a_guide_stays_on_the_guide` covers it. +**Drawing tools, Dimension tool second point without construction guides through a first point off the anchors:** By VectorCraft's own rule, with Smart Guides on, the second Linear point does not snap to the construction guides through a first point that is not on an anchor, so a click beside a segment straight below such a first point lands on the segment; after a first point picked on an anchor, the construction guides through it still apply. The second point still snaps to anchors, centers and alignment lines, and with Snap to Perpendicular or Snap to Tangent on, it lands where the line from the first point meets a segment square or grazes a curve. `a_point_on_a_guide_stays_on_the_guide`, `the_second_point_after_an_anchor_lands_on_construction_guides`, `the_second_point_lands_square_to_a_segment_or_grazing_a_curve` and `linear_picks_show_and_snap_to_smart_guides` cover it. **Drawing tools, Dimension tool dimensions selected with their art keep their lines and label:** Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave a dimension selected with art it measures, directly or in selected groups without fills, strokes or effects of their own, as it is and change only the other art; when the new art no longer fits its link, the dimension stays where it was, no longer linked, and undo links it again. `expand_leaves_a_dimension_selected_with_its_art_as_it_is`, `expand_appearance_leaves_a_dimension_selected_with_its_art_as_it_is`, `outline_stroke_leaves_a_dimension_selected_with_its_art_as_it_is`, `a_dimension_selected_with_art_that_has_nothing_to_expand_changes_nothing`, `a_group_effect_expanded_reaches_the_dimension_it_holds`, `expand_keeps_a_dimension_in_its_asset_and_its_variable_binding`, `a_dimension_keeps_no_link_to_the_art_expand_replaced`, `a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced`, `a_dimension_outlasts_the_stroke_expand_outlined` and `expand_appearance_in_safe_mode_leaves_a_dimension_selected_with_its_art` cover it. **Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views:** `live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle` checks every TraceView, geometric and visual bounds and rotated result bounds. `live_trace_selection_highlight_excludes_the_retained_source_rectangle` checks result highlights and the existing ordinary-group policy. Shared editing-child traversal also preserves Shaper handling. **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:** Shared existing preset definitions are reused; no external preset assets are added. Presets preserve each selected artboard's orientation, and orientation choices apply even when the active board already matches. Fit operations draft bounds until OK, affect only the active artboard and leave artwork in place. Regression coverage checks draft/Cancel, selected-set application, active-only fitting and Undo. @@ -807,6 +807,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Export for Screens numbers distinct row files whose names collide, compared without case, as `-2`, `-3`… instead of leaving them out; identical rows still write one file. It puts row folders that differ only in case into one folder (`document.exportForScreens`). - Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones. Every header cell is a variable, and the rows replace the document's data sets as Data Set 1, Data Set 2 and so on. An import error about a row's values gives the file line the row starts on. - Variables: Import Data Sets… (`dataset.import`) reads a UTF-16 file without a byte-order mark, instead of refusing it, when at least half of its two-byte units hold a NUL byte; it uses the byte order that reads its line breaks. + - Graphs: the Graph Data window's Import Data reads a UTF-16 text file without a byte-order mark by the same rule as Import Data Sets…, instead of filling the cells with NUL characters. It reads a file cut short by one byte, or with an unpaired surrogate, as it reads one after a byte-order mark. - Variables: Import Data Sets… (`dataset.import`) defines the variables a file's header names that the document lacks, unbound. `#Shown` defines a visibility variable, `@Photo` defines an image variable, and a header without a prefix defines a text variable. A header that is a variable's exact name keeps naming it, and any other `%` header (graph data files) is refused. - Variables: Load Variable Library… in the Variables panel menu (`variable.loadLibrary`) replaces the document's variables and data sets with a variable library's, read from its XML file, in one undo step: every data set is deleted, and every variable is deleted with its bindings and values, except a text variable the library also declares, which stays bound so the library's data sets change its objects (#4136); then the library's other text variables are defined, unbound, and its data sets take the text of each value's `p` elements, one paragraph each. Variables of other kinds are left out with a warning that names their kinds. - Variables: Save Variable Library… in the Variables panel menu (`variable.saveLibrary`) writes the document's text variables and data sets as a variable library's XML file, which Load Variable Library reads back with the same text variables, data set names and text values. Variables of other kinds are left out with a warning that names their kinds. @@ -905,6 +906,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Hindi English words (2026-11-13): 264 rows no longer keep English words in their sentences, repeat a parameter's name in Hindi or garble them, among them "unknown align", which now names the alignment; lists of accepted values such as `(left|right|top|bottom)` stay as their English writes them; two tests check every row; native review remains; scores and estimates unchanged. - Message field names and values (2026-11-13): 49 status and error messages in the Hindi, Bulgarian, Danish, Indonesian, Dutch, Brazilian Portuguese, Swedish and Turkish catalogs keep the field names in brackets, such as `[subpath, anchor]`, and the values an `unknown` message lists, such as `(points, lines)`, as their English writes them; a test checks every catalog; scores and estimates unchanged. - Italian messages (2026-11-13): the Italian catalog (`it`) has 1,802 more status and error message rows, and the untranslated report's missing Italian messages go from 1,842 to 40; the translations are VectorCraft's own and await native review; scores and estimates unchanged. See [localization-parity.md](localization-parity.md). + - Italian messages no catalog had (2026-11-13): the Italian catalog has 126 more `@msg` rows, 3,560 in all: the image export, color profile, color book, swatch exchange file, DXF and DWG import, 3D and Revolve, script and zip errors that no other catalog translates yet, and the Banner Hems and Grommets, parameter range and recovery save messages that other catalogs translate. The untranslated report lists 54 Italian messages, which open #7116, #7211, #7378 and #7405 translate; scores and estimates unchanged. - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The Czech catalog (`crates/ui-egui/src/i18n/cs.tsv`) translates 114 status and error messages that no catalog had a row for: the 3D and Revolve limits, image export and encoding errors, color profiles, color books and swatch exchange files, binary DXF and DWG import, path operations, swatch, graphic style and action set libraries, and zip, script and regular expression errors. The untranslated report (`untranslated_report`) still lists 54 messages for every language: the 53 composite font, SVG filter, read root and graph design errors that open #7211, #7378 and #7405 translate, and `` `index` must be a whole number ``. See [localization-parity.md](localization-parity.md). @@ -912,8 +914,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Polish catalog (`crates/ui-egui/src/i18n/pl.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,439 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 46 Polish messages: 44 that open #7211, #7214, #7116, #7330 and #1931 translate, and 2 that no language has a row for. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). + - The Japanese catalog (`crates/ui-egui/src/i18n/ja.tsv`) has 3,421 `@msg` rows: a row for each status and error message the untranslated report (`untranslated_report`) finds in the sources, except 38. The report lists those 38 for Japanese: the 36 composite font errors that open #7211 translates, `select a path` (open #7214) and the justification order error (open #7116). The status bar does not use the rows for ``unknown parameter `{within}{key}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})`, because a message with two placeholders in a row cannot be matched (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`). The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. A native speaker has not reviewed the wording. - A status or error message whose braces are literal, such as a plug-in's `expected {"objects": [...]}` or a command's `quotes must be {double?, single?}`, is translated only when the whole message matches its row: the row writes those braces doubled and has no placeholder, so it no longer replaces a different message that starts with the same words, such as a JSON reason `expected value at line 1 column 1`, which stays in English. A row without placeholders is found at any message length (`plain_message_keys_double_their_braces`, `literal_brace_rows_match_only_their_message`). - The status bar translates the errors a script's `File` and `Folder` objects, `vc.run`, `$.evalFile`, the `$` object's properties and functions, `alert`, `confirm` and `prompt`, `vc.document` and `vc.log` raise, the limits a run can pass, and the errors of Drop Script Engine, and the line for a run that fails (a syntax error, a thrown error, a limit, a stop or an internal error, with the script's name, the line and column, the included file it is in and the files left written), in every language that translates every message. The script's name, paths, numbers, command ids and the text a script throws itself are shown as they are. A failure with no line and column, such as an error in code `eval` compiled, is shown with one colon after the script's name (`an_error_without_a_place_names_the_script_and_kind`). A message template that names a value twice matches only when both places hold the same text, so the line for a failed run is not taken for the message inside it (`a_repeated_placeholder_matches_the_same_text`). The untranslated-strings report also lists the messages the script host's `fail`, `value` and `over` helpers take and the errors the script interpreter throws, and a constant's text comes from the file or crate that declares it, so the Scripts menu's headers are not listed as messages (`script_errors_are_translated`, `script_host_errors_are_translated`, `message_scan_reads_script_interpreter_errors`, `message_scan_reads_constants_of_the_file_and_its_crate`, `message_scan_skips_test_modules_with_any_test_cfg`). + - The Hungarian catalog (`crates/ui-egui/src/i18n/hu.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,444 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 40 Hungarian messages: the composite font, justification order and connect end errors that open #7211, #7116 and #7330 translate, and two messages that no catalog has a row for. The status bar does not use the rows for ``unknown parameter `{within}{key}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})`, because a message with two placeholders in a row cannot be matched (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`). The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. A native speaker has not reviewed the wording. - The Dutch catalog (`crates/ui-egui/src/i18n/nl.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,447 `@msg` rows in all. The report lists 41 Dutch messages: the composite font errors, the justification spacing error and the `connect` end error, which open #7211, #7116 and #7330 translate, and three unknown-parameter and range errors (``unknown parameter `{within}{key}`; {why}``, ``unknown parameter `{within}settings.{format}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})`), which open #7353 rewrites so that a catalog row can match them. The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. A native speaker has not reviewed the wording. - The Traditional Chinese catalog (`crates/ui-egui/src/i18n/zh-hant.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,438 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 40 Traditional Chinese messages: open #7211, #7116 and #7330 add the rows for 38 of them, and the other 2 have no row in any catalog. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). @@ -1026,6 +1030,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`, `panels::tabs::tests::indent_markers_after_select_all_keep_each_story_paragraphs_indents`, `dialogs::justification::tests::justification_dialog_follows_the_story_thread_at_ok`). - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`). - With the Selection tool, a Character or Paragraph panel change to a selected frame of threaded text changes every frame of its story, locked and hidden frames included, as one undo step. The anti-aliasing method, Variable Font axes, the Tabs panel's tab stops and both panels' Reset Panel change the whole story too, and the panels show a value only when every frame of the story has it. A story selected through several of its frames or through a group is changed once, and other type keeps its formatting (VectorCraft's own behavior; `panels::character::tests::selection_tool_changes_reach_every_frame_of_the_story`, `selection_tool_fields_read_every_frame_of_the_story`, `selected_stories_list_each_frame_once`, `panels::paragraph::tests::selection_tool_paragraph_changes_reach_every_frame_of_the_story`, `panels::tabs::tests::selection_tool_tab_stops_reach_every_frame_of_the_story`). + - With the Selection tool, the Justification dialog, the OpenType panel, a Character or Paragraph Styles row and Type › Bullets and Numbering › Convert to Text also change every frame of a selected story, locked and hidden frames included, as one undo step. The Justification dialog acts on the story's frames as they were when it opened. When the frames differ, the OpenType panel's Figure menu shows Mixed, its checkboxes show a dash (a click turns the feature on everywhere), and the style panels highlight no row; a row shows "+" when any frame overrides its style (VectorCraft's own behavior; `dialogs::justification::tests::selection_tool_justification_reaches_every_frame_of_the_story`, `panels::opentype::tests::selection_tool_features_reach_every_frame_of_the_story`, `panels::text_styles::tests::selection_tool_styles_reach_every_frame_of_the_story`, `panels::paragraph::tests::selection_tool_convert_to_text_takes_every_frame_of_the_story`). - Switching from a Type tool to the Eyedropper keeps a whole story selected by Select All: a pick applies to the text of every frame as one undo step, and switching back restores the story selection (VectorCraft's own behavior; `textedit::switching_to_the_eyedropper_keeps_the_whole_story_selected`). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. - On a layout without Latin letters, a one-key shortcut goes by the position of the key that typed the character, in the web build too, where the character arrives before its key press: on a Russian layout the P key (`з`) chooses the Pen, and two keys pressed in one frame each run their own shortcut. @@ -1102,7 +1107,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). A path the Smooth tool changes stays selected whole, and the anchor points and segments selected on it with Direct Selection are deselected. Selected paths the drag does not change keep their selected anchors, and undo restores the earlier selection (`tests/draw2.rs`, `smoothing_a_path_drops_its_direct_selected_anchors`). While the Smooth tool drags with a path selected that it can smooth, a dashed outline shows the band it smooths: everything within its radius of the drag (12 screen pixels, VectorCraft's own value), round at both ends; Alt smoothing with the Pencil, Paintbrush and Blob Brush shows the same band. While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open. Smooth Tool Options' Fidelity is in pixels, as [the help page](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=79) gives it: the Smooth tool, and an Alt smoothing drag with the Pencil, Paintbrush or Blob Brush, refits the path at Fidelity divided by the zoom, so a drag smooths a path as much on screen at 25 % as at 800 %. Reading the pixels as screen pixels is VectorCraft's own rule, as for the Pencil and the Paintbrush (#5729); the range (0.05 to 100 px) and the default (2.5 px) are VectorCraft's own values, and a saved Fidelity keeps its number (`crates/tools/src/draw2/gesture/tests.rs` test `smooth_fidelity_is_in_screen_pixels_at_any_zoom`, `crates/engine/src/tests/draw2.rs` test `smooth_region_refits_at_the_tolerance_sent_at_any_zoom`). - With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes. + With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short level line), as the page describes. Over an end of a selected open path, where the stroke continues the path, freehand or as a straight line, it shows a path-continuation cursor (a crosshair with a short line rising to the right), as [Extend paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/extend-paths-with-the-pencil-tool.html) describes, and again while a continuing stroke ends near an open end of another selected path, where the release connects the two ([Connect two paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/connect-two-paths-with-the-pencil-tool.html)). A continuing stroke, a straight line too, that ends within Close Paths When Ends Are Within of the path's other end shows the closing cursor, and the release closes the path; Alt held at the release closes a continuing freehand stroke too (a straight line stays open, as above). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). A drag on one of its eight bounding-box handles scales it as the Selection tool's handles do, with their cursors and snapping, instead of drawing a new shape from the handle. A shape tool takes a press as a handle's only on the drawn handle square (VectorCraft's own rule: 3 screen points from its centre along each axis at the default Anchor Size, a half point more or less per step; the Selection tool takes 5), so a press just beside a handle still draws (tests `a_shape_tool_scales_the_selection_by_its_bounding_box_handles`, `a_shape_tool_draws_from_a_press_just_beside_a_handle` and `a_shape_tool_scales_the_shape_just_drawn_by_its_bounding_box_handle`). The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation (unturned, or turned by a nonzero General › Constrain Angle), and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). @@ -1290,6 +1295,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). - The solids of one Extrude & Bevel (both kinds) or Inflate effect on one object, its fill's, its strokes' and a group's members' together, keep up to 163,840 faces (VectorCraft's own limit). Each solid is built in turn; once their faces pass the limit, no later solid is built, the object draws its source art, and applying or editing the effect, Export 3D Object and Expand as Wireframes report that the 3D surface is too complex. Tests `solids::tests::an_objects_solids_count_their_faces_together`, `a_groups_solids_together_past_the_limit_draw_the_source`, `a_fill_and_strokes_together_past_the_limit_draw_the_source`. + - Inflate at a Volume of 0 % works out each shape's puff only when its solid is built, so no puff is worked out for the shapes after the face limit stops, and the solids are the same bit for bit; with any volume every puff is worked out first, as the highest one places the eye. The shadow builds no solid for a fully transparent fill or stroke. Tests `solid::tests::a_flat_inflate_solves_each_puff_as_its_solid_is_taken`, `a_flat_inflate_solved_as_taken_matches_one_solved_first`, `shadow::tests::an_inflate_shape_that_casts_nothing_builds_no_solid`. - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset. An edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid stay straight (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `a_beveled_cap_and_its_walls_share_their_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Export 3D Object builds each Extrude & Bevel (both kinds) and Inflate solid once: the same build gives the faces that count toward the object's limit and the faces of its mesh, and the meshes match those of a separate build bit for bit. Tests `mesh::tests::a_meshed_solid_is_built_once`, `an_extruded_solid_and_its_mesh_match_the_two_built_apart`, `inflated_solids_and_their_meshes_match_the_two_built_apart`, `solids::tests_mesh::one_build_exports_the_meshes_built_apart`, `a_stroke_past_the_limits_is_left_out_of_the_export`. @@ -1342,11 +1348,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Mouse wheel over the font family field (#5253):** In the Character and Properties panels and the Control bar, turning the mouse wheel over the closed font family field applies the next family to the selected type (the previous one when turned up), keeping the style when the family has it, else its closest. Each notch is one undo step; leaving the field or Escape keeps the family, and Undo takes it back. The steps use the font menu's order at the first turn and keep it until the pointer leaves the field or the text selection changes, so recent-font updates do not reshuffle later turns. That order follows the font filters every font menu shares (one chosen in the Glyphs panel's menu or a dialog's applies too) but ignores its search text, puts recent families first, and stops at the first and last family. A turn with a modifier key is left to the panel and the canvas. Behavior reference: the [Illustrator CS6 reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf#page=10) describes editing hovered dropdown controls with the mouse wheel; [Find and apply fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-apply-fonts.html) names the family field's places. Applying it to the font field, the order, the ends and the undo steps are VectorCraft's own rules. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/tests/font_wheel.rs`. - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). Each reference shows an icon of the height it measures before its name, in the open list and in the closed field (#6304): a frame around a letter for Em Box, a bar from the baseline to the top of a capital for Cap Height and of a lowercase x for x-Height, and a frame inset in the em square for ICF Box. With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). - The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line gets the room of the narrower part of the frame, or of the span beside a wrap object, that it reaches. When the frame cannot hold all of such a paragraph, the lines it holds are still composed with the whole paragraph, the lines past the frame's end measured as wide as the last line the frame holds (VectorCraft's own rule); they overflow or go on in the next threaded frame, which composes them again. Such a paragraph is broken with the Single-line Composer when the frame has no room for its first line or when the search passes VectorCraft's own limits. Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/set-hyphenation-and-line-breaks.html), and for text that flows on between threaded objects, [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html). + The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line gets the room of the narrower part of the frame, or of the span beside a wrap object, that it reaches. When the frame cannot hold all of such a paragraph, the lines it holds are still composed with the whole paragraph, the lines past the frame's end measured as wide as the last line the frame holds (VectorCraft's own rule); they overflow or go on in the next threaded frame, which composes them again. Such a paragraph is broken with the Single-line Composer when the frame has no room for its first line or when the search passes VectorCraft's own limits. The Single-line Composer measures a line's room across the height of its tallest character too: a line that takes in a taller character is placed again and broken again in the room there. Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/set-hyphenation-and-line-breaks.html), and for text that flows on between threaded objects, [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html). A text wrap object's Offset may be negative as well as positive ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=307)). A negative Offset (to -1000 pt) shrinks the wrap object by that distance, measured from each edge itself and from the edges of its holes, so lines beside it, above it and below it run that far into it, and an object narrower than twice the distance no longer moves text aside. With Invert Wrap, a negative Offset keeps the text that far inside the object. These are VectorCraft's own rules for the shape of the shrunk object. Text Wrap Options, `object.textWrap.make` and `object.textWrap.options` and the MCP `text_wrap` tool take the negative value, and layout uses it (`crates/text/src/layout/region.rs` tests, `cmd::textwrap::tests::text_wrap_options_negative_offset_reflows_and_round_trips`). In area type of a shape other than a rectangle, each line's whole band, ascent to descent, stays inside the frame: where an edge pinches in within the band, the line ends at the narrowest point, and a line runs beside a hole that starts and ends within its band, not across it. In area type of any shape, a line also stays clear of a text wrap object that reaches into any part of its band, and inside an Invert Wrap object over its whole band (`crates/text/src/layout/region.rs` tests). In area type whose frame has several contours, a line runs across an edge two contours share only when their edges lie exactly on one line, so a gap between contours keeps lines from crossing it, even a gap one unit in the last place wide near the edge of the canvas; an edge divided by an anchor exactly on it still lets lines cross (`layout::region::separate_slanted_contours_keep_a_four_ulp_gap_near_canvas_limit`, `shared_slanted_edge_still_joins_near_canvas_limit`, `area_type_sets_no_line_across_a_four_ulp_gap_between_slanted_contours`). - With Invert Wrap, the spans a line keeps inside a wrap shape of many parts (a comb of 500 teeth) are found by stepping through the line's spans and the shape's spans once for each piece of the band, so the work grows with the number of spans, not with its square (`crates/text/src/layout/region.rs` tests). + With Invert Wrap and a positive Offset, a line uses all the room the wrap shape grown by the Offset (across and along) holds at every height of its band: beside a notch the Offset fills, the line reaches the grown edge, whatever the band's height, the slope of the notch's sides or the number of anchors on the shape (VectorCraft's own measure). Measuring a wrap shape of many parts (a comb of 500 teeth) takes a few dozen steps per part, not one per pair of parts. The measure stops after a fixed number of steps; a shape that needs more, for example one whose edges cross one another tens of thousands of times, has its band cut into at most 64 pieces instead, which keeps the line inside the grown shape but can leave it shorter than the grown shape allows (`crates/text/src/layout/region.rs` tests). Text Wrap Options › Offset is a distance field in the document's units: a bare number is in those units, a typed unit ("12 pt") is read as typed, and OK sends points. In a document in picas, a bare number follows Preferences › Units › Numbers Without Units Are Points as the other length fields do: it is in points while that preference is on (the default) and in picas while it is off (`dialogs::command::tests::text_wrap_offset_in_picas_follows_numbers_without_units_are_points`). An Offset typed outside -1000 to 1000 pt, VectorCraft's own range for the command, or text that doesn't read, is refused: the field flashes and the dialog stays open, whether Enter or a click on OK ends the entry; in every dialog drawn by the shared dialog window, a click on OK no longer confirms it when the distance field it takes the focus from refuses its text (`dialogs::command::tests::text_wrap_offset_field_takes_document_units_and_refuses_values_outside_its_range`). Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/enter-values-in-panels-and-dialog-boxes.html). The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. @@ -1471,6 +1477,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradient mesh keeps the object's outline (#2752):** Create Gradient Mesh, a Mesh tool click and Expand of a gradient to a mesh lay the mesh's border on the path's outline: each border edge is the piece of the outline between its two points, and where the outline bends through an anchor (a star's points, an L-shape's inner corner, a rounded rectangle's arcs) no single curve follows, the mesh takes a line through that anchor, up to 256 each way. Holes and other subpaths of a compound path are still left out. Public reference: [About mesh objects and Create mesh objects, CS3 user guide, pages 184 to 185](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) (mesh lines carry anchor points as well as mesh points). Tests: `a_mesh_keeps_the_outline_of_every_shape` and `a_mesh_takes_lines_only_where_the_outline_needs_them` in `crates/doc/src/live.rs`, `a_mesh_tool_click_keeps_a_stars_points` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **A point on a mesh's outline is on the mesh:** a point exactly on the outline of a gradient mesh (within a millionth of a point) locates on the edge it lies on, on all four sides alike and with no tolerance, as it already did on the top and left edges, so `object.mesh.addLine` and Create Gradient Mesh `at` a point on the bottom or right edge add their lines there instead of reporting the point outside, and the Eyedropper samples the mesh's color there; a point just outside still needs `tolerance`. Tests: `mesh_locate_takes_a_point_on_any_side_of_the_outline` in `crates/doc/src/live.rs`, `mesh_add_line_exactly_on_the_edge_needs_no_tolerance` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. +- **Picked mesh points in the panels** (VectorCraft's own rule): with points of a gradient mesh picked by the Mesh tool, Direct Selection or the Lasso, the Fill proxy, the Color panel and the Color Wheel show the first picked point's color, so their sliders keep the color a drag sets, and "?" shows when the picked points' colors differ. The Control bar shows the first picked point's opacity. Tests: `the_fill_proxy_shows_the_picked_mesh_points_color`, `the_fill_proxy_follows_where_the_fill_goes_with_an_appearance_row_active` and `the_fill_proxy_counts_picked_mesh_points_beside_other_objects` in `crates/engine/src/tests/xform.rs`, `picked_mesh_points_are_the_color_the_panel_shows_and_edits` in `crates/ui-egui/src/panels/color/tests.rs`, `picking_mesh_points_shows_their_opacity` in `crates/ui-egui/src/panels/transparency.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo flat-art check with named colors:** with `logoColors` named, whether the image is flat art is judged by its own colors, as many as are named, with white, or as the Colors setting gives, whichever is more. Named colors 20 levels off a flat badge's colors still trace it, a hatch on a light ground gives the same result with close and far named colors, a gradient's refusal score does not depend on how far the named colors are from it, and areas of colors that were not named are traced in the named colors instead of being refused; code-drawn tests in `crates/trace`. @@ -1505,6 +1512,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur As VectorCraft's own rule, `tool.setOption` for the Live Paint Bucket and the Live Paint Selection tool, chosen or not, checks every value before it sets any: a switch that is not `true` or `false`, a `color` that is not an RGB color or a layer color's name, or that has an alpha part, a `width` that is not a number and an option the tool does not have give an error naming the option, and nothing is set or saved. A `width` outside 1 to 20 px is still set to the nearest end of that range. Saved settings that hold an invalid value still load, and the tool keeps its default for that option. Evidence: `tests::build::{live_paint_tool_options_refuse_invalid_values_and_change_nothing, live_paint_tool_options_take_rgb_colors_and_clamp_the_width, live_paint_tools_load_saved_settings_with_invalid_values}`. As VectorCraft's own rule, `tool.setOption` for the Shape Builder, chosen or not, checks every value the same way: a switch that is not `true` or `false`, a `pickColorFrom` or `selection` that is not one of its names, a `gapLength` that is not a number, a `highlightColor` that is not an RGB color or a layer color's name, or that has an alpha part, and an option the tool does not have give an error naming the option, and nothing is set or saved. A `gapLength` outside 0 to 72 pt is still set to the nearest end of that range. Evidence: `tests::build::{shape_builder_tool_options_refuse_invalid_values_and_change_nothing, shape_builder_tool_options_take_valid_values_and_clamp_the_gap}`. A drag of 3 screen pixels or more with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected. + A double-click with the Live Paint Selection tool on a face selects it and the faces of its group that no stroked edge parts from it ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)), and on an edge selects it and the edges joined to it end to end that have its stroke paint ([the help page](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)); Shift-double-click adds them to the selection or removes them from it. Triple-click is not handled yet. When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`. Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`. @@ -1604,6 +1612,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page describes effects on one fill or stroke, and the help page describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A linked instance's PDF link covers its art where the effects draw it. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Art baked for an instance that then stays an instance still counts toward that limit, and while an instance's art bakes, the art's own objects are held in the limit for the instances nested in it. Instances the export leaves out (hidden ones, those in hidden groups, on template layers, and on hidden layers or layers that do not print when the export leaves those out) are not placed and do not count toward that limit. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_holds_the_outer_art_while_it_bakes`, `placing_symbols_counts_the_art_it_drops`, `placing_symbols_counts_art_dropped_for_depth_against_later_baked_art`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`, `placing_symbols_skips_instances_the_export_leaves_out`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`, `a_linked_instance_keeps_its_link_when_its_art_has_effects`). A raster effect (Drop Shadow, Outer Glow, Gaussian Blur and the other effects that paint pixels) on a symbol instance's topmost fill draws on each filled shape of the symbol's art, and one on its topmost stroke draws on each stroked shape, after any geometry effects listed before it, under the same rules as geometry effects: only a visible fill or stroke with an opacity above 0 counts, a group's own fills and strokes in the art take the effect, and an instance inside the art passes it on. Each shape gets its own shadow or glow, which is VectorCraft's own rule. The canvas, Use Preview Bounds and the drawn bounds, SVG and PDF export draw it, and Object › Expand Appearance is enabled for such an instance and makes the effect an image at the document's raster effects resolution, in one undo step (engine `expand::a_symbol_instance_fill_shadow_shadows_its_filled_art`, `a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art`, `a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it`, `a_nested_symbol_instance_takes_the_outer_fill_shadow`, `export_draws_a_symbol_instance_fill_shadow`). Existing documents with a raster effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). @@ -1690,6 +1699,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A gap between glyphs of two sizes is measured against the smaller one, so a word set beside a much larger one with no space glyph between them (a browser's margin between two sizes, as in `≤ 4` and a small `hours`) keeps its word space (#3623). SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `