diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index c42b0c69de..43b339ccf4 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -25,17 +25,33 @@ const MAX_OVERLAP_TESTS: usize = 1 << 22; /// The width of the stroke around each face that covers the seams between faces. const SEAM_WIDTH: f64 = 0.75; -/// About the bytes one copy of `paint` takes. +/// About the bytes one copy of `paint` takes, with the strings it owns (swatch and pattern names). fn paint_bytes(paint: &Paint) -> usize { use std::mem::size_of; use vectorcraft_doc::color::{FreeformPoint, GradientStop}; let own = size_of::(); - let Paint::Gradient(g) = paint else { return own }; - let freeform = g - .freeform - .as_ref() - .map_or(0, |f| f.points.len() * size_of::() + f.lines.iter().map(|l| l.len() * size_of::()).sum::()); - own + size_of::() + g.gradient.stops.len() * size_of::() + freeform + let name = |s: &Option| s.as_ref().map_or(0, String::len); + let g = match paint { + Paint::Solid { swatch, .. } => return own + name(swatch), + Paint::Pattern { pattern, .. } => return own + pattern.len(), + Paint::Gradient(g) => g, + _ => return own, + }; + let freeform = g.freeform.as_ref().map_or(0, |f| { + // Each line is a Vec: its header too, not only its indices. + f.points.len() * size_of::() + + f.lines.len() * size_of::>() + + f.lines.iter().map(|l| l.len() * size_of::()).sum::() + }); + let stops: usize = g.gradient.stops.iter().map(|s| size_of::() + name(&s.swatch)).sum(); + own + size_of::() + name(&g.swatch) + stops + freeform +} + +/// Add `copies` copies of `paint` to the running byte total `used`; whether it still fits within +/// [`MAX_PAINT_COPIES`] (for the solid surfaces, whose items' copies count together). +pub(crate) fn paint_copies_add(used: &mut usize, copies: usize, paint: &Paint) -> bool { + *used = used.saturating_add(copies.saturating_mul(paint_bytes(paint))); + *used <= MAX_PAINT_COPIES } fn visible(fx: &[Effect]) -> bool { diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index fed0a9d741..3c433d3445 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -484,6 +484,21 @@ fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) { } } +/// `item`'s paint, borrowed (for checks that must not copy it). +fn paint_ref(item: &AppearanceItem) -> &Paint { + match item { + AppearanceItem::Fill(f) => &f.paint, + AppearanceItem::Stroke(s) => &s.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`). +fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize { + let derived = colored && paint.color().is_some() && facets.iter().any(|f| f.rgb_gradient.is_some()); + facets.len().saturating_mul(if derived { 6 } else { 2 }) +} + /// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the /// stroke's outline (whose contours nest as its holes do) for a stroke. fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) { @@ -543,6 +558,8 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> let classic = fx.id == CLASSIC_EXTRUDE; let colored = !classic && view_options(&p).has_colored_lights(); let masks = has_masks(shapes); + // The paint copies of every solid together: past the allowance the source path is drawn. + let mut paint_bytes = 0usize; if fx.id == ROTATE { // Every part turns about the centre of the object's own bounds (a group's members about // the centre of theirs), so they stay together. @@ -552,6 +569,9 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> let (paint, opacity, blend) = paint_of(item, s.opacity); let (area, rule) = region(&s.path, s.rule, item); let f = facets(ROTATE, &area, rule, b, &p).ok()?; + if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) { + return None; + } out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored)))); } } @@ -569,6 +589,7 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> for ((item, opacity, _, _, scopes), f) in parts.iter().zip(built) { let item: &AppearanceItem = item; match f { + Ok(f) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => return None, Ok(f) => solids.push((item, *opacity, *scopes, f)), // A stroke's outline past the limits is left out; the fill's solid still draws. Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index f660a6c8f7..400f3b58b9 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -14,6 +14,10 @@ use super::{ARCLEN_ACCURACY, kink, push_seg, segments, subpaths, tangent}; /// More dashes than this (a tiny pattern on a long path) draws the line solid instead. const MAX_DASHES: f64 = 200_000.0; +/// Pattern entries walked in all (every subpath and every corner restart starts the pattern over, and +/// zero-length entries cost no path length, so a long pattern is walked in full even on a short +/// run); past this the line is drawn solid. About 100x what real art needs. +const MAX_STEPS: f64 = 20_000_000.0; /// A pattern whose longest entry is shorter than this can't advance along the path (each entry /// ends within the generator's tolerance of where it starts), so it draws the line solid. const MIN_ENTRY: f64 = 1e-6; @@ -80,6 +84,10 @@ pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Opt if plans.iter().map(|p| p.periods).sum::() * pat.len() as f64 > MAX_DASHES { return None; } + let restarts = |p: &Plan| p.restart.iter().filter(|r| **r).count() as f64; + if plans.iter().map(|p| (p.periods.ceil().max(1.0) + restarts(p)) * pat.len() as f64).sum::() > MAX_STEPS { + return None; + } // Where an exact pattern starts: `offset` into it. let mut off = d.offset.rem_euclid(period); let mut ix0 = 0; @@ -89,8 +97,13 @@ pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Opt } let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 }; let mut out = Dashed::default(); + // The estimates above trust the fitted scales; this counts the pattern entries actually walked, + // so entries that fit down to nothing can't keep a subpath looping. + let mut budget = MAX_STEPS as usize; for plan in &plans { - dash_subpath(&mut out, plan, &pat, phase, keep); + if !dash_subpath(&mut out, plan, &pat, phase, keep, &mut budget) { + return None; + } } Some(out) } @@ -183,7 +196,8 @@ impl Piece { } } -fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool) { +/// Dash one subpath; `false` when `budget` (pattern entries left to walk, shared by all subpaths) runs out. +fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool, budget: &mut usize) -> bool { let first_dot = out.dots.len(); let total: f64 = plan.lens.iter().sum(); // Fraction of the subpath's length at `x` along the plan. @@ -244,6 +258,8 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: push_dot(out, first_dot, Dot { at: seg.eval(t), dir: tangent(seg, t), t: frac(acc + end) }); } } + let Some(rest) = budget.checked_sub(1) else { return false }; + *budget = rest; cuts += 1; s = end; ph.ix = (ph.ix + 1) % pat.len(); @@ -273,7 +289,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: write(&mut out.path, &d.segs); out.path.close_path(); out.spans.push((frac(0.0), frac(0.0) + 1.0)); - return; + return true; } if starts_on && ends_on && dashes.len() > 1 { // The dash running through the start point is one dash. @@ -291,6 +307,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: let t0 = frac(d.start); out.spans.push((t0, if total > 0.0 { t0 + (d.end - d.start) / total } else { t0 })); } + true } /// Add `dot` unless the subpath's last dot (from index `first`) is already there: a run ending on diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index 91933087d4..b427fdbce7 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -222,3 +222,17 @@ fn smooth_points_of_curves_stay_smooth_with_any_join() { assert!((a - want).abs() / want < 0.005, "{join:?}: {a} vs {want}"); } } + +#[test] +fn a_long_pattern_restarted_on_many_short_subpaths_draws_solid() { + // Each subpath walks the zero-length entries in full although it is far shorter than a period. + let mut pattern = vec![0.0; 2000]; + pattern[0] = 1.0; + let mut b = BezPath::new(); + for i in 0..20_000 { + b.move_to((i as f64 * 2.0, 0.0)); + b.line_to((i as f64 * 2.0 + 0.01, 0.0)); + } + let d = Dash { pattern, offset: 0.0, align_corners: false }; + assert!(dash(&b, &d).is_none()); +} diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs index 325ce9b38a..3877a60ae9 100644 --- a/crates/effects/src/svg_filter.rs +++ b/crates/effects/src/svg_filter.rs @@ -1101,8 +1101,9 @@ pub fn with_id(xml: &str, id: &str) -> Option { let f = filter_element(&doc).ok()?; let at = f.attributes().find(|a| a.name() == "id" && a.namespace().is_none())?.range_value(); let (start, end) = (at.start.checked_sub(head)?, at.end.checked_sub(head)?); - // The value may sit in single quotes: those are escaped too. - let id = escaped(id).replace('\'', "'"); + // The value may sit in single quotes: those are escaped too. A tab, line feed or carriage + // return is written as a character reference, which attribute-value normalization keeps. + let id = escaped(id).replace('\'', "'").replace('\t', " ").replace('\n', " ").replace('\r', " "); Some(format!("{}{}{}", xml.get(..start)?, id, xml.get(end..)?)) } diff --git a/crates/effects/src/trap.rs b/crates/effects/src/trap.rs index f60232bedd..b2721ed0ff 100644 --- a/crates/effects/src/trap.rs +++ b/crates/effects/src/trap.rs @@ -218,34 +218,66 @@ fn visible(sources: &[TrapSource]) -> Vec { /// The pairs `(i, j)`, `i < j`, in ascending order, of `rects` (`None`: left out) that come within /// `reach_x` of each other across x and `reach_y` across y. A sweep over the rects sorted by their /// left edge, so shapes spread over the page cost about one check each rather than one per pair. -fn near_pairs(rects: &[Option], reach_x: f64, reach_y: f64) -> Vec<(usize, usize)> { +/// `None` when there are more than [`MAX_NEAR_PAIRS`] (many shapes all within reach of each other +/// make the pair list grow with the square of their count) or the sweep examines more than +/// `MAX_NEAR_SCAN` pairs (many shapes sharing an x range, far apart in y). +fn near_pairs(rects: &[Option], reach_x: f64, reach_y: f64) -> Option> { + near_pairs_within(rects, reach_x, reach_y, MAX_NEAR_SCAN) +} + +fn near_pairs_within(rects: &[Option], reach_x: f64, reach_y: f64, max_scan: usize) -> Option> { let mut order: Vec<(usize, Rect)> = rects.iter().enumerate().filter_map(|(i, r)| r.map(|r| (i, r))).collect(); order.sort_unstable_by(|a, b| a.1.x0.total_cmp(&b.1.x0).then(a.0.cmp(&b.0))); let mut pairs = vec![]; + let mut scanned = 0usize; for (k, &(i, a)) in order.iter().enumerate() { // Later rects start at or right of `a`, so only how far right they start can rule them out. for &(j, b) in order[k + 1..].iter().take_while(|(_, b)| b.x0 <= a.x1 + reach_x) { + // Rects sharing an x range but far apart in y are examined without being stored. + scanned += 1; + if scanned > max_scan { + return None; + } if b.y0 <= a.y1 + reach_y && a.y0 <= b.y1 + reach_y { + if pairs.len() >= MAX_NEAR_PAIRS { + return None; + } pairs.push((i.min(j), i.max(j))); } } } pairs.sort_unstable(); - pairs + Some(pairs) } +/// Most pairs the sweep in `near_pairs` examines, stored or not (a plain bounds check each). +const MAX_NEAR_SCAN: usize = 1 << 27; + +/// Most candidate pairs one trap evaluation keeps; past it no traps are made (the art draws as is). +/// Each pair costs a boolean trap band, so real art stays far below this. +pub const MAX_NEAR_PAIRS: usize = 1 << 20; + /// The traps between `sources` (back to front) with options `o`: for each two sources of /// different solid colours whose visible parts meet, the strip of the darker one's visible part /// next to the lighter one's (the other way round with [`TrapOptions::reverse`]), painted per -/// [`trap_paint`]. Empty when no visible edges of different colours meet. +/// [`trap_paint`]. Empty when no visible edges of different colours meet, or when there are more +/// than [`MAX_NEAR_PAIRS`] candidate pairs or the pair scan is too long (see [`try_traps`] to tell +/// those apart). pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec { + try_traps(sources, o).unwrap_or_default() +} + +/// [`traps`], but `None` when there are more than [`MAX_NEAR_PAIRS`] candidate pairs or the scan for +/// them is too long (`MAX_NEAR_SCAN` pairs examined), as opposed to +/// `Some` of an empty list when no edges meet. +pub fn try_traps(sources: &[TrapSource], o: &TrapOptions) -> Option> { let colors: Vec> = sources.iter().map(|s| trap_color(&s.fill)).collect(); if colors.iter().flatten().count() < 2 { - return vec![]; + return Some(vec![]); } let (w, r) = (o.thickness, o.height_width); if !(w.is_finite() && w > 0.0 && r.is_finite() && r > 0.0) { - return vec![]; // `trap_band` cuts nothing with these. + return Some(vec![]); // `trap_band` cuts nothing with these. } let vis = visible(sources); // Only pairs whose visible parts come within the trap's reach can trap (`trap_band` rejects @@ -257,7 +289,8 @@ pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec { let extent = rects.iter().flatten().flat_map(|b| [b.x0, b.y0, b.x1, b.y1]).fold(0.0_f64, |m, v| m.max(v.abs())); let hair = 1e-9 * (1.0 + extent + w * r.max(1.0)); let mut out = vec![]; - for (i, j) in near_pairs(&rects, w + hair, w * r + hair) { + let pairs = near_pairs(&rects, w + hair, w * r + hair)?; + for (i, j) in pairs { let (Some(ci), Some(cj)) = (colors[i], colors[j]) else { continue }; if vis[i].is_empty() || vis[j].is_empty() || same_color(&sources[i].fill, &sources[j].fill, ci, cj) { continue; @@ -276,7 +309,7 @@ pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec { let Some(color) = colors[from] else { continue }; out.push(TrapPiece { path: band, paint: trap_paint(&sources[from].fill, color, o), from, into }); } - out + Some(out) } /// A trap's appearance: one overprinting fill of `paint`, no stroke. @@ -379,10 +412,19 @@ mod tests { assert!(near(po::area(&t[0].path, FillRule::NonZero), 22.0 * 22.0 - 400.0)); } + #[test] + fn the_pair_scan_is_bounded_even_when_no_pair_is_near() { + // Eleven rects share an x range but lie far apart in y: 55 pairs are examined, none stored. + let rects: Vec> = (0..11).map(|k| Some(Rect::new(0.0, k as f64 * 1000.0, 10.0, k as f64 * 1000.0 + 10.0))).collect(); + assert_eq!(near_pairs_within(&rects, 1.0, 1.0, 100), Some(vec![])); + assert_eq!(near_pairs_within(&rects, 1.0, 1.0, 54), None); + } + #[test] fn no_trap_without_adjoining_edges_of_different_colours() { let apart = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), yellow()), src(Rect::new(30.0, 0.0, 40.0, 10.0), blue())]; assert!(traps(&apart, &TrapOptions::default()).is_empty()); + assert_eq!(try_traps(&apart, &TrapOptions::default()).map(|t| t.len()), Some(0), "no edges meet: empty, not over the cap"); let same = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), blue()), src(Rect::new(10.0, 0.0, 20.0, 10.0), blue())]; assert!(traps(&same, &TrapOptions::default()).is_empty()); let mut unfilled = abutting(); @@ -431,10 +473,13 @@ mod tests { fn only_shapes_within_reach_are_paired() { let s = many_apart_and_one_abutting(); let rects: Vec> = s.iter().map(|t| t.path.bounds()).collect(); - assert_eq!(near_pairs(&rects, 1.0, 1.0), vec![(0, 2000)], "the squares 10 pt apart are never paired"); + assert_eq!(near_pairs(&rects, 1.0, 1.0), Some(vec![(0, 2000)]), "the squares 10 pt apart are never paired"); // Reaching 10 pt, neighbours in a row pair, in ascending order; left-out rects never do. let row: Vec> = [3, 1, 0, 2].iter().map(|&k| s[k].path.bounds()).chain([None]).collect(); - assert_eq!(near_pairs(&row, 10.0, 10.0), vec![(0, 3), (1, 2), (1, 3)]); + assert_eq!(near_pairs(&row, 10.0, 10.0), Some(vec![(0, 3), (1, 2), (1, 3)])); + // Past the allowance the pair list stops growing instead of being built. + let packed = vec![Some(Rect::new(0.0, 0.0, 1.0, 1.0)); 1500]; + assert_eq!(near_pairs(&packed, 1.0, 1.0), None); let t = traps(&s, &TrapOptions { thickness: 1.0, ..Default::default() }); assert_eq!(t.len(), 1, "only the abutting pair traps"); assert_eq!((t[0].from, t[0].into), (2000, 0)); diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 5d0e5723ff..9ddcc43559 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -774,8 +774,9 @@ fn divide_dashes(s: &mut Session, p: &Value) -> Result { continue; } let only = (segs.len() < sp.segment_count()).then_some(&segs); - let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made)) - .ok_or_else(|| bad(C, "that makes too many pieces (at most 100000): make the dashes longer"))?; + let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made)).ok_or_else(|| { + bad(C, "that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer") + })?; made += out.len(); for (piece, kind) in out.into_iter().filter(|(_, k)| !(remove_gaps && *k == pt::Piece::Gap)) { dashes += usize::from(kind == pt::Piece::Dash); @@ -825,7 +826,7 @@ fn dashed_paths(d: &Document, roots: &[NodeId]) -> Vec { fn expand_dashes(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.pathtools.expandDashes"; - let too_many = || bad(C, "that makes too many pieces (at most 100000): make the dashes longer"); + let too_many = || bad(C, "that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer"); let roots = checked_ids_or_selection(s, p, C)?; let d = &s.doc()?.doc; // Each dashed path and its dashes, as the canvas draws them. diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 22703b467d..73bd0b249a 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -165,6 +165,23 @@ fn node_filters(n: &Node, out: &mut BTreeSet<(String, String)>) { }); } +/// The names the SVG filter effects of appearance `a` give (its own and its fills' and strokes'). +fn appearance_filter_names(a: &Appearance, out: &mut BTreeSet) { + let items = a.items.iter().flat_map(|i| i.effects().iter()); + let named = a.effects.iter().chain(items).filter(|e| e.id == Appearance::SVG_FILTER); + out.extend(named.filter_map(|e| e.params.get("name").and_then(Value::as_str)).map(str::to_string)); +} + +/// The names the SVG filter effects in the subtree of `n` give (opacity-mask art included). +fn node_filter_names(n: &Node, out: &mut BTreeSet) { + n.walk(&mut |m| { + appearance_filter_names(&m.appearance, out); + if let Some(mask) = &m.extras().mask { + node_filter_names(&mask.art, out); + } + }); +} + /// A global or spot colour of its own (not a tint of another swatch): what colours link to. fn is_base(w: &Swatch) -> bool { (w.global || w.spot) && matches!(w.paint, Paint::Solid { swatch: None, .. }) @@ -1219,7 +1236,7 @@ impl Clipboard { /// How the pasted SVG filter effects relink ([`Relink::filters`]). Each names the document's /// filter of its name; one that draws another definition than that filter takes the document's /// filter when pasted back into the document it came from, and elsewhere keeps its own under a - /// free name, so editing or deleting the document's filter leaves it alone. When that name would + /// name no filter or effect of the document gives, so editing or deleting the document's filter leaves it alone. When that name would /// make the filter too long to draw, the free name is a shorter start of its name ("Shif"), else /// a free letter or digit; the filter's id is always its name. fn filter_links(&self, d: &Document, same_doc: bool) -> BTreeMap<(String, String), (String, String)> { @@ -1240,12 +1257,25 @@ impl Clipboard { appearance_filters(&g.appearance, &mut found); } let mut taken: BTreeSet = d.svg_filters.iter().map(|f| f.name.clone()).chain(found.iter().map(|(n, _)| n.clone())).collect(); + let mut effects_read = false; let mut links = BTreeMap::new(); for (name, xml) in found { let Some(held) = d.svg_filters.iter().find(|f| f.name == name).filter(|f| f.xml != xml.trim()) else { continue }; let to = if same_doc { (name.clone(), held.xml.clone()) } else { + // The names the document's effects give are taken too, those whose filter was + // deleted included: defining a filter of that name later changes every effect that + // names it. They are read at the first rename only. + if !effects_read { + effects_read = true; + for root in d.art_roots() { + node_filter_names(root, &mut taken); + } + for g in &d.graphic_styles { + appearance_filter_names(&g.appearance, &mut taken); + } + } // Free names made from the starts of the name, longest first, then free letters and // digits: the first under which the filter still draws. let mut ends: Vec = name.char_indices().map(|(i, _)| i).skip(1).collect(); @@ -1255,7 +1285,7 @@ impl Clipboard { let single = ('a'..='z').chain('0'..='9').map(String::from).filter(|n| !taken.contains(n)); let renamed = shorter.chain(single).find_map(|new| { let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new) - .filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok())?; + .filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?; Some((new, drawn)) }); let Some((new, drawn)) = renamed else { continue }; diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index ba6941eb0d..8ea016db65 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -670,9 +670,11 @@ fn render_pixels( opts: &vectorcraft_render::RenderOptions, color: RasterColorModel, ) -> Result<(vectorcraft_render::Rendered, Option<&'static str>)> { - // No side longer than the renderer draws: it would cut the image off there. - let side = f64::from(vectorcraft_render::MAX_RASTER_SIDE); - if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > side || region.height() * scale > side { + // No side longer than the renderer draws: it would cut the image off there. The sides are + // measured in the renderer's whole pixels. + let side = vectorcraft_render::MAX_RASTER_SIDE; + let (w, h) = vectorcraft_render::region_pixels(region, scale); + if region.width() * region.height() * scale * scale > MAX_PIXELS || w > side || h > side { return Err(EngineError::Other("image would be too large; lower the resolution".into())); } let mut r = vectorcraft_render::Renderer::new(); diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index c8453c8c01..3da73c0385 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -719,7 +719,7 @@ fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result { /// Pathfinder panel › Trap ([`vectorcraft_render::effects::trap`]): the traps between the selected /// objects' leaf shapes, as one group of overprinting paths above the topmost selected object. fn trap(s: &mut Session, p: &Value) -> Result { - use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, traps}; + use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, try_traps}; const ID: &str = "object.pathfinder.trap"; let mut q = p.clone(); if let Some(t) = length_given(p, "thickness", ID)? { @@ -758,7 +758,12 @@ fn trap(s: &mut Session, p: &Value) -> Result { if sources.iter().filter(|s| s.fill.color().is_some()).count() < 2 { return Err(EngineError::Other("Trap: select two or more objects filled with solid colours".into())); } - let made = traps(&sources, &options); + let Some(made) = try_traps(&sources, &options) else { + return Err(EngineError::Other(format!( + "Trap: more than {} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time", + vectorcraft_render::effects::trap::MAX_NEAR_PAIRS + ))); + }; if made.is_empty() { return Err(EngineError::Other("Trap: no edges of different colours meet among the selected objects".into())); } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 48959c54e2..6d29550fa0 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAl use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; -use super::pathops::{len_param, length_given, number_given, shape_node}; +use super::pathops::{length_given, number_given, shape_node}; use super::{ CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok, str_param, switch_or, targets, text_param, @@ -824,7 +824,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result { let leading = match p.get("leading") { None | Some(Value::Null) => None, Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None), - Some(_) => Some(Some(len_param(p, "leading").ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))), + Some(_) => { + Some(Some(length_given(p, "leading", C).ok().flatten().ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))) + } }; let tracking = number_given(p, "tracking", C)?; let justify = match text_param(p, "justify", C)? { diff --git a/crates/engine/src/script/console.rs b/crates/engine/src/script/console.rs index 5a3b40ffe3..a0e3ee6d01 100644 --- a/crates/engine/src/script/console.rs +++ b/crates/engine/src/script/console.rs @@ -106,7 +106,8 @@ impl Entry { Entry::End(e) => format!("ended: {}", e.id()), Entry::Report { ms, host_calls, slowest } => { let time = ms.map_or_else(|| "time unavailable".to_string(), |ms| format!("{ms} ms")); - let mut s = format!("{time}, {host_calls} host calls"); + let calls = if *host_calls == 1 { "host call" } else { "host calls" }; + let mut s = format!("{time}, {host_calls} {calls}"); if !slowest.is_empty() { let list: Vec = slowest.iter().map(|c| format!("{} ×{} {} ms", c.id, c.calls, c.total_ms)).collect(); s.push_str("; slowest: "); diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 78002422d1..7c082739c4 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -166,21 +166,27 @@ thread_local! { static NEXT_JOB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); /// A session's hold on its running job: while one exists the lease is held. Dropping it ends the job. +/// It keeps the run's number and its name as console lines give it, so the run's console entry can +/// end when the job is gone. #[derive(Debug)] -pub struct JobKey(u64); +pub struct JobKey { + id: u64, + run: u64, + name: Arc, +} impl Drop for JobKey { fn drop(&mut self) { let _ = JOBS.try_with(|jobs| { if let Ok(mut jobs) = jobs.try_borrow_mut() { - jobs.remove(&self.0); + jobs.remove(&self.id); } }); } } fn with_job(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option { - JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.0).map(f)) + JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.id).map(f)) } /// The state a script changes that a rollback puts back. @@ -1235,12 +1241,8 @@ impl Session { /// ([`Self::script_report`]). False with none running. pub fn script_abandon(&mut self) -> bool { let Some(key) = self.script_key.take() else { return false }; - let Some(job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else { - drop(key); - raise_ids(self); - self.script_stop.clear(); - self.reset_tool(); - cmd::compositefonts::sync(self); + let Some(job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.id)) else { + self.script_gone(key, console::Ending::Stopped); return true; }; drop(key); @@ -1250,6 +1252,19 @@ impl Session { true } + /// Ends the run whose job is no longer held: its console entry ends with `ending`, with no + /// report line, since its time and host calls went with the job, and the session's state is + /// put back as at the end of any run. + fn script_gone(&mut self, key: JobKey, ending: console::Ending) { + let line = console::ConsoleLine { run: key.run, name: key.name.clone(), entry: console::Entry::End(ending) }; + self.script_console.push(line, false); + drop(key); + raise_ids(self); + self.script_stop.clear(); + self.reset_tool(); + cmd::compositefonts::sync(self); + } + /// A script can't start while a document holds a drag in progress or an undo group, which its /// one undo step could not take in. fn settle_for_script(&mut self) -> Result<()> { @@ -1342,10 +1357,11 @@ impl Session { return Err(EngineError::Other(message)); } let scope = crate::file_access::ScopeHandle::capture(); + let (run, line_name) = (host.run, host.name.clone()); let job = Job { realm, host, world: World::working(self), start_uids: open, name: name.clone(), scope }; let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed); JOBS.with(|jobs| jobs.borrow_mut().insert(id, job)); - self.script_key = Some(JobKey(id)); + self.script_key = Some(JobKey { id, run, name: line_name }); Ok(name) } @@ -1353,14 +1369,15 @@ impl Session { /// run has ended: its report, or the error that ended it, with the documents put back. pub fn script_pump(&mut self) -> Option> { let key = self.script_key.take()?; - let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else { + let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.id)) else { + self.script_gone(key, console::Ending::Internal); return Some(Err(EngineError::Other(JOB_GONE.to_string()))); }; if let Some(e) = job.host.dialogs.take_failure() { return Some(self.script_failed(job, &e)); } if (job.host.waiting.get() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() { - JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job)); + JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job)); self.script_key = Some(key); return None; } @@ -1382,7 +1399,7 @@ impl Session { } Ok(Err(e)) => Some(self.script_failed(job, &e)), Ok(Ok(Progress::Paused)) => { - JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job)); + JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job)); self.script_key = Some(key); None } @@ -1653,6 +1670,13 @@ fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result bool { + s.script_key.as_ref().is_some_and(|k| JOBS.with(|jobs| jobs.borrow_mut().remove(&k.id)).is_some()) +} + /// The objects a run may add before it is asked to continue or stop. fn growth_objects() -> u64 { #[cfg(test)] diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 7ebe247161..e1def8a6fa 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1481,6 +1481,22 @@ fn rasterize_refuses_an_image_longer_than_the_renderer_draws() { assert!(close(b.y0, vb.y0) && b.y1 >= vb.y1, "{b:?} covers {vb:?}"); } +/// Art that snaps to exactly 32,768 pixels a side is rasterized, wherever it is on the canvas. +#[test] +fn rasterize_takes_art_that_snaps_to_32768_pixels_a_side() { + let scale = 400.0 / 72.0; + for y in [100_000.0, 250_000.0, 333_333.3, 777_777.7] { + let mut s = session(); + let probe = rect(&mut s, 10.0, y, 100.0, 5898.0); + let vb = s.doc().unwrap().doc.bounds_of(&[probe], true).unwrap(); + let h = 5898.0 + (32_767.8 / scale - vb.height()); + let a = rect(&mut s, 200.0, y, 100.0, h); + sel(&mut s, &[a]); + let r = s.execute("object.rasterize", &json!({"ppi": 400, "padding": 0})).unwrap_or_else(|e| panic!("at y = {y}: {e}")); + assert_eq!(r["height"].as_u64(), Some(u64::from(vectorcraft_render::MAX_RASTER_SIDE)), "at y = {y}: {r}"); + } +} + /// Rasterize refuses a resolution given as text or as another value that is not a number, and the /// selected art stays as it was. #[test] diff --git a/crates/engine/src/tests/script_console.rs b/crates/engine/src/tests/script_console.rs index ba57d96bdf..985e5b810e 100644 --- a/crates/engine/src/tests/script_console.rs +++ b/crates/engine/src/tests/script_console.rs @@ -170,6 +170,55 @@ fn a_stopped_run_reports_its_diagnostics_and_its_unended_line() { assert_eq!(texts(&mut s, "end"), ["ended: stopped"]); } +/// A report line counts one host call in the singular, and gives a run without a clock as "time +/// unavailable". +#[test] +fn a_report_line_counts_one_host_call_in_the_singular_and_says_when_time_is_unavailable() { + use crate::script::console::{Entry, Slow}; + let report = |ms: Option, host_calls: u64| Entry::Report { ms, host_calls, slowest: vec![] }.text(); + assert_eq!(report(Some(3), 0), "3 ms, 0 host calls"); + assert_eq!(report(Some(3), 1), "3 ms, 1 host call"); + assert_eq!(report(Some(0), 2), "0 ms, 2 host calls"); + assert_eq!(report(None, 0), "time unavailable, 0 host calls"); + assert_eq!(report(None, 1), "time unavailable, 1 host call"); + assert_eq!(report(None, 2), "time unavailable, 2 host calls"); + let slowest = vec![Slow { id: "a.b".into(), calls: 1, total_ms: 4, max_ms: 4 }]; + assert_eq!(Entry::Report { ms: None, host_calls: 1, slowest }.text(), "time unavailable, 1 host call; slowest: a.b ×1 4 ms"); +} + +/// A run whose job is gone still ends its console entry, once, with its own number and name and no +/// report line: as stopped when it is abandoned, as an internal fault when a slice finds it gone. +/// The session can start the next run. +#[test] +fn a_run_whose_job_is_gone_still_ends_its_console_entry() { + for abandon in [true, false] { + let mut s = Session::new(); + s.script_frames = true; + run(&mut s, "$.writeln('one'); while (true) {}").unwrap(); + assert!(s.script_pump().is_none()); + assert!(script::forget_job(&s)); + if abandon { + assert!(s.script_abandon()); + } else { + let e = s.script_pump().unwrap().unwrap_err(); + assert_eq!(e.to_string(), "the script's run is no longer available"); + } + assert!(!s.script_running()); + let all = lines(&mut s); + let kinds: Vec<&str> = all.iter().map(|(_, k, _)| k.as_str()).collect(); + assert_eq!(kinds, ["start", "output", "end"], "{all:?}"); + assert_eq!(all[2].2, if abandon { "ended: stopped" } else { "ended: internal" }); + let c = s.execute("script.console", &json!({})).unwrap(); + assert_eq!((c["lines"][2]["run"].as_u64(), c["lines"][2]["name"].as_str()), (Some(1), Some("t.js"))); + assert!(!s.script_abandon() && s.script_pump().is_none(), "nothing is left to end"); + assert_eq!(lines(&mut s).len(), 3, "the entry ends once"); + s.script_frames = false; + run(&mut s, "$.writeln('two')").unwrap(); + assert_eq!(texts(&mut s, "end").last().map(String::as_str), Some("ended: finished"), "the next run starts"); + assert_eq!(lines(&mut s).last().map(|l| l.0), Some(2)); + } +} + #[test] fn runs_are_numbered_a_busy_run_adds_nothing_and_clear_keeps_the_report() { let mut s = Session::new(); diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index 7606cebe4a..20d95f577c 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -284,6 +284,44 @@ fn pasted_art_with_a_filter_at_the_size_limit_still_draws_when_renamed() { } } +/// A pasted filter is not renamed to a name an effect in the document already gives, even one whose +/// filter was deleted, so defining a filter of that name later changes only that effect. +#[test] +fn pasted_art_takes_no_name_the_documents_effects_use() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": r#""#})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift 2", "ids": [a.0]})); + run(&mut s, "svgFilter.delete", json!({"name": "Shift 2"})); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + r#"{SHIFT_MORE}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let names: Vec = filter_effects(&s).into_iter().map(|(n, _)| n).collect(); + assert_eq!(names, ["Shift 2", "Shift 3"]); +} + +/// A pasted filter whose name holds a tab, a line feed or a carriage return keeps those characters +/// in its id under its new name. +#[test] +fn a_renamed_paste_keeps_whitespace_characters_in_its_id() { + for c in [" ", " ", " "] { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); + let svg = format!( + r#""# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_ne!(pasted[0].0, names(&mut s)[0], "renamed"); + assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok(pasted[0].0.clone()), "{c}: its id is its name"); + } +} + /// Art in a symbol's definition that uses a filter follows its edits too. #[test] fn a_symbols_art_follows_a_filter_edit() { diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 4447946190..f49a88c80b 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -522,6 +522,7 @@ fn character_ruby_and_path_handle_refuse_a_param_of_the_wrong_type() { ("text.setStyle", json!({"size": 30, "justify": {"value": "center"}}), string("justify")), ("text.setStyle", json!({"features": ["dlig", 5]}), tags.clone()), ("text.setStyle", json!({"leading": true}), "leading must be a number or \"auto\"".to_string()), + ("text.setStyle", json!({"leading": "12abc"}), "leading must be a number or \"auto\"".to_string()), ("text.setRangeStyle", json!({"id": id.0, "font": 5}), string("font")), ("text.setRangeStyle", json!({"id": id.0, "size": 30, "style": false}), string("style")), ("text.setRangeStyle", json!({"id": id.0, "features": [null]}), tags), diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 2023b8d47c..8efa0637ad 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -525,6 +525,51 @@ fn many_objects_of_one_name_are_numbered_in_turn() { assert!(text.contains("(Dot_00000000000000019999_)")); } +/// A document with one top-level layer for each of the 27 layer colour presets, named after its +/// position. +fn every_layer_preset() -> Document { + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + for i in 0..vectorcraft_doc::LAYER_COLORS.len() { + let id = doc.alloc_id(); + doc.layers.push(Arc::new(Node::layer(id, &format!("P{i}"), LayerColor::Preset(u8::try_from(i).unwrap())))); + } + doc +} + +/// #6502: Yellow, Magenta and Cyan (presets 4, 5 and 6) are written as the file's indexes 6, 4 +/// and 5, with their own RGB beside them; every other preset keeps its position. +#[test] +fn layer_presets_are_written_with_the_files_colour_index() { + let text = program(&every_layer_preset()); + for (i, (_, [r, g, b])) in vectorcraft_doc::LAYER_COLORS.iter().enumerate() { + let index = match i { + 4 => 6, + 5 => 4, + 6 => 5, + i => i, + }; + let want = format!(" {index} {r} {g} {b} 0 50 0 Lb\n(P{i}) Ln\n"); + assert!(text.contains(&want), "missing {want:?}"); + } +} + +/// #6502: every preset comes back as itself after a save and an open. +#[test] +fn layer_presets_survive_a_round_trip() { + let doc = read_back(&every_layer_preset()); + let colors: Vec = doc + .layers + .iter() + .map(|l| match l.kind { + NodeKind::Layer { color, .. } => color, + _ => panic!("{:?}", l.kind), + }) + .collect(); + let want: Vec = (0..27u8).map(LayerColor::Preset).collect(); + assert_eq!(colors, want); +} + #[test] fn layers_have_fourteen_operands() { let text = program(&sample()); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 0c3587e586..c598e2cd9a 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::swatch::{REGISTRATION, Swatch}; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; +use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath}; use vectorcraft_pathops::BoolOp; @@ -449,14 +449,7 @@ impl<'a> Writer<'a> { /// (`Xy`, `0 0 Xd`, `7 () XW`) when it has any, `LB`, `%AI5_EndLayer--`. fn write_layer(&mut self, n: &Node, after: &[Arc]) { let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return }; - let [r, g, b] = match color { - LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), - LayerColor::Custom(rgb) => *rgb, - }; - let index = match color { - LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), - _ => -1, - }; + let (index, [r, g, b]) = ai_ops::layer_color(*color); if *template { self.warn(TEMPLATES); } diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index a22c94de53..7707f4bc45 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -6,7 +6,7 @@ use std::fmt::Write; use vectorcraft_color::{Color, Gradient}; -use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer}; +use vectorcraft_doc::{LAYER_COLORS, LayerColor, LineCap, LineJoin, StrokeLayer}; use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath}; use crate::ps; @@ -62,6 +62,43 @@ pub(crate) fn guide(s: &mut String, xf: Affine, path: &PathData, editing: bool) } } +/// The colour index an `.ai` layer's `Lb` gives preset `preset` (its position in +/// [`LAYER_COLORS`]). The file numbers Magenta, Cyan and Yellow 4, 5 and 6, where +/// [`LAYER_COLORS`] has Yellow, Magenta and Cyan; every other preset has its own position (#6502, +/// from the index and RGB pairs of the `Lb` records in files users sent). A reader takes the index +/// and ignores the RGB beside it, so the index decides the colour. +pub(crate) fn ai_layer_index(preset: u8) -> u8 { + match preset { + 4 => 6, + 5 => 4, + 6 => 5, + i => i, + } +} + +/// The preset (position in [`LAYER_COLORS`]) of the colour index `index` in an `.ai` layer's +/// `Lb`: the inverse of [`ai_layer_index`]. +pub(crate) fn preset_of_ai_layer_index(index: u8) -> u8 { + match index { + 6 => 4, + 4 => 5, + 5 => 6, + i => i, + } +} + +/// The colour index and RGB an `.ai` layer's `Lb` writes for `color`: a preset's index +/// ([`ai_layer_index`]) with its RGB, or `-1` with a custom colour's RGB. +pub(crate) fn layer_color(color: LayerColor) -> (i64, [u8; 3]) { + match color { + LayerColor::Preset(i) => match LAYER_COLORS.get(usize::from(i)) { + Some((_, rgb)) => (i64::from(ai_layer_index(i)), *rgb), + None => (-1, [79, 128, 255]), + }, + LayerColor::Custom(rgb) => (-1, rgb), + } +} + pub(crate) fn unit(v: f32) -> f32 { if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } } diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 6c84eff236..c0d9852d88 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -471,8 +471,9 @@ impl<'a> Reader<'a> { a.dim = flag(4, false).then_some(dim_pct.round() as u8); let index = n.get(at).copied().unwrap_or(0.0); let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); + // The file numbers some presets differently from `LAYER_COLORS` (#6502). a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 { - LayerColor::Preset(index as u8) + LayerColor::Preset(crate::ai_ops::preset_of_ai_layer_index(index as u8)) } else { LayerColor::Custom([rgb(1), rgb(2), rgb(3)]) }); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 7b208caf38..2c219d2b07 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -8465,20 +8465,44 @@ fn layer_with(ops: &str) -> Node { /// the colour index and the last one change nothing. #[test] fn the_template_flag_of_a_layer() { + // Index 4 is the file's Magenta (#6502). let l = layer_with("1 1 0 0 1 0 0 4 255 79 255 1 50 0"); let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() }; assert!(template && !printable && l.locked); - assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(4))); + assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(5))); + // Index 5 is the file's Cyan (#6502). for ops in ["1 1 1 1 1 0 1 5 1 2 3 1 30 0", "1 1 1 1 1 0 0 5 1 2 3 1 30 1", "1 1 1 1 1 0 1 5 1 2 3 1 30"] { let l = layer_with(ops); let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() }; assert!(template && printable && !l.locked, "{ops}"); - assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(5)), "{ops}"); + assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(6)), "{ops}"); } let l = layer_with("1 1 1 1 0 0 1 0 79 128 255 0 50 0"); assert!(matches!(l.kind, NodeKind::Layer { template: false, .. })); } +/// #6502: an `.ai` file numbers Magenta, Cyan and Yellow 4, 5 and 6 (the index and RGB pairs of +/// the `Lb` records in files users sent), so they open as those presets; the index decides, not +/// the RGB beside it, and the other presets keep their position. +#[test] +fn layer_colour_indexes_follow_the_file_format() { + let color = |ops: &str| match layer_with(ops).kind { + NodeKind::Layer { color, .. } => color, + _ => panic!(), + }; + let preset = |name: &str| { + let i = vectorcraft_doc::LAYER_COLORS.iter().position(|(n, _)| *n == name).unwrap(); + LayerColor::Preset(u8::try_from(i).unwrap()) + }; + for (index, rgb, name) in + [(3, "79 79 255", "Blue"), (4, "255 79 255", "Magenta"), (5, "79 255 255", "Cyan"), (6, "255 255 79", "Yellow"), (7, "128 128 128", "Gray")] + { + assert_eq!(color(&format!("1 1 1 1 0 0 0 {index} {rgb} 0 50 0")), preset(name), "index {index}"); + } + // The RGB beside an index doesn't change the preset. + assert_eq!(color("1 1 1 1 0 0 0 4 255 255 79 0 50 0"), preset("Magenta")); +} + /// The colour index is one place earlier in eleven and twelve operands, as in ten, and the twelfth /// is the dimming percentage; with nine it is one place earlier still. #[test] diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index c5c1c4c269..6b553b8d1a 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -65,8 +65,8 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ - AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, - NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, + AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, Node, NodeKind, ParaDirection, + ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, }; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2}; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index c3ca85c083..979cd2a436 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -940,6 +940,35 @@ fn names_outside_windows_1252_are_replaced_and_kept_apart() { assert_eq!(inks(&read), want); } +/// #6502: the legacy writer gives Yellow, Magenta and Cyan (presets 4, 5 and 6) the file's +/// indexes 6, 4 and 5, and they open as the same presets. +#[test] +fn layer_presets_are_written_with_the_files_colour_index() { + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + for i in 3..8u8 { + let id = doc.alloc_id(); + doc.layers.push(Arc::new(Node::layer(id, &format!("P{i}"), vectorcraft_doc::LayerColor::Preset(i)))); + } + let out = write(&doc); + let text = String::from_utf8_lossy(&out.bytes).replace("\r\n", "\n"); + for (i, index) in [(3, 3), (4, 6), (5, 4), (6, 5), (7, 7)] { + let [r, g, b] = vectorcraft_doc::LAYER_COLORS[i].1; + let want = format!(" 0 {index} {r} {g} {b} Lb\n(P{i}) Ln\n"); + assert!(text.contains(&want), "missing {want:?} in\n{text}"); + } + let read = crate::import(&out.bytes).unwrap().document; + let colors: Vec<_> = read + .layers + .iter() + .filter_map(|l| match l.kind { + NodeKind::Layer { color, .. } => Some(color), + _ => None, + }) + .collect(); + assert_eq!(colors, (3..8u8).map(vectorcraft_doc::LayerColor::Preset).collect::>()); +} + #[test] fn losses_are_warned() { let mut doc = sample(); diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index cf601e9391..c95940b95d 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -5,9 +5,9 @@ use super::encoding::{ use super::{ Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, - LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, - ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, - TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, + LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData, + ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, + TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, }; pub(super) struct Writer<'a> { @@ -135,14 +135,7 @@ impl<'a> Writer<'a> { /// A top-level layer. False when it isn't a layer. pub(super) fn layer(&mut self, n: &Node) -> bool { let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return false }; - let [r, g, b] = match color { - LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), - LayerColor::Custom(rgb) => *rgb, - }; - let index = match color { - LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), - _ => -1, - }; + let (index, [r, g, b]) = ai_ops::layer_color(*color); let flag = |b: bool| u8::from(b); let name = self.layer_name(n.name.as_deref().unwrap_or("Layer")); // A template layer doesn't print and shows dimmed. @@ -173,14 +166,7 @@ impl<'a> Writer<'a> { } let art = std::mem::take(&mut self.hidden_art); let NodeKind::Layer { color, template, printable, .. } = &n.kind else { return false }; - let [r, g, b] = match color { - LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), - LayerColor::Custom(rgb) => *rgb, - }; - let index = match color { - LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), - _ => -1, - }; + let (index, [r, g, b]) = ai_ops::layer_color(*color); let name = format!("{} (hidden)", n.name.as_deref().unwrap_or("Layer")); let name = self.layer_name(&name); let _ = writeln!( diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index e4039e6d9c..c50889d030 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -1194,6 +1194,47 @@ impl Targets { (d, ov, held.contains(&true)) } + /// [`Self::snap_moved_to_rulers`] for a selection a move Shift keeps to its line (through the + /// grabbed point `p`, along `dir`), its bounds now `r`: the edge or centre of `r` that a ruler + /// guide runs past within `tol` goes onto it by sliding along the line, the way needing the + /// shortest slide winning and at most twice `tol` (a line nearly parallel to a guide would + /// carry the selection far to meet it). Returns the shift along the line and the guides. + pub(crate) fn snap_moved_along_to_rulers(&self, p: Point, r: Rect, dir: Vec2, tol: f64) -> (Vec2, Vec) { + let len = dir.hypot(); + if !len.is_finite() || len <= 1e-9 { + return (Vec2::ZERO, vec![]); + } + let u = dir / len; + // The way along the line, where the guide is and where it starts, and whether it is upright. + let mut best: Option<(f64, f64, Point, bool)> = None; + for (vertical, c) in [(true, u.x), (false, u.y)] { + if c.abs() <= 1e-9 { + continue; + } + let vs = if vertical { [r.x0, r.center().x, r.x1] } else { [r.y0, r.center().y, r.y1] }; + let (mid, half) = + if vertical { (Point::new(0.0, r.center().y), r.height() / 2.0) } else { (Point::new(r.center().x, 0.0), r.width() / 2.0) }; + for (t, from, _) in self.rulers.iter().filter(|g| g.vertical == vertical).filter_map(|g| g.line_at(mid, tol + half)) { + for v in vs { + let s = (t - v) / c; + if (t - v).abs() <= tol && s.abs() <= 2.0 * tol && best.is_none_or(|b| s.abs() < b.0.abs()) { + best = Some((s, t, from, vertical)); + } + } + } + } + let Some((s, t, from, vertical)) = best else { return (Vec2::ZERO, vec![]) }; + let moved = r + u * s; + let line = if vertical { + self.line(Point::new(t, from.y.min(moved.y0)), Point::new(t, from.y.max(moved.y1))) + } else { + self.line(Point::new(from.x.min(moved.x0), t), Point::new(from.x.max(moved.x1), t)) + }; + let mut ov: Vec = line.into_iter().collect(); + ov.extend(self.label(p + u * s, "align")); + (u * s, ov) + } + /// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for /// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own /// frame, and so is the result. The handle lands on an anchor or centre within `tol` on the page diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index 4994104656..c34f9ab7de 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -22,6 +22,25 @@ fn snaps_to_anchor_and_alignment() { assert_eq!(p.y, 200.0); } +/// #4312: a Shift move slides the selection along its line onto a ruler guide, but not far to +/// meet one a nearly parallel line only reaches a long way off. +#[test] +fn a_slide_along_a_line_lands_the_bounds_on_a_ruler_guide_within_reach() { + let t = Targets { rulers: vec![Ruler { vertical: true, pos: 300.0, span: None }], ..Targets::default() }; + let r = Rect::new(248.0, 10.0, 298.0, 40.0); + let p = Point::new(270.0, 25.0); + // Level: the right edge is 2 short of the guide, which is the way along the line. + assert_eq!(t.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 0.0), 4.0).0, Vec2::new(2.0, 0.0)); + // On the diagonal it is 2.8 along. + let (d, ov) = t.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 1.0), 4.0); + assert!((d - Vec2::new(2.0, 2.0)).hypot() < 1e-9 && !ov.is_empty(), "{d:?}"); + // Nearly upright, it would take about 118 along the line to meet the guide. + assert_eq!(t.snap_moved_along_to_rulers(p, r, Vec2::new(0.017, 1.0), 4.0).0, Vec2::ZERO); + // A guide of an artboard needs the selection to run past it. + let bounded = Targets { rulers: vec![Ruler { vertical: true, pos: 300.0, span: Some((100.0, 200.0)) }], ..Targets::default() }; + assert_eq!(bounded.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 0.0), 4.0).0, Vec2::ZERO); +} + #[test] fn rect_snap_aligns_edges() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index fe666bd671..8dbdb461ea 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -368,6 +368,14 @@ impl MoveSnap { if q != start + d { d = q - start; guides = ov; + } else if guides.is_empty() + && let Some(b) = self.bounds + { + // Nothing else lined up: the bounds slide along the line onto a ruler guide, as + // they do without Shift (#4312). + let (adj, ov) = t.snap_moved_along_to_rulers(start + d, b + d, u, cx.tol(cx.snap_tolerance)); + d += adj; + guides = ov; } } if cx.snap_to_pixel diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index a524d77389..67f5c91b5d 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -770,6 +770,30 @@ fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { assert_eq!(dx(&ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }, 172.0), 22.0); } +/// #4312: a Shift move keeps to its line while its edges and centre land on a ruler guide, as a +/// move without Shift does (#5746). +#[test] +fn a_shift_move_lands_the_edges_on_a_ruler_guide() { + let (mut d, id) = doc_with_rect(); + d.guides = vec![vectorcraft_doc::Guide::new(true, 333.0)]; + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + // Level; the left edge (100) would end 2 short of the guide, the pointer far from it. + let (m, _) = shift_moved(&c, (150.0 + 231.0, 150.0)); + assert!(moved_by(m, (233.0, 0.0)), "{m:?}"); + // The right edge, 1 past the guide. + let (m, _) = shift_moved(&c, (150.0 + 134.0, 150.0)); + assert!(moved_by(m, (133.0, 0.0)), "{m:?}"); + // Out of reach, or with Snap to Point off, the move stays as dragged. + let (m, _) = shift_moved(&c, (150.0 + 221.0, 150.0)); + assert!(moved_by(m, (221.0, 0.0)), "{m:?}"); + let off = ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }; + let (m, _) = shift_moved(&off, (150.0 + 231.0, 150.0)); + assert!(moved_by(m, (231.0, 0.0)), "{m:?}"); +} + /// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as /// gathered before the previews moved it); released, the move doesn't. #[test] diff --git a/crates/ui-egui/src/chrome/tabs.rs b/crates/ui-egui/src/chrome/tabs.rs index 4d5f75821b..506b149fee 100644 --- a/crates/ui-egui/src/chrome/tabs.rs +++ b/crates/ui-egui/src/chrome/tabs.rs @@ -136,7 +136,7 @@ pub(super) fn first_tab_shown(widths: &[f32], active: Option, room: f32) /// strip shows is the highlighted one. A tab dragged more than [`crate::doc_windows::TEAR_OFF`] /// above or below the strip floats its view in a window under the pointer; one dragged along the /// strip moves its document among the others (`document.move`, #5663), a line marking where it -/// will go. +/// will go. A middle-click on a tab closes it, as its × does (#6506). pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { /// The width of the » button. const MORE: f32 = 30.0; @@ -200,7 +200,9 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text }); ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text); hovering_art |= crate::document_drag::hover_tab(app, &resp, win, xr.expand(3.0)); - if xresp.clicked() { + // A middle-click anywhere on the tab closes it as the × does (#6506), so closing a + // modified document's last view still asks to save first. + if xresp.clicked() || resp.middle_clicked() || xresp.middle_clicked() { close = Some(win); } else if resp.clicked() { activate = Some(win); diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 15c856cd85..452fec1cc0 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -505,6 +505,65 @@ fn a_tabs_close_box_sits_where_the_platform_puts_it() { assert!(super::CLOSE_BEFORE_TITLE == cfg!(target_os = "macos")); } +/// A middle-click on a document tab closes it as the × does (#6506): a saved document closes, and +/// a modified one asks to save first instead of losing its changes. +#[test] +fn a_middle_click_on_a_tab_closes_it_as_the_close_box_does() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + for _ in 0..3 { + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + } + // The second document has unsaved changes; the third stays the current one. + app.session.set_active(1); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.session.set_active(2); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 200.0)); + let frame = |app: &mut crate::VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::doc_tabs(app, ui)); + out.textures_delta.clear(); + let mut titles = vec![]; + for c in &out.shapes { + if let egui::Shape::Text(t) = &c.shape + && t.visual_bounding_rect().width() > 30.0 + { + titles.push(t.visual_bounding_rect()); + } + } + titles.sort_by(|a, b| a.left().total_cmp(&b.left())); + titles + }; + let middle_click = |app: &mut crate::VectorcraftApp, at: egui::Pos2| { + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }; + frame(app, vec![egui::Event::PointerMoved(at), press(true)]); + frame(app, vec![press(false)]) + }; + let titles = frame(&mut app, vec![]); + assert_eq!(titles.len(), 3, "{titles:?}"); + // The modified document's tab: the save prompt comes up and the document stays open. + let titles = middle_click(&mut app, titles[1].center()); + assert_eq!(app.session.documents().len(), 3, "a modified document is not closed without asking"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::unsaved::KIND), "a modified document asks first"); + app.ui.dialog = None; + // The first, saved document's tab: it closes, and the others stay open. + let uid = |app: &crate::VectorcraftApp, i: usize| app.session.documents()[i].uid; + let (second, third) = (uid(&app, 1), uid(&app, 2)); + middle_click(&mut app, titles[0].center()); + assert_eq!(app.session.documents().len(), 2, "the middle-click closed the first document"); + assert_eq!((uid(&app, 0), uid(&app, 1)), (second, third)); + assert!(app.ui.dialog.is_none(), "a saved document closes without asking"); + // A middle-click on the × itself closes too: the third, saved document's. + let titles = frame(&mut app, vec![]); + assert_eq!(titles.len(), 2, "{titles:?}"); + let title = titles[1]; + let x = if super::CLOSE_BEFORE_TITLE { title.left() - 16.0 } else { title.right() + 20.0 }; + middle_click(&mut app, egui::pos2(x, title.center().y)); + assert_eq!(app.session.documents().len(), 1, "the middle-click on the × closed the third document"); + assert_eq!(uid(&app, 0), second); + assert!(app.ui.dialog.is_none(), "a saved document closes without asking"); +} + /// One headless frame of the status bar; returns the artboard navigator's buttons, left to /// right: first, previous, next, last. fn navigator(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1f930dfc45..e69dc7b97c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6521,8 +6521,14 @@ breakText Glyphs Глифи Ended at a limit Прекратен при лимит Stopped Спрян Ended by an internal fault Прекратен поради вътрешна грешка - {ms} ms, {calls} host calls {ms} ms, {calls} извиквания към хоста - {dropped} earlier lines dropped, {cut} lines cut {dropped} по-ранни реда премахнати, {cut} реда съкратени + time unavailable времето не е налично + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} най-бавни: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} извикване към хоста|{n} извиквания към хоста +@plural {n} earlier line dropped|{n} earlier lines dropped {n} по-ранен ред премахнат|{n} по-ранни реда премахнати +@plural {n} line cut|{n} lines cut {n} ред съкратен|{n} реда съкратени # Deferred plug-in effects Refresh Deferred Effects Обновяване на отложените ефекти diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 123f2e7cf4..fc4610e0d0 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6572,8 +6572,14 @@ shape Open Otvoren Ended at a limit Završeno na ograničenju Stopped Zaustavljeno Ended by an internal fault Završeno zbog interne greške - {ms} ms, {calls} host calls {ms} ms, {calls} poziva hosta - {dropped} earlier lines dropped, {cut} lines cut {dropped} ranijih redova uklonjeno, {cut} redova skraćeno + time unavailable vrijeme nije dostupno + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} najsporije: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} poziv hosta|{n} poziva hosta|{n} poziva hosta +@plural {n} earlier line dropped|{n} earlier lines dropped {n} raniji red uklonjen|{n} ranija reda uklonjena|{n} ranijih redova uklonjeno +@plural {n} line cut|{n} lines cut {n} red skraćen|{n} reda skraćena|{n} redova skraćeno # Deferred plug-in effects Refresh Deferred Effects Osvježi odgođene efekte diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index a7c1d07c10..44562ac0f1 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6776,8 +6776,14 @@ breakText Glyphs Glifs Ended at a limit Acabat en un límit Stopped Aturat Ended by an internal fault Acabat per un error intern - {ms} ms, {calls} host calls {ms} ms, {calls} crides a l’amfitrió - {dropped} earlier lines dropped, {cut} lines cut {dropped} línies anteriors descartades, {cut} línies tallades + time unavailable temps no disponible + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} més lentes: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} crida a l’amfitrió|{n} crides a l’amfitrió +@plural {n} earlier line dropped|{n} earlier lines dropped {n} línia anterior descartada|{n} línies anteriors descartades +@plural {n} line cut|{n} lines cut {n} línia tallada|{n} línies tallades @msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` han de ser números de línies que la consola conserva, `first` abans de `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3319dc953b..e42f9bd215 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6795,8 +6795,14 @@ breakText Glyphs Glyfy Ended at a limit Ukončeno na limitu Stopped Zastaveno Ended by an internal fault Ukončeno vnitřní chybou - {ms} ms, {calls} host calls {ms} ms, {calls} volání hostitele - {dropped} earlier lines dropped, {cut} lines cut {dropped} starších řádků zahozeno, {cut} řádků zkráceno + time unavailable čas není k dispozici + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} nejpomalejší: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} volání hostitele|{n} volání hostitele|{n} volání hostitele +@plural {n} earlier line dropped|{n} earlier lines dropped {n} starší řádek zahozen|{n} starší řádky zahozeny|{n} starších řádků zahozeno +@plural {n} line cut|{n} lines cut {n} řádek zkrácen|{n} řádky zkráceny|{n} řádků zkráceno @msg `first` and `last` must be numbers of lines the console holds, first before last `first` a `last` musí být čísla řádků, které konzole uchovává, `first` před `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index dd7af644be..cf7aa67b3f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6535,8 +6535,14 @@ breakText Glyphs Glyffer Ended at a limit Afsluttet ved en grænse Stopped Stoppet Ended by an internal fault Afsluttet af en intern fejl - {ms} ms, {calls} host calls {ms} ms, {calls} værtskald - {dropped} earlier lines dropped, {cut} lines cut {dropped} tidligere linjer fjernet, {cut} linjer afkortet + time unavailable tid ikke tilgængelig + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} langsomste: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} værtskald|{n} værtskald +@plural {n} earlier line dropped|{n} earlier lines dropped {n} tidligere linje fjernet|{n} tidligere linjer fjernet +@plural {n} line cut|{n} lines cut {n} linje afkortet|{n} linjer afkortet # Deferred plug-in effects Refresh Deferred Effects Opdatér udskudte effekter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f2ec5b4d36..b339033e66 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6382,8 +6382,14 @@ breakText Glyphs Glyphen Ended at a limit An einem Limit beendet Stopped Gestoppt Ended by an internal fault Durch einen internen Fehler beendet - {ms} ms, {calls} host calls {ms} ms, {calls} Host-Aufrufe - {dropped} earlier lines dropped, {cut} lines cut {dropped} ältere Zeilen verworfen, {cut} Zeilen gekürzt + time unavailable Zeit nicht verfügbar + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} langsamste: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} Host-Aufruf|{n} Host-Aufrufe +@plural {n} earlier line dropped|{n} earlier lines dropped {n} ältere Zeile verworfen|{n} ältere Zeilen verworfen +@plural {n} line cut|{n} lines cut {n} Zeile gekürzt|{n} Zeilen gekürzt @msg `first` and `last` must be numbers of lines the console holds, first before last `first` und `last` müssen Nummern von Zeilen sein, die die Konsole enthält, `first` vor `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d60272a1f2..114ed5a524 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6747,8 +6747,14 @@ breakText Glyphs Γλύφοι Ended at a limit Τερματίστηκε σε όριο Stopped Διακόπηκε Ended by an internal fault Τερματίστηκε λόγω εσωτερικού σφάλματος - {ms} ms, {calls} host calls {ms} ms, {calls} κλήσεις προς το κεντρικό σύστημα - {dropped} earlier lines dropped, {cut} lines cut {dropped} παλαιότερες γραμμές απορρίφθηκαν, {cut} γραμμές περικόπηκαν + time unavailable ο χρόνος δεν είναι διαθέσιμος + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} πιο αργές: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} κλήση προς το κεντρικό σύστημα|{n} κλήσεις προς το κεντρικό σύστημα +@plural {n} earlier line dropped|{n} earlier lines dropped {n} παλαιότερη γραμμή απορρίφθηκε|{n} παλαιότερες γραμμές απορρίφθηκαν +@plural {n} line cut|{n} lines cut {n} γραμμή περικόπηκε|{n} γραμμές περικόπηκαν @msg `first` and `last` must be numbers of lines the console holds, first before last Τα `first` και `last` πρέπει να είναι αριθμοί γραμμών που κρατά η κονσόλα, το `first` πριν από το `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 552037a49e..c0e50d82e9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6738,8 +6738,14 @@ breakText Glyphs Glifos Ended at a limit Terminado en un límite Stopped Detenido Ended by an internal fault Terminado por un error interno - {ms} ms, {calls} host calls {ms} ms, {calls} llamadas al host - {dropped} earlier lines dropped, {cut} lines cut {dropped} líneas anteriores descartadas, {cut} líneas recortadas + time unavailable tiempo no disponible + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} más lentos: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} llamada al host|{n} llamadas al host +@plural {n} earlier line dropped|{n} earlier lines dropped {n} línea anterior descartada|{n} líneas anteriores descartadas +@plural {n} line cut|{n} lines cut {n} línea recortada|{n} líneas recortadas @msg `first` and `last` must be numbers of lines the console holds, first before last `first` y `last` deben ser números de líneas que la consola conserva, `first` antes que `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f621203399..cdbd875764 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6830,8 +6830,14 @@ breakText Glyphs Merkit Ended at a limit Päättyi rajaan Stopped Pysäytetty Ended by an internal fault Päättyi sisäiseen virheeseen - {ms} ms, {calls} host calls {ms} ms, {calls} isäntäkutsua - {dropped} earlier lines dropped, {cut} lines cut {dropped} aiempaa riviä poistettu, {cut} riviä katkaistu + time unavailable aika ei käytettävissä + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} hitaimmat: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} isäntäkutsu|{n} isäntäkutsua +@plural {n} earlier line dropped|{n} earlier lines dropped {n} aiempi rivi poistettu|{n} aiempaa riviä poistettu +@plural {n} line cut|{n} lines cut {n} rivi katkaistu|{n} riviä katkaistu @msg `first` and `last` must be numbers of lines the console holds, first before last `first` ja `last` on oltava konsolin säilyttämien rivien numeroita, `first` ennen arvoa `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09b27035bb..cb81c4902c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6790,8 +6790,14 @@ breakText Glyphs Glyphes Ended at a limit Terminé à une limite Stopped Arrêté Ended by an internal fault Terminé par une erreur interne - {ms} ms, {calls} host calls {ms} ms, {calls} appels à l’hôte - {dropped} earlier lines dropped, {cut} lines cut {dropped} lignes antérieures supprimées, {cut} lignes coupées + time unavailable temps indisponible + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} plus lentes : {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} appel à l’hôte|{n} appels à l’hôte +@plural {n} earlier line dropped|{n} earlier lines dropped {n} ligne antérieure supprimée|{n} lignes antérieures supprimées +@plural {n} line cut|{n} lines cut {n} ligne coupée|{n} lignes coupées @msg `first` and `last` must be numbers of lines the console holds, first before last `first` et `last` doivent être des numéros de lignes que la console contient, `first` avant `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index a2b4ff9214..296f18e805 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8236,8 +8236,14 @@ theme Appearance अपीयरेंस Ended at a limit सीमा पर समाप्त Stopped रोका गया Ended by an internal fault आंतरिक त्रुटि से समाप्त - {ms} ms, {calls} host calls {ms} ms, {calls} होस्ट कॉल - {dropped} earlier lines dropped, {cut} lines cut {dropped} पुरानी पंक्तियाँ हटाई गईं, {cut} पंक्तियाँ काटी गईं + time unavailable समय उपलब्ध नहीं + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} सबसे धीमे: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} होस्ट कॉल|{n} होस्ट कॉल +@plural {n} earlier line dropped|{n} earlier lines dropped {n} पुरानी पंक्ति हटाई गई|{n} पुरानी पंक्तियाँ हटाई गईं +@plural {n} line cut|{n} lines cut {n} पंक्ति काटी गई|{n} पंक्तियाँ काटी गईं @msg `first` and `last` must be numbers of lines the console holds, first before last `first` और `last` कंसोल में रखी पंक्तियों की संख्याएँ होनी चाहिए, `first` पहले और `last` बाद में # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 46654e8bb4..34b6be0ab7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6779,8 +6779,14 @@ breakText Glyphs Glifák Ended at a limit Korlátnál ért véget Stopped Leállítva Ended by an internal fault Belső hiba miatt ért véget - {ms} ms, {calls} host calls {ms} ms, {calls} gazdahívás - {dropped} earlier lines dropped, {cut} lines cut {dropped} korábbi sor elvetve, {cut} sor levágva + time unavailable az idő nem érhető el + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} leglassabbak: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} gazdahívás +@plural {n} earlier line dropped|{n} earlier lines dropped {n} korábbi sor elvetve +@plural {n} line cut|{n} lines cut {n} sor levágva @msg `first` and `last` must be numbers of lines the console holds, first before last A `first` és a `last` a konzolban tárolt sorok számai legyenek, a `first` a `last` előtt # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a48c194da0..16854b5bb7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6535,8 +6535,14 @@ breakText Glyphs Glif Ended at a limit Berakhir pada batas Stopped Dihentikan Ended by an internal fault Berakhir karena galat internal - {ms} ms, {calls} host calls {ms} ms, {calls} panggilan host - {dropped} earlier lines dropped, {cut} lines cut {dropped} baris lama dibuang, {cut} baris dipotong + time unavailable waktu tidak tersedia + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} paling lambat: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} panggilan host +@plural {n} earlier line dropped|{n} earlier lines dropped {n} baris lama dibuang +@plural {n} line cut|{n} lines cut {n} baris dipotong # Deferred plug-in effects Refresh Deferred Effects Segarkan efek yang ditunda diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3dcc9cec56..6a8f3d7253 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6416,8 +6416,14 @@ breakText Glyphs Glifi Ended at a limit Terminato a un limite Stopped Interrotto Ended by an internal fault Terminato per un errore interno - {ms} ms, {calls} host calls {ms} ms, {calls} chiamate all'host - {dropped} earlier lines dropped, {cut} lines cut {dropped} righe precedenti scartate, {cut} righe tagliate + time unavailable tempo non disponibile + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} più lenti: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} chiamata all'host|{n} chiamate all'host +@plural {n} earlier line dropped|{n} earlier lines dropped {n} riga precedente scartata|{n} righe precedenti scartate +@plural {n} line cut|{n} lines cut {n} riga tagliata|{n} righe tagliate @msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devono essere numeri di righe presenti nella console, `first` prima di `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e6cd92d6ed..aabe534b69 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6481,8 +6481,14 @@ breakText Glyphs 字形 Ended at a limit 上限で終了 Stopped 停止 Ended by an internal fault 内部エラーで終了 - {ms} ms, {calls} host calls {ms} ms、ホスト呼び出し {calls} 回 - {dropped} earlier lines dropped, {cut} lines cut 古い行 {dropped} 行を破棄、{cut} 行を切り詰め + time unavailable 時間を取得できません + {ms} ms {ms} ms + {time}, {calls} {time}、{calls} + slowest: {commands} 最も遅いコマンド:{commands} + {dropped}, {cut} {dropped}、{cut} +@plural {n} host call|{n} host calls ホスト呼び出し {n} 回 +@plural {n} earlier line dropped|{n} earlier lines dropped 古い行 {n} 行を破棄 +@plural {n} line cut|{n} lines cut {n} 行を切り詰め @msg `first` and `last` must be numbers of lines the console holds, first before last `first` と `last` には、コンソールが保持している行の番号を、`first` が `last` より前になるよう指定してください # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 60bbfb87af..bb6a5da7d9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6856,8 +6856,14 @@ font-script Japanese იაპონური Ended at a limit დასრულდა ლიმიტზე Stopped შეჩერდა Ended by an internal fault დასრულდა შიდა შეცდომის გამო - {ms} ms, {calls} host calls {ms} ms, ჰოსტის {calls} გამოძახება - {dropped} earlier lines dropped, {cut} lines cut {dropped} ძველი სტრიქონი წაიშალა, {cut} სტრიქონი შემოკლდა + time unavailable დრო მიუწვდომელია + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} ყველაზე ნელი: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls ჰოსტის {n} გამოძახება +@plural {n} earlier line dropped|{n} earlier lines dropped {n} ძველი სტრიქონი წაიშალა +@plural {n} line cut|{n} lines cut {n} სტრიქონი შემოკლდა @msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4e847e0918..b7f5824635 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6377,8 +6377,14 @@ breakText Glyphs 글리프 Ended at a limit 한도에서 종료됨 Stopped 중지됨 Ended by an internal fault 내부 오류로 종료됨 - {ms} ms, {calls} host calls {ms} ms, 호스트 호출 {calls}회 - {dropped} earlier lines dropped, {cut} lines cut 이전 줄 {dropped}개 삭제됨, 줄 {cut}개 잘림 + time unavailable 시간 정보 없음 + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} 가장 느린 명령: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls 호스트 호출 {n}회 +@plural {n} earlier line dropped|{n} earlier lines dropped 이전 줄 {n}개 삭제됨 +@plural {n} line cut|{n} lines cut 줄 {n}개 잘림 @msg `first` and `last` must be numbers of lines the console holds, first before last `first`와 `last`는 콘솔에 있는 줄 번호여야 하며 `first`가 `last`보다 앞서야 합니다 # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e313809689..32db6785bb 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6586,8 +6586,14 @@ breakText Glyphs Glyphs Ended at a limit Beëindigd bij een limiet Stopped Gestopt Ended by an internal fault Beëindigd door een interne fout - {ms} ms, {calls} host calls {ms} ms, {calls} host-aanroepen - {dropped} earlier lines dropped, {cut} lines cut {dropped} eerdere regels verwijderd, {cut} regels ingekort + time unavailable tijd niet beschikbaar + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} traagste: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} host-aanroep|{n} host-aanroepen +@plural {n} earlier line dropped|{n} earlier lines dropped {n} eerdere regel verwijderd|{n} eerdere regels verwijderd +@plural {n} line cut|{n} lines cut {n} regel ingekort|{n} regels ingekort # Deferred plug-in effects Refresh Deferred Effects Uitgestelde effecten vernieuwen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fd3e2d74b1..398adf5f50 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6651,8 +6651,14 @@ breakText Glyphs Glify Ended at a limit Zakończono na limicie Stopped Zatrzymano Ended by an internal fault Zakończono z powodu błędu wewnętrznego - {ms} ms, {calls} host calls {ms} ms, wywołania hosta: {calls} - {dropped} earlier lines dropped, {cut} lines cut Odrzucone wcześniejsze wiersze: {dropped}, skrócone wiersze: {cut} + time unavailable czas niedostępny + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} najwolniejsze: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} wywołanie hosta|{n} wywołania hosta|{n} wywołań hosta +@plural {n} earlier line dropped|{n} earlier lines dropped {n} odrzucony wcześniejszy wiersz|{n} odrzucone wcześniejsze wiersze|{n} odrzuconych wcześniejszych wierszy +@plural {n} line cut|{n} lines cut {n} skrócony wiersz|{n} skrócone wiersze|{n} skróconych wierszy @msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` muszą być numerami wierszy przechowywanych w konsoli, `first` przed `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ca72406e13..9ede5521a8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6868,8 +6868,14 @@ swatch Yellow Green Verde-amarelado Ended at a limit Encerrado em um limite Stopped Interrompido Ended by an internal fault Encerrado por um erro interno - {ms} ms, {calls} host calls {ms} ms, {calls} chamadas ao host - {dropped} earlier lines dropped, {cut} lines cut {dropped} linhas anteriores descartadas, {cut} linhas cortadas + time unavailable tempo indisponível + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} mais lentos: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} chamada ao host|{n} chamadas ao host +@plural {n} earlier line dropped|{n} earlier lines dropped {n} linha anterior descartada|{n} linhas anteriores descartadas +@plural {n} line cut|{n} lines cut {n} linha cortada|{n} linhas cortadas @msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devem ser números de linhas que o console mantém, `first` antes de `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4ce19715bc..50eb9e969d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6884,8 +6884,14 @@ shape Open Открытый Ended at a limit Завершено на лимите Stopped Остановлено Ended by an internal fault Завершено из-за внутренней ошибки - {ms} ms, {calls} host calls {ms} мс, вызовов хоста: {calls} - {dropped} earlier lines dropped, {cut} lines cut Отброшено прежних строк: {dropped}, обрезано строк: {cut} + time unavailable время недоступно + {ms} ms {ms} мс + {time}, {calls} {time}, {calls} + slowest: {commands} самые медленные: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} вызов хоста|{n} вызова хоста|{n} вызовов хоста +@plural {n} earlier line dropped|{n} earlier lines dropped {n} прежняя строка отброшена|{n} прежние строки отброшены|{n} прежних строк отброшено +@plural {n} line cut|{n} lines cut {n} строка обрезана|{n} строки обрезаны|{n} строк обрезано @msg `first` and `last` must be numbers of lines the console holds, first before last `first` и `last` должны быть номерами строк, которые хранит консоль, причём `first` не позже `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 07c4fc48bf..60324bb91e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8280,8 +8280,14 @@ digits Hindi Hindi Ended at a limit Avslutades vid en gräns Stopped Stoppad Ended by an internal fault Avslutades av ett internt fel - {ms} ms, {calls} host calls {ms} ms, {calls} värdanrop - {dropped} earlier lines dropped, {cut} lines cut {dropped} tidigare rader borttagna, {cut} rader avkortade + time unavailable tid ej tillgänglig + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} långsammast: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} värdanrop|{n} värdanrop +@plural {n} earlier line dropped|{n} earlier lines dropped {n} tidigare rad borttagen|{n} tidigare rader borttagna +@plural {n} line cut|{n} lines cut {n} rad avkortad|{n} rader avkortade @msg `first` and `last` must be numbers of lines the console holds, first before last `first` och `last` måste vara nummer på rader som konsolen har kvar, `first` före `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index fd842dd342..79cbd7a648 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -227,6 +227,24 @@ fn each_language_counts_with_its_plural_rule() { } } +/// The Script Console's counted phrases have a row in every complete catalog, and a count picks +/// the language's own form. +#[test] +fn script_console_counts_take_each_language_s_form() { + const COUNTED: [(&str, &str); 3] = + [("{n} host call", "{n} host calls"), ("{n} earlier line dropped", "{n} earlier lines dropped"), ("{n} line cut", "{n} lines cut")]; + for l in LANGUAGES.iter().filter(|l| l.complete_menus) { + for (one, other) in COUNTED { + assert!(l.catalog().plural(one, other, 0).is_some(), "{} is missing the row for {other}", l.code); + } + } + assert_eq!([1, 2].map(|n| trn(de(), n, COUNTED[0].0, COUNTED[0].1)), ["1 Host-Aufruf", "2 Host-Aufrufe"]); + let ru = Lang::from_code("ru").expect("registered"); + assert_eq!([1, 3, 5].map(|n| trn(ru, n, COUNTED[2].0, COUNTED[2].1)), ["1 строка обрезана", "3 строки обрезаны", "5 строк обрезано"]); + let fr = Lang::from_code("fr").expect("registered"); + assert_eq!([0, 1].map(|n| trn(fr, n, COUNTED[1].0, COUNTED[1].1)), ["0 ligne antérieure supprimée", "1 ligne antérieure supprimée"]); +} + #[test] fn candidates_walk_from_specific_to_general() { assert_eq!(candidates("pt_BR.UTF-8"), ["pt-br", "pt"]); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 21cdd3733e..496dba8cf5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6751,8 +6751,14 @@ breakText Glyphs Glifler Ended at a limit Bir sınırda sona erdi Stopped Durduruldu Ended by an internal fault Bir iç hata nedeniyle sona erdi - {ms} ms, {calls} host calls {ms} ms, {calls} ana bilgisayar çağrısı - {dropped} earlier lines dropped, {cut} lines cut {dropped} eski satır atıldı, {cut} satır kesildi + time unavailable süre kullanılamıyor + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} en yavaşlar: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} ana bilgisayar çağrısı +@plural {n} earlier line dropped|{n} earlier lines dropped {n} eski satır atıldı +@plural {n} line cut|{n} lines cut {n} satır kesildi @msg `first` and `last` must be numbers of lines the console holds, first before last `first` ve `last`, konsolun tuttuğu satırların numaraları olmalı ve `first`, `last` değerinden önce gelmeli # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 255c72c05b..c1b1c3bdaf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6396,8 +6396,14 @@ breakText Glyphs Гліфи Ended at a limit Завершено на ліміті Stopped Зупинено Ended by an internal fault Завершено через внутрішню помилку - {ms} ms, {calls} host calls {ms} мс, викликів хоста: {calls} - {dropped} earlier lines dropped, {cut} lines cut Відкинуто попередніх рядків: {dropped}, обрізано рядків: {cut} + time unavailable час недоступний + {ms} ms {ms} мс + {time}, {calls} {time}, {calls} + slowest: {commands} найповільніші: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} виклик хоста|{n} виклики хоста|{n} викликів хоста +@plural {n} earlier line dropped|{n} earlier lines dropped {n} попередній рядок відкинуто|{n} попередні рядки відкинуто|{n} попередніх рядків відкинуто +@plural {n} line cut|{n} lines cut {n} рядок обрізано|{n} рядки обрізано|{n} рядків обрізано @msg `first` and `last` must be numbers of lines the console holds, first before last `first` і `last` мають бути номерами рядків, які зберігає консоль, причому `first` не пізніше `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6cd9716236..d458c348ee 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6397,8 +6397,14 @@ breakText Glyphs Glyph Ended at a limit Kết thúc ở giới hạn Stopped Đã dừng Ended by an internal fault Kết thúc do lỗi nội bộ - {ms} ms, {calls} host calls {ms} ms, {calls} lệnh gọi máy chủ - {dropped} earlier lines dropped, {cut} lines cut Đã bỏ {dropped} dòng cũ, đã cắt {cut} dòng + time unavailable không có thời gian + {ms} ms {ms} ms + {time}, {calls} {time}, {calls} + slowest: {commands} chậm nhất: {commands} + {dropped}, {cut} {dropped}, {cut} +@plural {n} host call|{n} host calls {n} lệnh gọi máy chủ +@plural {n} earlier line dropped|{n} earlier lines dropped đã bỏ {n} dòng cũ +@plural {n} line cut|{n} lines cut đã cắt {n} dòng @msg `first` and `last` must be numbers of lines the console holds, first before last `first` và `last` phải là số thứ tự của các dòng mà bảng điều khiển đang giữ, `first` đứng trước `last` # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bbd5ab2333..028bd60571 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6633,8 +6633,14 @@ breakText Glyphs 字形 Ended at a limit 在限制处结束 Stopped 已停止 Ended by an internal fault 因内部错误结束 - {ms} ms, {calls} host calls {ms} 毫秒,宿主调用 {calls} 次 - {dropped} earlier lines dropped, {cut} lines cut 已丢弃 {dropped} 行较早内容,已截断 {cut} 行 + time unavailable 时间不可用 + {ms} ms {ms} 毫秒 + {time}, {calls} {time},{calls} + slowest: {commands} 最慢的命令:{commands} + {dropped}, {cut} {dropped},{cut} +@plural {n} host call|{n} host calls 宿主调用 {n} 次 +@plural {n} earlier line dropped|{n} earlier lines dropped 已丢弃 {n} 行较早内容 +@plural {n} line cut|{n} lines cut 已截断 {n} 行 @msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必须是控制台保留的行号,且 `first` 不能在 `last` 之后 # Deferred plug-in effects diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index dbc2cf2485..7cb218f99f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6431,8 +6431,14 @@ breakText Glyphs 字符 Ended at a limit 在限制處結束 Stopped 已停止 Ended by an internal fault 因內部錯誤結束 - {ms} ms, {calls} host calls {ms} 毫秒,主機呼叫 {calls} 次 - {dropped} earlier lines dropped, {cut} lines cut 已捨棄 {dropped} 行較早內容,已截斷 {cut} 行 + time unavailable 時間無法取得 + {ms} ms {ms} 毫秒 + {time}, {calls} {time},{calls} + slowest: {commands} 最慢的命令:{commands} + {dropped}, {cut} {dropped},{cut} +@plural {n} host call|{n} host calls 主機呼叫 {n} 次 +@plural {n} earlier line dropped|{n} earlier lines dropped 已捨棄 {n} 行較早內容 +@plural {n} line cut|{n} lines cut 已截斷 {n} 行 @msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必須是主控台保留的行號,且 `first` 不能在 `last` 之後 # Deferred plug-in effects diff --git a/crates/ui-egui/src/panels/script_console.rs b/crates/ui-egui/src/panels/script_console.rs index 3a3a325000..78540b2115 100644 --- a/crates/ui-egui/src/panels/script_console.rs +++ b/crates/ui-egui/src/panels/script_console.rs @@ -39,10 +39,13 @@ pub(crate) fn shown(line: &ConsoleLine) -> String { Entry::Output(t) | Entry::Log(t) | Entry::Error(t) => t.clone(), Entry::End(e) => format!("■ {}", ending_label(*e)), Entry::Report { ms, host_calls, slowest } => { - let ms = ms.map_or_else(|| "?".to_string(), |ms| ms.to_string()); - let mut s = crate::i18n::fmt(tl!("{ms} ms, {calls} host calls"), &[("ms", &ms), ("calls", &host_calls.to_string())]); - for c in slowest { - s.push_str(&format!("; {} ×{} {} ms", c.id, c.calls, c.total_ms)); + let time = ms.map_or_else(|| tl!("time unavailable").to_string(), millis); + let calls = crate::i18n::tn(*host_calls, "{n} host call", "{n} host calls"); + let mut s = crate::i18n::fmt(tl!("{time}, {calls}"), &[("time", &time), ("calls", &calls)]); + if !slowest.is_empty() { + let list: Vec = slowest.iter().map(|c| format!("{} ×{} {}", c.id, c.calls, millis(c.total_ms))).collect(); + s.push_str("; "); + s.push_str(&crate::i18n::fmt(tl!("slowest: {commands}"), &[("commands", &list.join(", "))])); } s } @@ -50,13 +53,17 @@ pub(crate) fn shown(line: &ConsoleLine) -> String { format!("#{} {}: {}", line.run, line.name, text) } +/// A time in milliseconds, in the UI language. +fn millis(ms: u64) -> String { + crate::i18n::fmt(tl!("{ms} ms"), &[("ms", &ms.to_string())]) +} + /// The notice of the lines the console dropped and cut, or `None` when it has done neither. fn notice(c: &Console) -> Option { (c.dropped_lines() > 0 || c.truncated_lines() > 0).then(|| { - crate::i18n::fmt( - tl!("{dropped} earlier lines dropped, {cut} lines cut"), - &[("dropped", &c.dropped_lines().to_string()), ("cut", &c.truncated_lines().to_string())], - ) + let dropped = crate::i18n::tn(c.dropped_lines(), "{n} earlier line dropped", "{n} earlier lines dropped"); + let cut = crate::i18n::tn(c.truncated_lines(), "{n} line cut", "{n} lines cut"); + crate::i18n::fmt(tl!("{dropped}, {cut}"), &[("dropped", &dropped), ("cut", &cut)]) }) } diff --git a/crates/ui-egui/src/panels/tests_script_console.rs b/crates/ui-egui/src/panels/tests_script_console.rs index 414e1bb36c..c1fbcc4da9 100644 --- a/crates/ui-egui/src/panels/tests_script_console.rs +++ b/crates/ui-egui/src/panels/tests_script_console.rs @@ -6,7 +6,7 @@ use serde_json::json; use vectorcraft_engine::Session; use super::pstate; -use super::script_console::{DRAWN, ID, show}; +use super::script_console::{DRAWN, ID, show, shown}; use crate::tests_labels::painted_text; use crate::{Services, VectorcraftApp, script_runs}; @@ -96,3 +96,48 @@ fn copy_all_and_copy_selected_put_the_lines_on_the_clipboard() { let all = app.clipboard_out.take().unwrap(); assert!(all.lines().next().is_some_and(|l| l.contains("lines dropped")), "Copy All starts with the notice: {}", &all[..80]); } + +/// A report line reads in English as `script.console` gives it: "time unavailable" for a run +/// without a clock, one host call in the singular, and the slowest commands. +#[test] +fn a_report_line_reads_as_script_console_gives_it() { + use vectorcraft_engine::script::console::{ConsoleLine, Entry, Slow}; + let slow = vec![Slow { id: "a.b".into(), calls: 2, total_ms: 7, max_ms: 4 }, Slow { id: "c".into(), calls: 1, total_ms: 1, max_ms: 1 }]; + for ms in [None, Some(0), Some(12)] { + for host_calls in [0, 1, 2] { + for slowest in [vec![], slow.clone()] { + let entry = Entry::Report { ms, host_calls, slowest }; + let want = format!("#3 c.js: {}", entry.text()); + assert_eq!(shown(&ConsoleLine { run: 3, name: "c.js".into(), entry }), want); + } + } + } + let line = |ms, host_calls| shown(&ConsoleLine { run: 1, name: "c.js".into(), entry: Entry::Report { ms, host_calls, slowest: vec![] } }); + assert_eq!(line(None, 1), "#1 c.js: time unavailable, 1 host call"); + assert_eq!(line(Some(0), 2), "#1 c.js: 0 ms, 2 host calls"); +} + +/// The notice counts the lines dropped and the lines cut each in its own form, a count of one in +/// the singular; Copy All starts with it. +#[test] +fn the_notice_counts_one_line_in_the_singular() { + for (dropped, cut, want) in [ + (1, 0, "1 earlier line dropped, 0 lines cut"), + (0, 1, "0 earlier lines dropped, 1 line cut"), + (1, 2, "1 earlier line dropped, 2 lines cut"), + (2, 1, "2 earlier lines dropped, 1 line cut"), + (2, 2, "2 earlier lines dropped, 2 lines cut"), + ] { + let mut app = app(); + // A run adds a start, an end and a report line to its output lines. + let lines = if dropped > 0 { vectorcraft_engine::script::console::MAX_CONSOLE_LINES - 3 + dropped } else { cut + 1 }; + let source = format!("var big = 'x'; while (big.length < 5000) big += big; for (var i = 0; i < {lines}; i++) $.writeln(i < {cut} ? big : i)"); + run(&mut app, &source); + let c = app.session.script_console(); + assert_eq!((c.dropped_lines(), c.truncated_lines()), (dropped as u64, cut as u64)); + app.run("ui.copyScriptConsole", json!({})).unwrap(); + let all = app.clipboard_out.take().unwrap(); + assert_eq!(all.lines().next(), Some(want)); + assert!(painted_text(&mut app, show).contains(want)); + } +} diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 5eff145b17..50c57db650 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -304,12 +304,17 @@ fn frames_with_injected_input_are_confined() { let _ = std::fs::remove_dir_all(base); } -/// The enabled File › Open Recent Files rows `ui.menu.list` returns: (id, label). +/// Every File › Open Recent Files row `ui.menu.list` returns, disabled ones included: (id, label), +/// the label of a disabled row starting "(disabled) ". fn recent_rows(entries: &Value) -> Vec<(String, String)> { let rows = entries.as_array().map(Vec::as_slice).unwrap_or_default(); rows.iter() - .filter(|e| e["command"].as_str().is_some_and(|c| c.starts_with("file.openRecent")) && e["enabled"] == true) - .map(|e| (e["command"].as_str().unwrap_or_default().to_string(), e["label"].as_str().unwrap_or_default().to_string())) + .filter(|e| e["command"].as_str().is_some_and(|c| c.starts_with("file.openRecent"))) + .map(|e| { + let label = e["label"].as_str().unwrap_or_default(); + let label = if e["enabled"] == true { label.to_string() } else { format!("(disabled) {label}") }; + (e["command"].as_str().unwrap_or_default().to_string(), label) + }) .collect() } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8e38543a22..5cad08fbf7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.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; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.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; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | `.ai` save (PDF-compatible editing copy and legacy) and `.ai`/EPS open: a layer's `Lb` colour index follows the file's numbering, where Magenta, Cyan and Yellow are 4, 5 and 6 and `LAYER_COLORS` has Yellow, Magenta and Cyan; Yellow, Magenta and Cyan layers no longer come out as Magenta, Cyan and Yellow, and the other 24 presets are unchanged (#6502, from the index and RGB pairs in users' files). `LAYER_COLORS` keeps its order, so `.vectorcraft` files and the Layers panel don't change. Scores and estimates unchanged. | | 2026-11-11 | minor | `.ai` save: stroked type writes its stroke's cap, join and miter limit into the text document (keys 61, 62 and 64, as `.ai` import reads them since #6500), so they come back when the file opens again and no longer count among the attributes of type left out; a run without a stroke doesn't count them either, as they draw nothing. Tests `ai_editing::tests::type_comes_back_as_type`, `an_unstroked_run_with_a_stroke_style_loses_nothing` (follow-up to #6472) | | 2026-11-11 | minor | `.ai` and EPS import, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. | | 2026-11-11 | minor | `.ai` and EPS import: stroked type in an envelope with Distort Appearance is tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, for a stroke on its appearance or on a group round it as well as its characters'; it was mapped from its bounds with strokes, which reach as far as a miter join may at a corner. Tests `ai::envelope::tests::stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke`, `type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes`, `a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight` (part of #3179) | diff --git a/docs/mcp.md b/docs/mcp.md index 9592012540..9c2a286b72 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1744,7 +1744,12 @@ at the bottom of the panel and in its menu. Output goes on while the panel is cl - `script.console {}` returns `{lines: [{run, name, kind, text}], droppedLines, truncatedLines}`. `run` is the run's number in the session, and `kind` is `start`, `output`, `log`, `error`, `end` or `report`. An end line's text - is `ended:`, a space, and then `finished`, `error`, `fuel`, `hostLimit`, `limit`, `stopped` or `internal`. + is `ended:`, a space, and then `finished`, `error`, `fuel`, `hostLimit`, `limit`, `stopped` or `internal`. A report + line's text is the run's time (`12 ms`, or `time unavailable` on a platform without a clock), a comma and its host + calls (`1 host call`, `2 host calls`), then `; slowest:` and the slowest commands when any were timed. The panel + shows the same text in the interface language. Every run that starts gets an end line. When a run is no longer + available, its end line is `stopped` if the run was abandoned and `internal` if the next slice found it missing, and + no report line follows. - `script.console.clear {}` empties the console, sets its counts to zero and returns `{cleared}`. A running script goes on writing, and `scriptReport` keeps the last run's console. - `ui.copyScriptConsole {first?, last?}` copies lines `first` to `last` to the clipboard as the panel shows them, or diff --git a/docs/plugins.md b/docs/plugins.md index 3a934c9120..06fd89f28a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -170,7 +170,7 @@ much there is only once the run has ended. Cancel (also Esc) and Continue in Background (`plugin.cancelRuns`, `plugin.hideRuns`). When it is done its result is applied as `plugin.run` with the same parameters and objects, as one undo step, unless the document or the plug-in changed meanwhile: then nothing changes, and the status bar says to run it again. On the web, which has no threads, - it runs at once; the instruction budget alone bounds it there. + it runs at once, under the same instruction budget and 300 s limit. - `plugin.run` itself runs a heavy filter where it is called, as it runs any filter: an MCP client waits for it, and its `notifications/cancelled` stops it. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8e78caf8e6..c8fdfcb7e5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -732,6 +732,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. - Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`. - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. + - Scripts: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`). - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), and `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). @@ -2126,6 +2127,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-20 | minor | Type core: Hyphenation… and Justification… no longer listed as missing (done in #2310, #2755); evidence: a paragraph's Auto Leading drives the Character panel's Leading, the leading nudge and Fill with Placeholder Text; scores unchanged | | 2026-10-22 | minor | Drawing tools: a Pen, Anchor Point or Direct Selection drag's preview no longer stalls (#834: the position where the pointer re-entered the window could be skipped, and the move in the release's frame was lost); score and hours unchanged (estimated) | | 2026-10-23 | minor | Guides and snapping: a path moved by a press on its stroke or beside its edge (Selection, Group Selection, Direct Selection), and a straight segment Direct Selection drags, snap by the nearest point of the path, so the path lands exactly on a ruler guide with "align" instead of the pressed spot landing there and the path a few pixels beside it (#3701); `tests_prefs::a_move_held_by_a_paths_stroke_lands_the_path_on_a_ruler_guide`, `tests_prefs::a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide`. Score and hours unchanged. | +| 2026-11-11 | minor | Guides and snapping: a Shift move slides the selection along its line onto a ruler guide by its edges and centre, as a move without Shift does (#4312); a guide a nearly parallel line only reaches a long way off pulls nothing. Score and hours unchanged (estimated). | | 2026-10-22 | minor | Live vector effects: the Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects, its checkboxes visible (#885); the area row now lists only the options on a fill's or stroke's own Transform effect. Score and hours unchanged (estimated). | | 2026-10-23 | minor | COLR colour fonts (v0 and v1) draw and export as vector art; evidence added; scores unchanged. | | 2026-10-22 | minor | Libraries panel: the buttons beside the library picker show the items as icons (the default) or in a list, colours and graphics as rows with their names (a colour's hex value too), dragged and dropped by row; graphic thumbnails are shrunk to the size they are drawn at, so thin strokes stay whole. Tests: `panels::libraries::tests::the_list_view_shows_names_and_takes_drops_by_row`, `the_panel_draws_its_libraries`. Scores and hours unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 00781d47d6..44d0b19a4e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #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; Selection QoL: marquee modes and visible feedback, #5313; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #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; Selection QoL: marquee modes and visible feedback, #5313; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -365,6 +365,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | +| A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | | Behaviour | Status | Notes | |---|---|---| @@ -511,6 +512,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | +| 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | | 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | | 2026-11-10 | minor | Symbol libraries: Inventory Icons joins Basic Shapes, UI Frames and Map Markers as a fourth built-in library (Sword, Shield, Potion, Key, Gem; no default 9-slice). | | 2026-11-11 | minor | Files that record no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files, importer plug-ins) open and revert in Preferences › Units › General instead of points; native, SVG and DXF files and `.ai`/EPS ruler units are kept (#5820). Scores and estimates unchanged. |