Merge remote-tracking branch 'upstream/main' into fix-6437-outline-select-behind

# Conflicts:
#	docs/ui-parity.md
This commit is contained in:
HassanKhan
2026-11-11 19:01:30 +05:00
67 changed files with 951 additions and 153 deletions
+23 -7
View File
@@ -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::<Paint>();
let Paint::Gradient(g) = paint else { return own };
let freeform = g
.freeform
.as_ref()
.map_or(0, |f| f.points.len() * size_of::<FreeformPoint>() + f.lines.iter().map(|l| l.len() * size_of::<usize>()).sum::<usize>());
own + size_of::<vectorcraft_doc::color::GradientPaint>() + g.gradient.stops.len() * size_of::<GradientStop>() + freeform
let name = |s: &Option<String>| 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::<FreeformPoint>()
+ f.lines.len() * size_of::<Vec<usize>>()
+ f.lines.iter().map(|l| l.len() * size_of::<usize>()).sum::<usize>()
});
let stops: usize = g.gradient.stops.iter().map(|s| size_of::<GradientStop>() + name(&s.swatch)).sum();
own + size_of::<vectorcraft_doc::color::GradientPaint>() + 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 {
+21
View File
@@ -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<Vec<Arc<Node>>>
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<Vec<Arc<Node>>>
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<Vec<Arc<Node>>>
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(_)) => {}
+20 -3
View File
@@ -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::<f64>() * 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::<f64>() > 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
+14
View File
@@ -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());
}
+3 -2
View File
@@ -1101,8 +1101,9 @@ pub fn with_id(xml: &str, id: &str) -> Option<String> {
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('\'', "&apos;");
// 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('\'', "&apos;").replace('\t', "&#9;").replace('\n', "&#10;").replace('\r', "&#13;");
Some(format!("{}{}{}", xml.get(..start)?, id, xml.get(end..)?))
}
+54 -9
View File
@@ -218,34 +218,66 @@ fn visible(sources: &[TrapSource]) -> Vec<PathData> {
/// 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<Rect>], 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<Rect>], reach_x: f64, reach_y: f64) -> Option<Vec<(usize, usize)>> {
near_pairs_within(rects, reach_x, reach_y, MAX_NEAR_SCAN)
}
fn near_pairs_within(rects: &[Option<Rect>], reach_x: f64, reach_y: f64, max_scan: usize) -> Option<Vec<(usize, usize)>> {
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<TrapPiece> {
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<Vec<TrapPiece>> {
let colors: Vec<Option<Color>> = 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<TrapPiece> {
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<TrapPiece> {
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<Option<Rect>> = (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<Option<Rect>> = 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<Option<Rect>> = [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));
+4 -3
View File
@@ -774,8 +774,9 @@ fn divide_dashes(s: &mut Session, p: &Value) -> Result<Value> {
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<NodeId> {
fn expand_dashes(s: &mut Session, p: &Value) -> Result<Value> {
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.
+32 -2
View File
@@ -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<String>) {
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<String>) {
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<String> = 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<usize> = 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 };
+5 -3
View File
@@ -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();
+7 -2
View File
@@ -719,7 +719,7 @@ fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result<Value> {
/// 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<Value> {
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<Value> {
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()));
}
+4 -2
View File
@@ -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<Value> {
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)? {
+2 -1
View File
@@ -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<String> = slowest.iter().map(|c| format!("{} ×{} {} ms", c.id, c.calls, c.total_ms)).collect();
s.push_str("; slowest: ");
+37 -13
View File
@@ -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<str>,
}
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<T>(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option<T> {
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<Result<Value>> {
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<std::result:
r
}
/// Tests: removes the session's running job while the session keeps its hold on it. → whether a
/// job was removed.
#[cfg(test)]
pub(crate) fn forget_job(s: &Session) -> 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)]
+16
View File
@@ -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]
+49
View File
@@ -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<u64>, 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();
+38
View File
@@ -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#"<filter id="Shift 2"><feOffset dx="1" dy="1"/></filter>"#}));
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#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{SHIFT_MORE}</defs><rect width="30" height="30" filter="url(#Shift)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
run(&mut s, "edit.pasteInPlace", json!({}));
let names: Vec<String> = 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 ["&#9;", "&#10;", "&#13;"] {
let mut s = session();
run(&mut s, "svgFilter.define", json!({"xml": format!(r#"<filter id="S{c}t"><feOffset dx="3" dy="3"/></filter>"#)}));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs><filter id="S{c}t"><feOffset dx="9" dy="9"/></filter></defs><rect width="30" height="30" filter="url(#S{c}t)"/></svg>"#
);
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() {
+1
View File
@@ -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),
+45
View File
@@ -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<LayerColor> = doc
.layers
.iter()
.map(|l| match l.kind {
NodeKind::Layer { color, .. } => color,
_ => panic!("{:?}", l.kind),
})
.collect();
let want: Vec<LayerColor> = (0..27u8).map(LayerColor::Preset).collect();
assert_eq!(colors, want);
}
#[test]
fn layers_have_fourteen_operands() {
let text = program(&sample());
+2 -9
View File
@@ -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<Node>]) {
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);
}
+38 -1
View File
@@ -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 }
}
+2 -1
View File
@@ -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)])
});
+26 -2
View File
@@ -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]
+2 -2
View File
@@ -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};
+29
View File
@@ -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::<Vec<_>>());
}
#[test]
fn losses_are_warned() {
let mut doc = sample();
+5 -19
View File
@@ -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!(
+41
View File
@@ -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<Overlay>) {
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<Overlay> = 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
+19
View File
@@ -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();
+8
View File
@@ -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
+24
View File
@@ -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]
+4 -2
View File
@@ -136,7 +136,7 @@ pub(super) fn first_tab_shown(widths: &[f32], active: Option<usize>, 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);
+59
View File
@@ -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<egui::Event>| {
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<egui::Event>) -> Vec<egui::Rect> {
+8 -2
View File
@@ -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 Обновяване на отложените ефекти
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it is too large.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it is too large.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2327 and column 5.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it is too large.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8 -2
View File
@@ -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
Can't render this file because it is too large.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+18
View File
@@ -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"]);
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 182 and column 5.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 204 and column 5.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15 -8
View File
@@ -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<String> = 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<String> {
(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)])
})
}
@@ -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));
}
}
+8 -3
View File
@@ -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()
}
+2 -1
View File
@@ -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) |
+6 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+2
View File
@@ -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. |
+3 -1
View File
@@ -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. |