From 9bf05dece4c8e01a98f0d84cfcb112f5532db215 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:28:29 -0800 Subject: [PATCH 1/9] Image Trace: blurred edges no longer trace as rings of in-between colors A blend pixel took a side of its edge only when the picture's own colors on both sides lay within 2 px of it. With a limited palette, an edge blurred over more than about 4 px kept the middle of its ramp in the palette's in-between tones, which traced as rings of thin paths around the edge. With a Limited palette of at most 16 colors (3 Colors, 6 Colors, 16 Colors) and in Grayscale with at most 16 grays (Shades of Gray), settled_wide looks for the own colors within 8 px, pairs the 8 of them nearest to the pixel's color, and moves the pixel to the nearer side of the best pair when that pair explains it twice as well as its own color, or when it lies between the two and off the line between them by less than 0.15 of their distance apart. These values are VectorCraft's own. Of two pairs that explain it equally well it takes the closer one. A pixel keeps its color when an own pixel of that color lies within 4 px or when its color is one of the pair. Grayscale compares lightness. The colors within reach are kept as bitsets for a strip of 7 columns at a time. High Fidelity Photo, Low Fidelity Photo, Black and White and the other palettes keep the 2 px rule unchanged. On the quality harness, blurred and scanned flat art with 6 and 16 Colors goes from 6 to 9 paths to 3, and from 0.23 to 0.26 px off to 0.07 to 0.11. Askew shapes take up to 11 px more area in colors the art does not have, thin lines with 16 Colors lose more (largest distance 23.5 to 45.3 px), and Shades of Gray traces thin lines in 7.3 paths where it took 5.3. Noisy images settle more of their pixels: the dense grouping test traces 150 x 150 noise instead of 120 x 120 to keep more than its 500 paths. Covers the limited-palette part of #1911 (leogrox). Docs: docs/target-app-parity.md, docs/image-trace-quality.md. --- crates/trace/src/group.rs | 4 +- crates/trace/src/quantize.rs | 7 +- crates/trace/src/quantize/cut_edge.rs | 273 +++++++++++++++++++++++++- crates/trace/src/tests.rs | 109 ++++++++++ docs/image-trace-quality.md | 28 +++ docs/target-app-parity.md | 1 + 6 files changed, 416 insertions(+), 6 deletions(-) diff --git a/crates/trace/src/group.rs b/crates/trace/src/group.rs index b51ae57907..a0e3b85020 100644 --- a/crates/trace/src/group.rs +++ b/crates/trace/src/group.rs @@ -420,13 +420,13 @@ mod tests { #[test] fn a_dense_trace_is_grouped_with_a_few_box_tests_per_path() { - let img = noise(120, 120); + let img = noise(150, 150); for (method, stacked) in [(crate::Method::Abutting, false), (crate::Method::Overlapping, true)] { let paths = traced(&img, method); let n = paths.len(); let counted = |by_pairs: bool| { BOX_TESTS.with(|n| n.set(0)); - let found = if by_pairs { parents_by_pairs(&paths, 120, 120, stacked) } else { parents(&paths, 120, 120, stacked) }; + let found = if by_pairs { parents_by_pairs(&paths, 150, 150, stacked) } else { parents(&paths, 150, 150, stacked) }; (found, BOX_TESTS.with(std::cell::Cell::get)) }; let (found, tests) = counted(false); diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 9452671def..f5a2d9fa47 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -8,7 +8,7 @@ mod denoise; mod palette; mod rare; -use cut_edge::{choosing_pixels, compact, settled}; +use cut_edge::{choosing_pixels, compact, settled, settled_wide, settles_wide}; pub(crate) use cut_edge::{continuous_tone, settle_cut_edges}; pub use denoise::denoise; pub(crate) use denoise::denoise_keeping; @@ -91,7 +91,8 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized let g = l.round().clamp(0.0, 255.0) as u8; [g, g, g] }); - (settled(merge_mixed(compact(labels, palette.collect()), img.width as usize, img.height as usize, &[]), img, &own), vec![]) + let q = merge_mixed(compact(labels, palette.collect()), img.width as usize, img.height as usize, &[]); + (if settles_wide(params) { settled_wide(q, img, &own, true) } else { settled(q, img, &own) }, vec![]) } // Logo mode traces without quantising; asked directly, it quantises like Color. Mode::Color | Mode::Logo => { @@ -170,7 +171,7 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized if library { q } else { merge_mixed(q, img.width as usize, img.height as usize, &rare) } }; let q = reduce(params.colors); - let q = settled(q, img, &own); + let q = if settles_wide(params) { settled_wide(q, img, &own, false) } else { settled(q, img, &own) }; rare.retain(|c| q.palette.contains(c)); (q, rare) } diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index 22e5c02af2..99b3c42845 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -1,5 +1,5 @@ use super::palette::{Bin, HistKey, Used, centers, median_cut, nearest_lab}; -use super::{Quantized, Raster, TRANSPARENT, luma, oklab}; +use super::{Mode, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab}; /// `q` with its blend pixels settled, and the colours that leaves unused dropped. /// @@ -65,6 +65,214 @@ pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized if changed { compact(q.labels, q.palette) } else { q } } +/// The most colors (Color) or grays (Grayscale) a palette may ask for to have its blend pixels +/// settled by [`settled_wide`] rather than [`settled`]. The limited-color presets ask for 3 to 16 +/// colors. +const LIMITED_COLORS: u32 = 16; + +/// Whether a trace with `params` settles its blend pixels with [`settled_wide`]: Grayscale, and +/// Color with a Limited palette, of at most [`LIMITED_COLORS`] colors. +pub(super) fn settles_wide(params: &TraceParams) -> bool { + match params.mode { + Mode::Grayscale => params.colors <= LIMITED_COLORS, + Mode::Color | Mode::Logo => params.palette == Palette::Limited && params.colors <= LIMITED_COLORS, + Mode::BlackAndWhite => false, + } +} + +/// How far (px, across, along or diagonally) [`settled_wide`] looks for the picture's own pixels +/// around a blend pixel: blur spreads an edge over several pixels, and its two sides can lie this +/// far apart. +const BLEND_REACH: usize = 8; + +/// How far (px, across, along or diagonally) an own pixel of its color keeps a blend pixel's color +/// in [`settled_wide`]: where three colors meet, the blend is of its color too. +const OWN_NEAR: usize = 4; + +/// How many of the colors within reach of a blend pixel, nearest to its color first, +/// [`settled_wide`] pairs up. +const NEAR_COLORS: usize = 8; + +/// How many columns at a time [`settled_wide`] keeps the colors within reach for, so that its +/// memory does not grow with the width of the picture. +const BLEND_STRIP: usize = 7; + +/// How far apart (squared levels) the distances of a blend pixel off the lines between two pairs +/// of colors may be for [`settled_wide`] to count them as equal: rounding leaves a gray that lies +/// between two grays about 1e-28 off the line between them. +const SAME_FIT: f64 = 1e-6; + +/// How much nearer (squared) to the line between a pair of colors than to its own color a blend +/// pixel must lie for [`settled_wide`] to settle it: half as far, as [`settled`] asks. +const BETTER_FIT: f64 = 0.25; + +/// How far off the line between a pair of colors (as a share of their distance apart) a blend pixel +/// that lies between the two may be for [`settled_wide`] to settle it whatever its own color. +const ON_THE_LINE: f64 = 0.15; + +/// [`settled`] for a limited palette (see [`settles_wide`]), which also settles the in-between +/// tones of an edge blurred over several pixels: without it they trace as a ring of a third color +/// around the edge. +/// +/// A blend pixel (see [`own_colours`]) whose color is none of those of the picture's own pixels +/// within [`OWN_NEAR`] pixels of it takes the side it is nearer to of the pair of the colors of own +/// pixels within [`BLEND_REACH`] pixels that it is best a blend of (of two pairs that explain it +/// equally well, the closer one), when that explains it twice as well as its own color does, or +/// when it lies between the two and off the line between them by less than [`ON_THE_LINE`] of +/// their distance apart. Only the [`NEAR_COLORS`] colors nearest to its color are paired. It keeps +/// its own color when that color is one of the pair, or when one color is all there is around it. +/// With `gray` (Grayscale), a pixel's color is its gray (see [`luma`]). +pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: bool) -> Quantized { + let (w, h) = (img.width as usize, img.height as usize); + if q.labels.len() != w * h || own.len() != w * h { + return q; + } + let before = q.labels.clone(); + let colors: Vec<[f64; 3]> = q.palette.iter().map(|c| c.map(f64::from)).collect(); + let dist2 = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::(); + // The colors of the own pixels within reach of each pixel, as bits (one word per 64 palette + // colors), a strip of columns at a time: along its row for the rows within reach (a window + // sliding along each row, kept for the last 2 × reach + 1 rows), then across those rows. + let words = q.palette.len().div_ceil(64).max(1); + let span = 2 * BLEND_REACH + 1; + let strip = BLEND_STRIP.min(w); + let mut rows = vec![0u64; span * strip * words]; + let mut counts = vec![0u32; q.palette.len()]; + let mut window = vec![0u64; words]; + let mut bits = vec![0u64; words]; + // The bits of row `y` for the columns `x0..x1` (at most `strip` of them). + let mut row_colors = |y: usize, x0: usize, x1: usize, rows: &mut [u64]| { + let Some(out) = rows.get_mut((y % span) * strip * words..(y % span + 1) * strip * words) else { return }; + counts.fill(0); + window.fill(0); + // The own pixel at `x` enters the window (`add`) or leaves it. + let step = |counts: &mut [u32], window: &mut [u64], x: usize, add: bool| { + let i = y * w + x; + let l = before[i] as usize; + if !own[i] || l >= counts.len() { + return; + } + if add { + counts[l] += 1; + window[l / 64] |= 1 << (l % 64); + } else { + counts[l] -= 1; + if counts[l] == 0 { + window[l / 64] &= !(1 << (l % 64)); + } + } + }; + let first = x0.saturating_sub(BLEND_REACH); + for x in first..(x0 + BLEND_REACH).min(w) { + step(&mut counts, &mut window, x, true); + } + for x in x0..x1 { + if x + BLEND_REACH < w { + step(&mut counts, &mut window, x + BLEND_REACH, true); + } + if let Some(gone) = x.checked_sub(BLEND_REACH + 1).filter(|&g| g >= first) { + step(&mut counts, &mut window, gone, false); + } + if let Some(o) = out.get_mut((x - x0) * words..(x - x0 + 1) * words) { + o.copy_from_slice(&window); + } + } + }; + let mut changed = false; + let mut near: Vec = Vec::new(); + let mut by_distance: Vec<(f64, u16)> = Vec::new(); + let pixel = |p: &[u8]| if gray { [f64::from(luma([p[0], p[1], p[2], p[3]])); 3] } else { [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])] }; + for (x0, y) in (0..w).step_by(strip.max(1)).flat_map(|x0| (0..h).map(move |y| (x0, y))) { + let x1 = (x0 + strip).min(w); + if y == 0 { + for yy in 0..BLEND_REACH.min(h) { + row_colors(yy, x0, x1, &mut rows); + } + } + if y + BLEND_REACH < h { + row_colors(y + BLEND_REACH, x0, x1, &mut rows); + } + 'pixels: for x in x0..x1 { + let i = y * w + x; + let mine_l = before[i]; + if own[i] || mine_l == TRANSPARENT { + continue; + } + // Its color is the picture's own here. + for yy in y.saturating_sub(OWN_NEAR)..=(y + OWN_NEAR).min(h - 1) { + for xx in x.saturating_sub(OWN_NEAR)..=(x + OWN_NEAR).min(w - 1) { + if own[yy * w + xx] && before[yy * w + xx] == mine_l { + continue 'pixels; + } + } + } + bits.fill(0); + for yy in y.saturating_sub(BLEND_REACH)..=(y + BLEND_REACH).min(h - 1) { + let at = (yy % span) * strip * words + (x - x0) * words; + for (b, r) in bits.iter_mut().zip(rows.get(at..at + words).unwrap_or_default()) { + *b |= r; + } + } + near.clear(); + for (k, &b) in bits.iter().enumerate() { + let mut b = b; + while b != 0 { + let l = (k * 64 + b.trailing_zeros() as usize) as u16; + b &= b - 1; + near.push(l); + } + } + let (Some(p), Some(&mine)) = (img.rgba.get(i * 4..i * 4 + 4).map(pixel), colors.get(mine_l as usize)) else { + continue; + }; + if near.iter().filter(|&&l| l != mine_l).count() < 2 { + continue; + } + // In busy art many colors lie within reach: only the ones nearest to its color are paired. + // Each distance is worked out once, and only the nearest few are sorted (nearest first, + // then by index). + if near.len() > NEAR_COLORS { + by_distance.clear(); + by_distance.extend(near.iter().map(|&l| (colors.get(l as usize).map_or(f64::INFINITY, |&c| dist2(p, c)), l))); + let order = |a: &(f64, u16), b: &(f64, u16)| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)); + by_distance.select_nth_unstable_by(NEAR_COLORS, order); + by_distance.truncate(NEAR_COLORS); + by_distance.sort_unstable_by(order); + near.clear(); + near.extend(by_distance.iter().map(|&(_, l)| l)); + } + // The pair it is best a blend of, by the square of how far it lies off the line between them, + // and of two pairs it lies equally far off, the closer pair (in grays it lies on the line + // between every pair of grays darker and lighter than it). + let mut best: Option<(f64, [u16; 2], u16, f64, bool)> = None; + for (n, &a) in near.iter().enumerate() { + let Some(&ca) = colors.get(a as usize) else { continue }; + let pa = [p[0] - ca[0], p[1] - ca[1], p[2] - ca[2]]; + for &b in &near[n + 1..] { + let Some(&cb) = colors.get(b as usize) else { continue }; + let d = [cb[0] - ca[0], cb[1] - ca[1], cb[2] - ca[2]]; + let len2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + let along = if len2 > 0.0 { (pa[0] * d[0] + pa[1] * d[1] + pa[2] * d[2]) / len2 } else { 0.0 }; + let t = along.clamp(0.0, 1.0); + let off2 = (pa[0] - t * d[0]).powi(2) + (pa[1] - t * d[1]).powi(2) + (pa[2] - t * d[2]).powi(2); + if best.is_none_or(|x| off2 < x.0 - SAME_FIT || (off2 <= x.0 + SAME_FIT && len2 < x.3)) { + best = Some((off2, [a, b], if t < 0.5 { a } else { b }, len2, along > 0.0 && along < 1.0)); + } + } + } + // A pixel already of one of the two colors it is best a blend of stays as it is. + if let Some((off2, pair, to, apart2, between)) = best + && !pair.contains(&mine_l) + && (off2 < BETTER_FIT * dist2(p, mine) || (between && off2 < ON_THE_LINE * ON_THE_LINE * apart2)) + { + q.labels[i] = to; + changed = true; + } + } + } + if changed { compact(q.labels, q.palette) } else { q } +} + /// Whether `img` is continuous tone (a photo, a gradient) rather than flat art, however noisy /// or unevenly lit (a scan): whether eight colours leave much of it unsaid. Flat art is a few /// colours and what noise and lighting make of them, and eight colours come within a few levels @@ -443,3 +651,66 @@ fn edge_blend_of(own: [f64; 3], beside: [u8; 3], alpha: u8) -> bool { let off2: f64 = (0..3).map(|c| (own[c] - from[c] - t * d[c]).powi(2)).sum(); off2 <= EDGE_BLEND * EDGE_BLEND } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_gray_blend_takes_the_closer_of_two_pairs_it_lies_between_despite_rounding() { + // Gray 105 in the middle, with own pixels of gray 50 to its left, 200 to its right and black + // along the top row, and a palette gray of 120 of its own. It lies between 0 and 200 exactly + // and between 50 and 200 to within rounding: it takes 50, the nearer of the closer pair. + let gray = |x: u32, y: u32| match (x, y) { + (8, 8) => 105, + (_, 0) => 0, + (0..=8, _) => 50, + _ => 200, + }; + let img = Raster::from_fn(17, 17, |x, y| { + let v = gray(x, y); + [v, v, v, 255] + }); + let palette = vec![[0, 0, 0], [50, 50, 50], [200, 200, 200], [120, 120, 120]]; + let labels: Vec = (0..17 * 17) + .map(|i| match gray(i % 17, i / 17) { + 0 => 0, + 50 => 1, + 200 => 2, + _ => 3, + }) + .collect(); + let own: Vec = labels.iter().map(|&l| l != 3).collect(); + let q = settled_wide(Quantized { labels, palette }, &img, &own, true); + let middle = q.labels[8 * 17 + 8]; + assert_eq!(q.palette[usize::from(middle)], [50, 50, 50]); + } + + #[test] + fn a_blend_pixel_beyond_the_end_of_a_pair_keeps_its_nearest_color() { + // A column of [92, 92, 92] between own pixels of [100, 100, 100] to its left and + // [200, 200, 200] to its right, with a palette color of [90, 90, 90]: it lies beyond the + // darker end of the pair, not between the two, and its own color is the nearer, so it + // keeps it. + let value = |x: u32| match x { + 0..=7 => 100, + 8 => 92, + _ => 200, + }; + let img = Raster::from_fn(17, 17, |x, _| { + let v = value(x); + [v, v, v, 255] + }); + let palette = vec![[100, 100, 100], [200, 200, 200], [90, 90, 90]]; + let labels: Vec = (0..17 * 17u32) + .map(|i| match value(i % 17) { + 100 => 0, + 200 => 1, + _ => 2, + }) + .collect(); + let own: Vec = labels.iter().map(|&l| l != 2).collect(); + let q = settled_wide(Quantized { labels, palette }, &img, &own, false); + assert_eq!(q.palette[usize::from(q.labels[8 * 17 + 8])], [90, 90, 90]); + } +} diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index d0a589b916..b244c3b79b 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1330,6 +1330,115 @@ fn a_blend_of_two_colours_is_no_sliver_of_a_third() { assert!(b.y0 > 89.0 && b.y1 < 110.0, "{b:?}"); } +/// A light disc of radius 32 at (55, 60) on a dark ground, its edge blurred into a ramp 10 px +/// wide, and elsewhere a patch of the tone halfway between the two, so that the palette holds +/// that tone. Returns the picture and whether a color is that tone. +fn blurred_disc_and_halfway_patch() -> (Raster, impl Fn([u8; 3]) -> bool) { + const DARK: [f64; 3] = [20.0, 25.0, 35.0]; + const LIGHT: [f64; 3] = [225.0, 195.0, 160.0]; + let mid = [0, 1, 2].map(|k| (DARK[k] + LIGHT[k]) / 2.0); + let img = Raster::from_fn(160, 120, |x, y| { + let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let c = if (112.0..152.0).contains(&px) && (40.0..80.0).contains(&py) { + mid + } else { + let t = ((32.0 - Point::new(px, py).distance(Point::new(55.0, 60.0))) / 10.0 + 0.5).clamp(0.0, 1.0); + [0, 1, 2].map(|k| DARK[k] + t * (LIGHT[k] - DARK[k])) + }; + let c = |k: usize| c[k].round() as u8; + [c(0), c(1), c(2), 255] + }); + (img, move |c: [u8; 3]| (0..3).all(|k| (f64::from(c[k]) - mid[k]).abs() <= 12.0)) +} + +#[test] +fn a_blurred_edge_is_no_ring_of_a_tone_between_its_sides() { + let (img, is_mid) = blurred_disc_and_halfway_patch(); + let res = trace(&img, &sixteen_colors()); + let mids: Vec<&TracedPath> = res.paths.iter().filter(|p| is_mid(p.color)).collect(); + assert_eq!(mids.len(), 1, "the patch, and no ring around the disc: {:?}", res.paths.iter().map(|p| p.color).collect::>()); + let b = mids[0].path.bounds().unwrap(); + assert!(b.x0 > 110.0 && b.y0 > 38.0 && b.y1 < 82.0, "{b:?}"); + // The disc's edge lies halfway along the ramp. + let light = res.paths.iter().find(|p| p.color[0] > 180).unwrap(); + let off = along(&light.path).iter().map(|p| (p.distance(Point::new(55.0, 60.0)) - 32.0).abs()).fold(0.0, f64::max); + assert!(off < 1.0, "{off} px off the middle of the ramp"); +} + +#[test] +fn grayscale_settles_a_tinted_blurred_edge_by_its_lightness() { + // The same blurred disc and halfway patch in grays, and tinted green at the same lightness + // (every pixel moved by the same color, which keeps its luma): Grayscale reads lightness + // alone, so both make the same grays in the same places. + let lightness = |x: u32, y: u32| { + let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + if (112.0..152.0).contains(&px) && (40.0..80.0).contains(&py) { + 130 + } else { + let t = ((32.0 - Point::new(px, py).distance(Point::new(55.0, 60.0))) / 10.0 + 0.5).clamp(0.0, 1.0); + (40.0 + 180.0 * t).round() as u8 + } + }; + let gray = Raster::from_fn(160, 120, |x, y| { + let v = lightness(x, y); + [v, v, v, 255] + }); + let tinted = Raster::from_fn(160, 120, |x, y| { + let v = lightness(x, y); + [v - 40, v + 28, v - 40, 255] + }); + let lumas = |img: &Raster| img.rgba.as_chunks::<4>().0.iter().map(|&p| crate::quantize::luma(p)).collect::>(); + assert_eq!(lumas(&tinted), lumas(&gray)); + let params = TraceParams { mode: Mode::Grayscale, colors: 8, ..TraceParams::default() }; + assert_eq!(quantize(&tinted, ¶ms), quantize(&gray, ¶ms)); +} + +#[test] +fn grayscale_settles_a_blurred_edge_between_its_own_two_grays() { + // A dark gray and a light gray meet in a ramp 12 px wide, and a black band runs across the top. + // In grays every blend lies between black and the light gray as well as between the two grays + // (to within rounding): the ramp's pixels just below the band take the nearer of the two grays, + // the grays on either side of them. + for (dark, light) in [(40u8, 200u8), (50, 200), (30, 170), (60, 230), (45, 215)] { + let img = Raster::from_fn(160, 60, |x, y| { + let v = if y < 8 { + 0 + } else { + let t = ((f64::from(x) + 0.5 - 74.0) / 12.0).clamp(0.0, 1.0); + (f64::from(dark) + f64::from(light - dark) * t).round() as u8 + }; + [v, v, v, 255] + }); + let params = TraceParams { mode: Mode::Grayscale, colors: 8, ..TraceParams::default() }; + let q = quantize(&img, ¶ms); + let middle = u16::from(dark) + u16::from(light); + for y in 10..16 { + for x in 74..86 { + let v = img.rgba[(y * 160 + x) * 4]; + let g = q.palette[usize::from(q.labels[y * 160 + x])][0]; + assert_eq!(2 * u16::from(g) < middle, 2 * u16::from(v) < middle, "{dark} to {light}, ({x}, {y}): {v} took gray {g}"); + assert!(g >= dark, "{dark} to {light}, ({x}, {y}): {v} took gray {g}"); + } + } + } +} + +#[test] +fn the_photo_presets_keep_the_tones_of_a_blurred_edge() { + // The photo presets ask for more colors than the limited-color presets and keep the middle of + // the ramp in the halfway tone: only a blend pixel with the colors on both sides within 2 px of + // it takes a side. + let (img, is_mid) = blurred_disc_and_halfway_patch(); + for name in ["High Fidelity Photo", "Low Fidelity Photo"] { + let q = quantize(&img, &preset(name).unwrap()); + let middle = (0..160 * 120) + .filter(|&i| (Point::new((i % 160) as f64 + 0.5, (i / 160) as f64 + 0.5).distance(Point::new(55.0, 60.0)) - 32.0).abs() < 1.0) + .filter(|&i| q.palette.get(usize::from(q.labels[i])).is_some_and(|&c| is_mid(c))) + .count(); + assert!(middle > 100, "{name}: {middle} pixels of the halfway tone in the middle of the ramp"); + } +} + #[test] fn hairlines_keep_their_colour_in_color_mode() { // Black lines one pixel wide on white: every black pixel is on an edge, none is a blend. diff --git a/docs/image-trace-quality.md b/docs/image-trace-quality.md index 034e009f17..364dc2fcd8 100644 --- a/docs/image-trace-quality.md +++ b/docs/image-trace-quality.md @@ -156,6 +156,34 @@ under 0.9 px RMS from the gradient's contours, 1.13 px when smoothed as edges, a four corners); `layers::tests` (a ramp, flat colors in palette entries far apart, close flat colors, a thin flat stroke, colors off the line between their entries, and Grayscale's grays, each read from both sides of the crack) and `subpixel::tests` (the rule, and one decision for a whole piece from either side). +## Blurred edges +`settled` moved a blend pixel to a side of its edge only when the picture's own colors on both sides lay +within 2 px of it, so the middle of an edge blurred over more than about 4 px kept the palette's +in-between tones and traced as rings of thin paths around the edge. In Color with a Limited palette of +at most 16 colors and in Grayscale with at most 16 grays, `cut_edge::settled_wide` now does the work, by +VectorCraft's own rule (RGB distances, 0 to 255 a channel; Grayscale reads each pixel as its gray). A +blend pixel with no own pixel of its color within 4 px pairs the 8 own colors within 8 px that are nearest +to its color. It takes the nearer side of the pair it is best a blend of when that pair explains it twice +as well as its own color, or when it lies between the two and off the line between them by less than 0.15 +of their distance apart. Of two pairs that fit equally well it takes the closer pair. Other palettes, +High Fidelity Photo, Low Fidelity Photo and Black and White keep `settled`. +Measured with the harness below on `main` at `77970c6924`, before and after the change (256 px variants +unless stated): 16 Colors on blurred flat art 9.0 paths to 3.0, 158 anchors to 34, 0.239 px off to 0.069; +6 Colors 6.0 paths to 3.0, 118 anchors to 33, 0.226 px off to 0.071; scanned, 16 Colors 8.0 paths to 3.0 +and 0.259 px off to 0.110, 6 Colors 7.5 paths to 3.0 and 0.235 px off to 0.093. On each preset's own +inputs (section 1), 6 Colors went from 4.1 paths to 3.0 and 0.143 px off to 0.101, and 16 Colors from 4.6 +paths to 3.0 and 0.145 px off to 0.100. Auto Color does not change. Worse by more than 5% (25 of 211 rows +that moved, most of them variants of the same inputs): askew shapes, odd px 11 to 22 with 6 Colors (paths +4.0 to 4.3, odd colours 0.3 to 0.7), 23 to 26 with 3 Colors, 17 to 22 with 16 Colors and 27 to 30 with +Shades of Gray; thin lines, missed max 23.5 to 45.3 px with 16 Colors, and with Shades of Gray paths 5.3 to +7.3, anchors 36 to 41 and odd colours 3.0 to 5.0; linear gradients with 3 Colors, paths 3.0 to 3.3, and +with 6 Colors with Gradients, paths 1.3 to 1.5 and anchors 13 to 16; two- and three-stop gradients with 6 +Colors with Gradients, paths 1.2 to 1.3; gradient and photo with 16 Colors, overlap px 0.0 to 0.2 and +specks 0.8 to 1.0. The tables below are the previous measurement, not a rerun. +Tests on code-drawn images: `tests::a_blurred_edge_is_no_ring_of_a_tone_between_its_sides` (a disc +blurred into a 10 px ramp, 16 Colors), `tests::grayscale_settles_a_blurred_edge_between_its_own_two_grays`, +`tests::grayscale_settles_a_tinted_blurred_edge_by_its_lightness`, +`tests::the_photo_presets_keep_the_tones_of_a_blurred_edge` and `quantize::cut_edge::tests`. ## A simpler contour walk `contour::trace_mask` follows each region's outline along the pixel cracks with the inside on the right. It used to build a table of outgoing directions for every pixel corner in a raster pass and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d9783b0216..81a6ac6639 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1330,6 +1330,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, band borders in smooth areas:** in Color and Grayscale traces, a border between two colors where the image changes gradually is the contour of a smooth area, by VectorCraft's own rule (`subpixel::low_contrast`, distances measured in RGB, 0 to 255 a channel): next to the border the image makes less than half the step between the two colors; it makes more of the step a few pixels further out, or changes by a distance under 12 (about 7 levels in each channel of a gray); the pixels on each side change too; and its whole change next to the border is under twice its change from one color towards the other, or under 24. Grayscale reads each pixel as its gray, for this rule and for every edge's sub-pixel position. Such a border is smoothed over about 4 px instead of 0.5 px (0.9 px for an edge without anti-aliasing), keeps no corners and is fitted to at least 0.5 px, so band borders in photos and gradients follow the image's contours with fewer anchors; without Shapes, both colors beside it share the same curve. Color traces of clean flat-color art and Black and White traces are unchanged, Grayscale traces of colored art place their edges closer to the art's outlines, and noisy flat-color art can take fewer anchors where noise splits a color into bands; see [quality notes](image-trace-quality.md#band-borders-in-smooth-areas). Tests: `tests::the_borders_of_a_gradients_bands_follow_its_contours`, `layers::tests` and `subpixel::tests` in `crates/trace`. Scores and hours unchanged. - **Image Trace, cut-out edges:** in Color and Grayscale, a semi-transparent pixel (alpha 128 to 254) on the edge of a cut-out image, beside a pixel that is less than half opaque, traces as a color of the fully opaque pixels within two pixels of it when its own color is that color or a darker shade of it, and of several such colors, as the one the most of those pixels have (art across a transparent gap does not count), so a darkened edge tone no longer traces as a 1 px sliver of an unrelated palette color, while a thin semi-transparent outline of another color keeps its own; code-drawn cut-out, opaque-edge and thin-outline tests in `crates/trace`. - **Image Trace, cut-out edges choose no color:** in Color and Grayscale, the edge pixels that take the color beside them, and the semi-transparent pixels within two pixels of them whose color is also the opaque art's color blended with the background, do not count when the palette or the grays are chosen, so the edge of a cut-out image no longer adds a second shade of a color to Auto Color's palette, takes away an outline's color or takes a gray of its own; beside those pixels, the anti-aliasing between a color and an outline drawn along the edge is no color of Auto Color's own when it lies at least 0.45 of the way from the color to the outline and at least one in 32 of its pixels has both around it (edge pixels that choose no color count when they lie within 2.5 Oklab × 100 of the nearest of those colors): its pixels trace as the outline, so a thin outline stays a ring of few pieces, except beside an outline along the edge with a region of 8 pixels of its own, where each takes the nearer of the two, so a wider outline is not drawn wider; an outline over the edge in the color of other art traces in that color, while an opaque rim drawn along the edge keeps its color, also where other art reaches it; a thin semi-transparent outline of another color still counts, and opaque images trace as before; code-drawn cut-out outline, rim, opaque line and grayscale edge tests in `crates/trace`. +- **Image Trace, blurred edges:** in Color with a Limited palette of at most 16 colors (3 Colors, 6 Colors, 16 Colors) and in Grayscale with at most 16 grays (Shades of Gray), by VectorCraft's own rule (RGB distances, 0 to 255 a channel), a blend pixel with no unblended pixel of its own color within 4 pixels takes the nearer side of the pair of colors within 8 pixels that it is best a blend of, of the 8 colors within 8 pixels nearest to its color, when that pair explains it twice as well as its own color, or when it lies between the two and off the line between them by less than 0.15 of their distance apart; it keeps its color when it is one of the pair. Grayscale compares lightness. The in-between tones of an edge blurred over several pixels no longer trace as rings around it. High Fidelity Photo, Low Fidelity Photo and Black and White trace as before, and so do Automatic, Full Tone and Document Library palettes. Noisy and textured images settle more of their pixels. By `crates/trace/tests/quality` (256 px variants), 16 Colors on blurred flat art went from 9.0 paths to 3.0, 158 anchors to 34 and 0.239 px off to 0.069, and 6 Colors from 6.0 paths to 3.0 and 0.226 px off to 0.071; askew shapes take up to 11 px more area in colors the art does not have, and with 16 Colors the largest distance from a thin line's outline to the trace goes from 23.5 px to 45.3 (see [quality notes](image-trace-quality.md#blurred-edges)). Code-drawn blurred-disc and grayscale ramp tests in `crates/trace`. - **Image Trace, simpler contour walk:** `contour::trace_mask` works out each turn from the pixels during the walk, with no precomputed direction table and no assertion; identical loops to the previous walk on every 4 × 4 mask (see [quality notes](image-trace-quality.md#a-simpler-contour-walk)). - **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: blurred and noisy linear gradients 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at worst. - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. From 40bd6a88113a6bbd2b62075c634bc119c4c43656 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:29:54 -0800 Subject: [PATCH 2/9] =?UTF-8?q?Place:=20File=20=E2=80=BA=20Revert=20drops?= =?UTF-8?q?=20reads=20started=20for=20the=20content=20it=20discards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file read for Place, for the place cursor or dropped on the canvas, a bitmap read for Paste, and a paste waiting at its Swatch Conflict dialog are no longer placed or pasted in a document that File › Revert replaced while they waited. Revert keeps the document's tab and uid, so these were placed in the reverted content with the selection, clipboard and insertion context of the content it discarded. - DocState::reverts counts the Reverts of a document; replace_document increments it, and a batch that fails puts it back with the document. - Reads, deferred swatch pastes and web canvas drops keep the count they started with and are dropped, with a status message, when it changed. - A paste given after a Revert starts a chain of its own instead of following a paste given before it. --- crates/engine/src/doc_state.rs | 6 + crates/engine/src/session/documents.rs | 2 + crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/place.rs | 11 +- crates/ui-egui/src/placing.rs | 47 +++++-- crates/ui-egui/src/placing/tests.rs | 166 ++++++++++++++++++++++++- crates/ui-egui/src/tests/place.rs | 4 +- docs/target-app-parity.md | 1 + 34 files changed, 303 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 0ef7555e02..10d21266ba 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -55,6 +55,11 @@ pub struct DocState { pub recalled_selection: RecalledSelection, /// Process-unique id of this open document (tab indices shift when tabs close). pub uid: u64, + /// How many times File → Revert replaced the document's content in its tab + /// ([`Session::replace_document`]). Work started for the document (a file read for Place, a + /// pasted bitmap) is dropped when this has changed by the time it finishes; edits do not + /// change it, and a batch that fails puts it back with the document. + pub reverts: u64, /// Preferences › User Interface › Open Documents As Tabs when the document opened: cleared, /// the UI opens it in a floating window of its own. pub opened_as_tab: bool, @@ -313,6 +318,7 @@ impl DocState { last_selection_cmd: None, recalled_selection: RecalledSelection::default(), uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + reverts: 0, opened_as_tab: true, mask_view: None, spelling_ignored: Default::default(), diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 0d472ef81d..8c919434fc 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -370,6 +370,8 @@ impl Session { st.history.limit = old.history.limit; // Same open document (caches keyed by uid stay valid); a new revision redraws it. st.uid = old.uid; + // Work started for the content being thrown away is dropped when it finishes. + st.reverts = old.reverts + 1; st.opened_as_tab = old.opened_as_tab; st.revision = old.revision + 1; st.format = old.format; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 708ffe9db6..29b9a7e5b3 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4128,6 +4128,9 @@ axis Both И двете @msg Couldn't open {name}: {e} Неуспешно отваряне на {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Неуспешно поставяне на {name}: документът, върху който беше пуснат, е затворен @msg Couldn't place {name}: the document it was placed in was closed Неуспешно поставяне на {name}: документът, в който се поставяше, е затворен +@msg Couldn't place {name}: the document it was dropped on was reverted Неуспешно поставяне на {name}: документът, върху който беше пуснат, е върнат към запазеното +@msg Couldn't place {name}: the document it was placed in was reverted Неуспешно поставяне на {name}: документът, в който се поставяше, е върнат към запазеното +@msg Couldn't paste: the document it was pasted in was reverted Неуспешно поставяне: документът, в който се поставяше, е върнат към запазеното @msg Couldn't read {name}: {e} Неуспешно четене на {name}: {e} @msg Couldn't save recovery data: {e} Неуспешно запазване на данните за възстановяване: {e} @msg Create Outlines: select text objects Създаване на контури: изберете текстови обекти diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 8f5ea73487..a4b007a28b 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4492,6 +4492,9 @@ imageTrace Outline Obris @msg Couldn't open {name}: {e} Nije moguće otvoriti {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Nije moguće postaviti {name}: dokument na koji je ispušten je zatvoren @msg Couldn't place {name}: the document it was placed in was closed Nije moguće postaviti {name}: dokument u koji je postavljen je zatvoren +@msg Couldn't place {name}: the document it was dropped on was reverted Nije moguće postaviti {name}: dokument na koji je ispušten je vraćen na sačuvano +@msg Couldn't place {name}: the document it was placed in was reverted Nije moguće postaviti {name}: dokument u koji je postavljen je vraćen na sačuvano +@msg Couldn't paste: the document it was pasted in was reverted Nije moguće zalijepiti: dokument u koji se lijepilo je vraćen na sačuvano @msg Couldn't read {name}: {e} Nije moguće pročitati {name}: {e} @msg Couldn't save recovery data: {e} Nije moguće sačuvati podatke za oporavak: {e} @msg Create Outlines: select text objects Kreiraj obrise: odaberite tekstualne objekte diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5516c9662f..871e750b7f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5521,6 +5521,9 @@ digits Malayalam Malaiàlam @msg Couldn't paste: the document it was pasted in was closed No s’ha pogut enganxar: el document on s’enganxava s’ha tancat @msg Couldn't place {name}: the document it was dropped on was closed No s’ha pogut col·locar {name}: el document on es va deixar anar estava tancat @msg Couldn't place {name}: the document it was placed in was closed No s’ha pogut col·locar {name}: el document on es va col·locar estava tancat +@msg Couldn't place {name}: the document it was dropped on was reverted No s’ha pogut col·locar {name}: el document on es va deixar anar s’ha revertit +@msg Couldn't place {name}: the document it was placed in was reverted No s’ha pogut col·locar {name}: el document on es va col·locar s’ha revertit +@msg Couldn't paste: the document it was pasted in was reverted No s’ha pogut enganxar: el document on s’enganxava s’ha revertit @msg Couldn't place {name}: {e} No s’ha pogut col·locar {name}: {e} @msg Couldn't read {name}: {e} No s’ha pogut llegir {name}: {e} @msg Couldn't save recovery data: {e} No s’han pogut desar les dades de recuperació: {e} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d03d192a3a..992b85b89d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4649,6 +4649,9 @@ imageTrace Outline Obrys @msg Couldn't open {name}: {e} {name} nelze otevřít: {e} @msg Couldn't place {name}: the document it was dropped on was closed {name} nelze umístit: dokument, na který byl přetažen, byl zavřen @msg Couldn't place {name}: the document it was placed in was closed {name} nelze umístit: dokument, do kterého se umísťoval, byl zavřen +@msg Couldn't place {name}: the document it was dropped on was reverted {name} nelze umístit: dokument, na který byl přetažen, byl vrácen k uložené verzi +@msg Couldn't place {name}: the document it was placed in was reverted {name} nelze umístit: dokument, do kterého se umísťoval, byl vrácen k uložené verzi +@msg Couldn't paste: the document it was pasted in was reverted Nelze vložit: dokument, do kterého se vkládalo, byl vrácen k uložené verzi @msg Couldn't read {name}: {e} {name} nelze přečíst: {e} @msg Couldn't save recovery data: {e} Data pro obnovení nelze uložit: {e} @msg Create Outlines: select text objects Převést na křivky: vyberte textové objekty diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00c85aaeb6..f33129dc75 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4246,6 +4246,9 @@ imageTrace Outline Kontur @msg Couldn't open {name}: {e} Kunne ikke åbne {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Kunne ikke placere {name}: dokumentet, den blev sluppet på, var lukket @msg Couldn't place {name}: the document it was placed in was closed Kunne ikke placere {name}: dokumentet, den blev placeret i, var lukket +@msg Couldn't place {name}: the document it was dropped on was reverted Kunne ikke placere {name}: dokumentet, den blev sluppet på, blev gendannet +@msg Couldn't place {name}: the document it was placed in was reverted Kunne ikke placere {name}: dokumentet, den blev placeret i, blev gendannet +@msg Couldn't paste: the document it was pasted in was reverted Kunne ikke indsætte: dokumentet, der blev indsat i, blev gendannet @msg Couldn't read {name}: {e} Kunne ikke læse {name}: {e} @msg Couldn't save recovery data: {e} Kunne ikke gemme gendannelsesdata: {e} @msg Create Outlines: select text objects Opret konturer: markér tekstobjekter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 47337855aa..e08d9a5963 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4262,6 +4262,9 @@ theme Appearance Erscheinungsbild @msg Couldn't open {name}: {e} {name} konnte nicht geöffnet werden: {e} @msg Couldn't place {name}: the document it was dropped on was closed {name} konnte nicht platziert werden: Das Dokument, auf das die Datei gezogen wurde, wurde geschlossen @msg Couldn't place {name}: the document it was placed in was closed {name} konnte nicht platziert werden: Das Dokument, in dem die Datei platziert wurde, wurde geschlossen +@msg Couldn't place {name}: the document it was dropped on was reverted {name} konnte nicht platziert werden: Das Dokument, auf das die Datei gezogen wurde, wurde auf die letzte Version zurückgesetzt +@msg Couldn't place {name}: the document it was placed in was reverted {name} konnte nicht platziert werden: Das Dokument, in dem die Datei platziert wurde, wurde auf die letzte Version zurückgesetzt +@msg Couldn't paste: the document it was pasted in was reverted Konnte nicht eingefügt werden: Das Dokument, in das eingefügt wurde, wurde auf die letzte Version zurückgesetzt @msg Couldn't read {name}: {e} {name} konnte nicht gelesen werden: {e} @msg Couldn't save recovery data: {e} Wiederherstellungsdaten konnten nicht gespeichert werden: {e} @msg Create Outlines: select text objects In Pfade umwandeln: Wählen Sie Textobjekte aus diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c319729a71..01d878a0ec 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2419,6 +2419,9 @@ axis Both Και τα δύο @msg Couldn't open {name}: {e} Δεν ήταν δυνατό το άνοιγμα του {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Δεν ήταν δυνατή η τοποθέτηση του {name}: το έγγραφο στο οποίο αποθέσατε το αρχείο έκλεισε @msg Couldn't place {name}: the document it was placed in was closed Δεν ήταν δυνατή η τοποθέτηση του {name}: το έγγραφο στο οποίο τοποθετούνταν το αρχείο έκλεισε +@msg Couldn't place {name}: the document it was dropped on was reverted Δεν ήταν δυνατή η τοποθέτηση του {name}: το έγγραφο στο οποίο αποθέσατε το αρχείο επανήλθε στην αποθηκευμένη έκδοση +@msg Couldn't place {name}: the document it was placed in was reverted Δεν ήταν δυνατή η τοποθέτηση του {name}: το έγγραφο στο οποίο τοποθετούνταν το αρχείο επανήλθε στην αποθηκευμένη έκδοση +@msg Couldn't paste: the document it was pasted in was reverted Δεν ήταν δυνατή η επικόλληση: το έγγραφο στο οποίο γινόταν η επικόλληση επανήλθε στην αποθηκευμένη έκδοση @msg Couldn't read {name}: {e} Δεν ήταν δυνατή η ανάγνωση του {name}: {e} @msg Couldn't save recovery data: {e} Δεν ήταν δυνατή η αποθήκευση δεδομένων ανάκτησης: {e} @msg Create Outlines: select text objects Δημιουργία περιγραμμάτων: επιλέξτε αντικείμενα κειμένου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5b203292a4..be7cd76be0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4339,6 +4339,9 @@ axis Both Ambos @msg Couldn't open {name}: {e} No se pudo abrir {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed No se pudo colocar {name}: se cerró el documento en el que se soltó @msg Couldn't place {name}: the document it was placed in was closed No se pudo colocar {name}: se cerró el documento en el que se estaba colocando +@msg Couldn't place {name}: the document it was dropped on was reverted No se pudo colocar {name}: se revirtió el documento en el que se soltó +@msg Couldn't place {name}: the document it was placed in was reverted No se pudo colocar {name}: se revirtió el documento en el que se estaba colocando +@msg Couldn't paste: the document it was pasted in was reverted No se pudo pegar: se revirtió el documento en el que se estaba pegando @msg Couldn't read {name}: {e} No se pudo leer {name}: {e} @msg Couldn't save recovery data: {e} No se pudieron guardar los datos de recuperación: {e} @msg Create Outlines: select text objects Crear contornos: selecciona objetos de texto diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b2c6f48d54..3a5a9aa1b8 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4563,6 +4563,9 @@ imageTrace Outline Ääriviiva @msg Couldn't open {name}: {e} Tiedostoa {name} ei voitu avata: {e} @msg Couldn't place {name}: the document it was dropped on was closed Kohdetta {name} ei voitu sijoittaa: asiakirja, johon se pudotettiin, on suljettu @msg Couldn't place {name}: the document it was placed in was closed Kohdetta {name} ei voitu sijoittaa: asiakirja, johon sitä sijoitettiin, on suljettu +@msg Couldn't place {name}: the document it was dropped on was reverted Kohdetta {name} ei voitu sijoittaa: asiakirja, johon se pudotettiin, on palautettu tallennettuun versioon +@msg Couldn't place {name}: the document it was placed in was reverted Kohdetta {name} ei voitu sijoittaa: asiakirja, johon sitä sijoitettiin, on palautettu tallennettuun versioon +@msg Couldn't paste: the document it was pasted in was reverted Liittäminen ei onnistunut: asiakirja, johon liitettiin, on palautettu tallennettuun versioon @msg Couldn't read {name}: {e} Tiedostoa {name} ei voitu lukea: {e} @msg Couldn't save recovery data: {e} Palautustietoja ei voitu tallentaa: {e} @msg Create Outlines: select text objects Muunna ääriviivoiksi: valitse tekstiobjekteja diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e84f344060..eeca595064 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4328,6 +4328,9 @@ axis Both Les deux @msg Couldn't open {name}: {e} Impossible d’ouvrir {name} : {e} @msg Couldn't place {name}: the document it was dropped on was closed Impossible d’importer {name} : le document sur lequel il a été déposé a été fermé @msg Couldn't place {name}: the document it was placed in was closed Impossible d’importer {name} : le document dans lequel il était importé a été fermé +@msg Couldn't place {name}: the document it was dropped on was reverted Impossible d’importer {name} : le document sur lequel il a été déposé a été rétabli à la version précédente +@msg Couldn't place {name}: the document it was placed in was reverted Impossible d’importer {name} : le document dans lequel il était importé a été rétabli à la version précédente +@msg Couldn't paste: the document it was pasted in was reverted Impossible de coller, car le document de destination a été rétabli à la version précédente @msg Couldn't read {name}: {e} Impossible de lire {name} : {e} @msg Couldn't save recovery data: {e} Impossible d’enregistrer les données de récupération : {e} @msg Create Outlines: select text objects Vectoriser : sélectionnez des objets texte diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 017bc03750..f01ad9df8c 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6638,6 +6638,9 @@ antiAlias Sharp शार्प @msg Couldn't place {name}: {e} नहीं रह सकता {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed नहीं था {name}: यह डॉक्यूमेंट बंद हो गया था @msg Couldn't place {name}: the document it was placed in was closed नहीं था {name}: यह डॉक्यूमेंट बंद किया गया था +@msg Couldn't place {name}: the document it was dropped on was reverted {name} प्लेस नहीं कर सका: जिस डॉक्यूमेंट पर इसे छोड़ा गया था, उसे मूल स्थिति में लाया गया +@msg Couldn't place {name}: the document it was placed in was reverted {name} प्लेस नहीं कर सका: जिस डॉक्यूमेंट में इसे प्लेस किया जा रहा था, उसे मूल स्थिति में लाया गया +@msg Couldn't paste: the document it was pasted in was reverted पेस्ट नहीं कर सका: जिस डॉक्यूमेंट में पेस्ट किया जा रहा था, उसे मूल स्थिति में लाया गया @msg Couldn't read {name}: {e} नहीं पढ़ सकता {name}: {e} @msg Couldn't save recovery data: {e} रिकवरी डेटा सेव नहीं किया जा सका: {e} @msg counterPer `{c}`: item or file counterPer `{c}`: item या file diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index effb31e5d8..e4d3d65f7a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5145,6 +5145,9 @@ imageTrace Outline Körvonal @msg Couldn't paste: the document it was pasted in was closed Nem sikerült beilleszteni: a dokumentumot, amelybe a beillesztés történt, bezárták @msg Couldn't place {name}: the document it was dropped on was closed Nem sikerült elhelyezni: {name}; a céldokumentumot bezárták @msg Couldn't place {name}: the document it was placed in was closed Nem sikerült elhelyezni: {name}; a dokumentumot, amelybe került volna, bezárták +@msg Couldn't place {name}: the document it was dropped on was reverted Nem sikerült elhelyezni: {name}; a céldokumentumot visszaállították a mentett változatra +@msg Couldn't place {name}: the document it was placed in was reverted Nem sikerült elhelyezni: {name}; a dokumentumot, amelybe került volna, visszaállították a mentett változatra +@msg Couldn't paste: the document it was pasted in was reverted Nem sikerült beilleszteni: a dokumentumot, amelybe a beillesztés történt, visszaállították a mentett változatra @msg Couldn't place {name}: {e} Nem sikerült elhelyezni: {name}: {e} @msg Couldn't read {name}: {e} Nem sikerült beolvasni: {name}: {e} @msg Couldn't save recovery data: {e} Nem sikerült menteni a helyreállítási adatokat: {e} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 912c6954eb..10870b436e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4246,6 +4246,9 @@ imageTrace Outline Kontur @msg Couldn't open {name}: {e} Tidak dapat membuka {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Tidak dapat menempatkan {name}: dokumen tempatnya dijatuhkan sudah ditutup @msg Couldn't place {name}: the document it was placed in was closed Tidak dapat menempatkan {name}: dokumen tempatnya ditempatkan sudah ditutup +@msg Couldn't place {name}: the document it was dropped on was reverted Tidak dapat menempatkan {name}: dokumen tempatnya dijatuhkan sudah dikembalikan +@msg Couldn't place {name}: the document it was placed in was reverted Tidak dapat menempatkan {name}: dokumen tempatnya ditempatkan sudah dikembalikan +@msg Couldn't paste: the document it was pasted in was reverted Tidak dapat menempel: dokumen tempatnya ditempel sudah dikembalikan @msg Couldn't read {name}: {e} Tidak dapat membaca {name}: {e} @msg Couldn't save recovery data: {e} Tidak dapat menyimpan data pemulihan: {e} @msg Create Outlines: select text objects Buat kontur: pilih objek teks diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2d6c7e2186..a8f251c184 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4391,6 +4391,9 @@ axis Both Entrambi @msg Couldn't open {name}: {e} Impossibile aprire {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Impossibile inserire {name}: il documento su cui è stato rilasciato è stato chiuso @msg Couldn't place {name}: the document it was placed in was closed Impossibile inserire {name}: il documento in cui veniva inserito è stato chiuso +@msg Couldn't place {name}: the document it was dropped on was reverted Impossibile inserire {name}: il documento su cui è stato rilasciato è stato ripristinato +@msg Couldn't place {name}: the document it was placed in was reverted Impossibile inserire {name}: il documento in cui veniva inserito è stato ripristinato +@msg Couldn't paste: the document it was pasted in was reverted Impossibile incollare: il documento in cui si incollava è stato ripristinato @msg Couldn't read {name}: {e} Impossibile leggere {name}: {e} @msg Couldn't save recovery data: {e} Impossibile salvare i dati di recupero: {e} @msg Create Outlines: select text objects Crea contorni: seleziona oggetti di testo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 17444d894e..2e8a5b175f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5179,6 +5179,9 @@ swatch Bright Violet ビビッドバイオレット @msg no user library folder here この環境にはユーザーライブラリフォルダーがありません @msg Couldn't place {name}: the document it was dropped on was closed {name} を配置できませんでした: ドロップ先のドキュメントは閉じられています @msg Couldn't place {name}: the document it was placed in was closed {name} を配置できませんでした: 配置先のドキュメントは閉じられています +@msg Couldn't place {name}: the document it was dropped on was reverted {name} を配置できませんでした: ドロップ先のドキュメントは保存済みの状態に戻されました +@msg Couldn't place {name}: the document it was placed in was reverted {name} を配置できませんでした: 配置先のドキュメントは保存済みの状態に戻されました +@msg Couldn't paste: the document it was pasted in was reverted 貼り付けられませんでした: 貼り付け先のドキュメントは保存済みの状態に戻されました @msg no files to place 配置するファイルがありません @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` は VectorCraft ドキュメント、PDF、PDF 互換の .ai ファイルのいずれでもありません @msg paths: give at least one file paths: ファイルを 1 つ以上指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index be8a847cb2..811918b6b2 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4045,6 +4045,9 @@ font-technology Variable ცვლადი @msg Couldn't open {name}: {e} {name} ვერ გაიხსნა: {e} @msg Couldn't place {name}: the document it was dropped on was closed {name} ვერ განთავსდა: დოკუმენტი, რომელზეც ის ჩამოაგდეს, დაიხურა @msg Couldn't place {name}: the document it was placed in was closed {name} ვერ განთავსდა: დოკუმენტი, რომელშიც ის თავსდებოდა, დაიხურა +@msg Couldn't place {name}: the document it was dropped on was reverted {name} ვერ განთავსდა: დოკუმენტი, რომელზეც ის ჩამოაგდეს, წინა ვერსიაზე დაბრუნდა +@msg Couldn't place {name}: the document it was placed in was reverted {name} ვერ განთავსდა: დოკუმენტი, რომელშიც ის თავსდებოდა, წინა ვერსიაზე დაბრუნდა +@msg Couldn't paste: the document it was pasted in was reverted ჩასმა ვერ მოხერხდა: დოკუმენტი, რომელშიც ის ჩაისმებოდა, წინა ვერსიაზე დაბრუნდა @msg Couldn't read {name}: {e} {name} ვერ წაიკითხა: {e} @msg Couldn't save recovery data: {e} აღდგენის მონაცემები ვერ შეინახა: {e} @msg Create Outlines: select text objects კონტურების შექმნა: მონიშნეთ ტექსტური ობიექტები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 991daba6d0..68d2e4ba7d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5106,6 +5106,9 @@ theme Appearance 외관 @msg no user library folder here 여기에는 사용자 라이브러리 폴더가 없습니다 @msg Couldn't place {name}: the document it was dropped on was closed {name}을(를) 가져올 수 없습니다: 놓은 문서가 닫혔습니다 @msg Couldn't place {name}: the document it was placed in was closed {name}을(를) 가져올 수 없습니다: 가져오던 문서가 닫혔습니다 +@msg Couldn't place {name}: the document it was dropped on was reverted {name}을(를) 가져올 수 없습니다: 놓은 문서가 되돌려졌습니다 +@msg Couldn't place {name}: the document it was placed in was reverted {name}을(를) 가져올 수 없습니다: 가져오던 문서가 되돌려졌습니다 +@msg Couldn't paste: the document it was pasted in was reverted 붙여넣을 수 없습니다: 붙여넣던 문서가 되돌려졌습니다 @msg no files to place 가져올 파일이 없습니다 @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}`은(는) VectorCraft 문서, PDF 또는 PDF 호환 .ai 파일이 아닙니다 @msg paths: give at least one file paths: 파일을 하나 이상 지정하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b8ca33858f..fede5d9289 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4761,6 +4761,9 @@ imageTrace Outline Contour @msg Couldn't open {name}: {e} Kon {name} niet openen: {e} @msg Couldn't place {name}: the document it was dropped on was closed Kon {name} niet plaatsen: het document waarop het werd neergezet, is gesloten @msg Couldn't place {name}: the document it was placed in was closed Kon {name} niet plaatsen: het document waarin het werd geplaatst, is gesloten +@msg Couldn't place {name}: the document it was dropped on was reverted Kon {name} niet plaatsen: het document waarop het werd neergezet, is teruggezet +@msg Couldn't place {name}: the document it was placed in was reverted Kon {name} niet plaatsen: het document waarin het werd geplaatst, is teruggezet +@msg Couldn't paste: the document it was pasted in was reverted Kon niet plakken: het document waarin werd geplakt, is teruggezet @msg Couldn't read {name}: {e} Kon {name} niet lezen: {e} @msg Couldn't save recovery data: {e} Kon herstelgegevens niet opslaan: {e} @msg Create Outlines: select text objects Contouren maken: selecteer tekstobjecten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index eb5afe87cc..861bedc750 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4473,6 +4473,9 @@ imageTrace Outline Kontur @msg Couldn't open {name}: {e} Nie udało się otworzyć {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Nie udało się umieścić {name}: dokument, na który go upuszczono, został zamknięty @msg Couldn't place {name}: the document it was placed in was closed Nie udało się umieścić {name}: dokument, w którym był umieszczany, został zamknięty +@msg Couldn't place {name}: the document it was dropped on was reverted Nie udało się umieścić {name}: dokument, na który go upuszczono, został przywrócony +@msg Couldn't place {name}: the document it was placed in was reverted Nie udało się umieścić {name}: dokument, w którym był umieszczany, został przywrócony +@msg Couldn't paste: the document it was pasted in was reverted Nie udało się wkleić: dokument, do którego wklejano, został przywrócony @msg Couldn't read {name}: {e} Nie udało się odczytać {name}: {e} @msg Couldn't save recovery data: {e} Nie udało się zapisać danych odzyskiwania: {e} @msg Create Outlines: select text objects Utwórz kontury: zaznacz obiekty tekstowe diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 80615d6370..0ffb916dc2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3808,6 +3808,9 @@ axis Both Ambos @msg Couldn't open {name}: {e} Não foi possível abrir {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Não foi possível inserir {name}: o documento em que foi solto foi fechado @msg Couldn't place {name}: the document it was placed in was closed Não foi possível inserir {name}: o documento em que estava sendo inserido foi fechado +@msg Couldn't place {name}: the document it was dropped on was reverted Não foi possível inserir {name}: o documento em que foi solto foi revertido +@msg Couldn't place {name}: the document it was placed in was reverted Não foi possível inserir {name}: o documento em que estava sendo inserido foi revertido +@msg Couldn't paste: the document it was pasted in was reverted Não foi possível colar: o documento em que estava sendo colado foi revertido @msg Couldn't read {name}: {e} Não foi possível ler {name}: {e} @msg Couldn't save recovery data: {e} Não foi possível salvar os dados de recuperação: {e} @msg Create Outlines: select text objects Criar contornos: selecione objetos de texto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c830e69b37..fdba4f5160 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4376,6 +4376,9 @@ axis Both Оба @msg Couldn't open {name}: {e} Не удалось открыть {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Не удалось поместить {name}: документ, в который его перетащили, был закрыт @msg Couldn't place {name}: the document it was placed in was closed Не удалось поместить {name}: документ, в который его помещали, был закрыт +@msg Couldn't place {name}: the document it was dropped on was reverted Не удалось поместить {name}: документ, в который его перетащили, был возвращён к сохранённой версии +@msg Couldn't place {name}: the document it was placed in was reverted Не удалось поместить {name}: документ, в который его помещали, был возвращён к сохранённой версии +@msg Couldn't paste: the document it was pasted in was reverted Не удалось вставить: документ, в который выполнялась вставка, был возвращён к сохранённой версии @msg Couldn't read {name}: {e} Не удалось прочитать {name}: {e} @msg Couldn't save recovery data: {e} Не удалось сохранить данные восстановления: {e} @msg Create Outlines: select text objects Создание контуров: выделите текстовые объекты diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d4304b97bd..5ed1992034 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4249,6 +4249,9 @@ axis Both Båda @msg Couldn't open {name}: {e} Kunde inte öppna {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Kunde inte placera {name}: dokumentet det släpptes på har stängts @msg Couldn't place {name}: the document it was placed in was closed Kunde inte placera {name}: dokumentet det placerades i har stängts +@msg Couldn't place {name}: the document it was dropped on was reverted Kunde inte placera {name}: dokumentet det släpptes på har återgått till sparad version +@msg Couldn't place {name}: the document it was placed in was reverted Kunde inte placera {name}: dokumentet det placerades i har återgått till sparad version +@msg Couldn't paste: the document it was pasted in was reverted Kunde inte klistra in: dokumentet det klistrades in i har återgått till sparad version @msg Couldn't read {name}: {e} Kunde inte läsa {name}: {e} @msg Couldn't save recovery data: {e} Kunde inte spara återställningsdata: {e} @msg Create Outlines: select text objects Skapa konturer: markera textobjekt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f5fc694ec2..385a462b95 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4129,6 +4129,9 @@ axis Both Her İkisi @msg Couldn't open {name}: {e} {name} açılamadı: {e} @msg Couldn't place {name}: the document it was dropped on was closed {name} yerleştirilemedi: bırakıldığı belge kapatıldı @msg Couldn't place {name}: the document it was placed in was closed {name} yerleştirilemedi: yerleştirildiği belge kapatıldı +@msg Couldn't place {name}: the document it was dropped on was reverted {name} yerleştirilemedi: bırakıldığı belge geri döndürüldü +@msg Couldn't place {name}: the document it was placed in was reverted {name} yerleştirilemedi: yerleştirildiği belge geri döndürüldü +@msg Couldn't paste: the document it was pasted in was reverted Yapıştırılamadı: yapıştırıldığı belge geri döndürüldü @msg Couldn't read {name}: {e} {name} okunamadı: {e} @msg Couldn't save recovery data: {e} Kurtarma verileri kaydedilemedi: {e} @msg Create Outlines: select text objects Anahatlar Oluştur: metin nesnelerini seçin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5a36512db0..fe21de311d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4375,6 +4375,9 @@ axis Both Обидві @msg Couldn't open {name}: {e} Не вдалося відкрити {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Не вдалося помістити {name}: документ, у який його перетягли, було закрито @msg Couldn't place {name}: the document it was placed in was closed Не вдалося помістити {name}: документ, у який його поміщали, було закрито +@msg Couldn't place {name}: the document it was dropped on was reverted Не вдалося помістити {name}: документ, у який його перетягли, було повернуто до збереженої версії +@msg Couldn't place {name}: the document it was placed in was reverted Не вдалося помістити {name}: документ, у який його поміщали, було повернуто до збереженої версії +@msg Couldn't paste: the document it was pasted in was reverted Не вдалося вставити: документ, у який виконувалося вставлення, було повернуто до збереженої версії @msg Couldn't read {name}: {e} Неможливо прочитати {name}: {e} @msg Couldn't save recovery data: {e} Не вдалося зберегти дані відновлення: {e} @msg Create Outlines: select text objects Створення контурів: виділіть текстові об’єкти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 839f5c7405..cc40048fa0 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4803,6 +4803,9 @@ theme Appearance Giao diện @msg Couldn't paste: the document it was pasted in was closed Không thể dán: tài liệu mà nó được dán vào đã đóng @msg Couldn't place {name}: the document it was dropped on was closed Không thể đặt {name}: tài liệu mà nó được thả vào đã đóng @msg Couldn't place {name}: the document it was placed in was closed Không thể đặt {name}: tài liệu mà nó được đặt vào đã đóng +@msg Couldn't place {name}: the document it was dropped on was reverted Không thể đặt {name}: tài liệu mà nó được thả vào đã được hoàn về +@msg Couldn't place {name}: the document it was placed in was reverted Không thể đặt {name}: tài liệu mà nó được đặt vào đã được hoàn về +@msg Couldn't paste: the document it was pasted in was reverted Không thể dán: tài liệu mà nó được dán vào đã được hoàn về @msg Couldn't place {name}: {e} Không thể đặt {name}: {e} @msg Couldn't read {name}: {e} Không thể đọc {name}: {e} @msg Couldn't save recovery data: {e} Không thể lưu dữ liệu phục hồi: {e} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b0d6001e32..40ebab407e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4862,6 +4862,9 @@ imageTrace Outline 轮廓 @msg no user library folder here 此处没有用户库文件夹 @msg Couldn't place {name}: the document it was dropped on was closed 无法置入 {name}:将它拖放到的文档已关闭 @msg Couldn't place {name}: the document it was placed in was closed 无法置入 {name}:要置入的文档已关闭 +@msg Couldn't place {name}: the document it was dropped on was reverted 无法置入 {name}:将它拖放到的文档已恢复 +@msg Couldn't place {name}: the document it was placed in was reverted 无法置入 {name}:要置入的文档已恢复 +@msg Couldn't paste: the document it was pasted in was reverted 无法粘贴:要粘贴到的文档已恢复 @msg no files to place 没有要置入的文件 @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` 不是 VectorCraft 文档、PDF 或 PDF 兼容的 .ai 文件 @msg paths: give at least one file paths:请至少给出一个文件 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c24ae37609..83bfd8d7b2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2611,6 +2611,9 @@ file-filter Templates 範本 @msg no user library folder here 此處沒有使用者資料庫資料夾 @msg Couldn't place {name}: the document it was dropped on was closed 無法置入 {name}:放置它的文件已關閉 @msg Couldn't place {name}: the document it was placed in was closed 無法置入 {name}:要置入的文件已關閉 +@msg Couldn't place {name}: the document it was dropped on was reverted 無法置入 {name}:放置它的文件已回復 +@msg Couldn't place {name}: the document it was placed in was reverted 無法置入 {name}:要置入的文件已回復 +@msg Couldn't paste: the document it was pasted in was reverted 無法貼上:要貼上的文件已回復 @msg no files to place 沒有要置入的檔案 @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` 不是 VectorCraft 文件、PDF 或 PDF 相容的 .ai 檔案 @msg paths: give at least one file paths:請提供至少一個檔案 diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 94ae695ca4..fff3587182 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -33,6 +33,9 @@ pub type PlaceInbox = Arc>>; #[derive(Clone, Copy, Debug, PartialEq)] pub struct DropAt { pub doc: u64, + /// The document's [`vectorcraft_engine::DocState::reverts`] at the drop: files that arrive + /// after File → Revert threw that content away are not placed. + pub reverts: u64, pub at: Point, pub embed: bool, } @@ -455,7 +458,7 @@ impl VectorcraftApp { None if is_document(name) => return DropTarget::Open, None => v.center, }; - DropTarget::Place(DropAt { doc: st.uid, at, embed: shift }) + DropTarget::Place(DropAt { doc: st.uid, reverts: st.reverts, at, embed: shift }) } } @@ -473,7 +476,7 @@ pub type DropFile = (String, Option, Vec); /// Files dropped on the window, each placed on a canvas or opened (and added to Open Recent /// Files) as its target says: the placed ones first, so the last document opened is the one /// shown. A file for a canvas goes to the document it was dropped on, which becomes active again; -/// it isn't placed when that document has closed since. +/// it isn't placed when that document has closed or been reverted since. pub fn drop_files(app: &mut VectorcraftApp, mut files: Vec<(DropTarget, DropFile)>) { files.sort_by_key(|(target, _)| *target == DropTarget::Open); for (target, (name, path, bytes)) in files { @@ -482,6 +485,10 @@ pub fn drop_files(app: &mut VectorcraftApp, mut files: Vec<(DropTarget, DropFile app.status(format!("Couldn't place {name}: the document it was dropped on was closed")); continue; }; + if app.session.documents().get(i).is_some_and(|st| st.reverts != d.reverts) { + app.status(format!("Couldn't place {name}: the document it was dropped on was reverted")); + continue; + } app.session.set_active(i); } let r = match target { diff --git a/crates/ui-egui/src/placing.rs b/crates/ui-egui/src/placing.rs index 3eacb28322..11ae770d5d 100644 --- a/crates/ui-egui/src/placing.rs +++ b/crates/ui-egui/src/placing.rs @@ -111,6 +111,9 @@ struct Chain { left: Option>, /// How many of its pastes wait. waiting: usize, + /// The document's [`vectorcraft_engine::DocState::reverts`] when the chain started: a paste + /// given after a File → Revert does not join it. + reverts: u64, } /// What a paste whose bitmap was decoded off the UI thread pastes. @@ -129,6 +132,9 @@ pub struct Pending { pub label: String, /// The document they are placed in ([`vectorcraft_engine::DocState::uid`]). doc: u64, + /// Its [`vectorcraft_engine::DocState::reverts`] when the read started: a read for content that + /// File → Revert has since thrown away is dropped. + reverts: u64, /// Whether the file was dropped on the canvas (its messages say so, as /// [`crate::place::drop_files`]'s do). dropped: bool, @@ -177,6 +183,8 @@ struct PendingSwatch { insertion: Insertion, selection: Vec, doc: u64, + /// The document's [`vectorcraft_engine::DocState::reverts`] when the dialog opened. + reverts: u64, chain: u64, } @@ -254,14 +262,19 @@ pub fn adopt_for_paste(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result< return Ok(None); } let copied = app.clipboard_publishing.latest(); - let (doc, objects) = app.session.doc().map(|st| (st.uid, st.selection.objects.clone())).map_err(|e| e.to_string())?; + let (doc, reverts, objects) = app.session.doc().map(|st| (st.uid, st.reverts, st.selection.objects.clone())).map_err(|e| e.to_string())?; let insertion = Insertion::of(app).ok_or("no document")?; // A paste given while one of the document's latest chain waits, with the selection and the // insertion context that chain expects, joins it: it goes next to what the paste before it - // leaves selected, as at once. Otherwise it starts a chain. + // leaves selected, as at once. Otherwise (also when the document was reverted since the chain + // started) it starts a chain. let context = (objects, insertion.clone()); - let joins = - app.placing.latest_chain.get(&doc).copied().filter(|c| app.placing.chains.get(c).is_some_and(|ch| ch.waiting > 0 && ch.context == context)); + let joins = app + .placing + .latest_chain + .get(&doc) + .copied() + .filter(|c| app.placing.chains.get(c).is_some_and(|ch| ch.waiting > 0 && ch.reverts == reverts && ch.context == context)); let (chain, selection) = match joins { Some(chain) => (chain, None), None => (app.placing.next_chain, Some(context.0.clone())), @@ -285,7 +298,7 @@ pub fn adopt_for_paste(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result< if started.is_some() { if joins.is_none() { app.placing.next_chain += 1; - app.placing.chains.insert(chain, Chain { context, left: None, waiting: 0 }); + app.placing.chains.insert(chain, Chain { context, left: None, waiting: 0, reverts }); app.placing.latest_chain.insert(doc, chain); } if let Some(ch) = app.placing.chains.get_mut(&chain) { @@ -321,7 +334,7 @@ fn spawn_labeled( chain: Option, read: impl FnOnce() -> Result + Send + 'static, ) -> Result, String> { - let doc = app.session.doc().map_err(|e| e.to_string())?.uid; + let (doc, reverts) = app.session.doc().map(|st| (st.uid, st.reverts)).map_err(|e| e.to_string())?; let (tx, done) = channel(); // The automation roots in force here are in force there. let scope = ScopeHandle::capture(); @@ -360,7 +373,7 @@ fn spawn_labeled( let _ = reader.send(job); app.placing.reader = Some(reader); } - app.placing.pending.push(Pending { name, label: label.clone(), doc, dropped, paste: chain.is_some(), chain, done, scope: completion }); + app.placing.pending.push(Pending { name, label: label.clone(), doc, reverts, dropped, paste: chain.is_some(), chain, done, scope: completion }); let status = format!("{label}…"); app.status(status.clone()); Ok(Some(json!({ "background": true, "status": status }))) @@ -416,6 +429,19 @@ fn finish(app: &mut VectorcraftApp, p: &Pending, r: Result) { } return; }; + // File → Revert threw away the content the read was started for: what was read is dropped, + // with the document, its selection, the clipboard and the tools as they are. + if app.session.documents().get(i).is_some_and(|st| st.reverts != p.reverts) { + let name = &p.name; + if p.paste { + app.status("Couldn't paste: the document it was pasted in was reverted"); + } else if p.dropped { + app.status(format!("Couldn't place {name}: the document it was dropped on was reverted")); + } else { + app.status(format!("Couldn't place {name}: the document it was placed in was reverted")); + } + return; + } if p.paste { let error = match &r { Ok(Read::Paste(paste)) => match &paste.pasted { @@ -508,6 +534,7 @@ fn finish_paste(app: &mut VectorcraftApp, paste: Paste) { insertion: paste.insertion.clone(), selection: requested_selection, doc: st.uid, + reverts: st.reverts, chain, }); deferred = true; @@ -579,6 +606,12 @@ pub(crate) fn confirm_swatch_paste(app: &mut VectorcraftApp, id: &str, params: V finish_chain(app, waiting.chain); return Err("Couldn't paste: the document it was pasted in was closed".into()); }; + // File → Revert threw away the content the paste was given in: nothing is pasted, and the + // clipboard, selection and insertion context stay as they are. + if app.session.documents().get(index).is_some_and(|st| st.reverts != waiting.reverts) { + finish_chain(app, waiting.chain); + return Err("Couldn't paste: the document it was pasted in was reverted".into()); + } app.session.set_active(index); app.sync_views(); let current = std::mem::replace(&mut app.session.clipboard, waiting.clipboard); diff --git a/crates/ui-egui/src/placing/tests.rs b/crates/ui-egui/src/placing/tests.rs index 9afe94b9ee..dbb37ff446 100644 --- a/crates/ui-egui/src/placing/tests.rs +++ b/crates/ui-egui/src/placing/tests.rs @@ -971,7 +971,7 @@ fn what_the_user_places_is_read_off_the_ui_thread_and_commands_read_at_once() { app.select_tool("selection"); // A drop and bytes placed without a path. let doc = app.session.doc().unwrap().uid; - let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); + let drop = DropTarget::Place(DropAt { doc, reverts: 0, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); crate::place::drop_files(&mut app, vec![(drop, ("b.bmp".into(), Some(b.clone()), vec![]))]); crate::io::place_bytes(&mut app, "c.png", &picture(Png, 10, 10)).unwrap(); assert_eq!(app.placing.pending.len(), 2); @@ -1044,7 +1044,7 @@ fn files_dropped_together_are_placed_in_the_order_dropped() { let small = [saved("order", "small1.png", &picture(Png, 8, 8)), saved("order", "small2.png", &picture(Png, 8, 8))]; let mut app = app(true); let doc = app.session.doc().unwrap().uid; - let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(200.0, 150.0), embed: false }); + let drop = DropTarget::Place(DropAt { doc, reverts: 0, at: vectorcraft_geom::Point::new(200.0, 150.0), embed: false }); let files = [&large, &small[0], &small[1]].map(|p| (drop, (vectorcraft_engine::cmd::fileio::file_name(p), Some(p.clone()), vec![]))); crate::place::drop_files(&mut app, files.into()); assert_eq!(app.placing.pending.len(), 3); @@ -1059,7 +1059,7 @@ fn files_dropped_together_are_read_one_at_a_time() { let files: Vec = (0..4).map(|i| saved("one-at-a-time", &format!("large{i}.tif"), &picture(Tiff, 1500, 1500))).collect(); let mut app = app(true); let doc = app.session.doc().unwrap().uid; - let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(200.0, 150.0), embed: false }); + let drop = DropTarget::Place(DropAt { doc, reverts: 0, at: vectorcraft_geom::Point::new(200.0, 150.0), embed: false }); let dropped = files.iter().map(|p| (drop, (vectorcraft_engine::cmd::fileio::file_name(p), Some(p.clone()), vec![]))).collect(); crate::place::drop_files(&mut app, dropped); placed(&mut app); @@ -1123,7 +1123,7 @@ fn a_file_that_cant_be_read_off_the_ui_thread_reports_as_at_once() { let name = vectorcraft_engine::cmd::fileio::file_name(&path); for app in [&mut at_once, &mut app] { let doc = app.session.doc().unwrap().uid; - let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); + let drop = DropTarget::Place(DropAt { doc, reverts: 0, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); crate::place::drop_files(app, vec![(drop, (name.clone(), Some(path.clone()), vec![]))]); placed(app); } @@ -1152,6 +1152,164 @@ fn a_file_whose_document_closed_meanwhile_is_not_placed() { } } +/// `app`, which also writes files, with its active document saved to a file in a folder of +/// `test`'s own and then changed (a rectangle drawn since the save) → the app and the file. +fn saved_and_changed(mut app: VectorcraftApp, test: &str) -> (VectorcraftApp, String) { + app.services.write = Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))); + let path = saved(test, "saved.vectorcraft", b""); + std::fs::remove_file(&path).unwrap(); + app.run("file.saveAs", json!({"path": path})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + (app, path) +} + +/// A file read for Place, for the place cursor or dropped on the canvas is not placed when File › +/// Revert threw away the content of its document before it was read: the reverted document, its +/// undo steps and the tool stay as they are, and the status bar says why. A file read after the +/// Revert is placed. +#[test] +fn a_file_whose_document_was_reverted_meanwhile_is_not_placed() { + let tif = saved("reverted", "photo.tif", &picture(Tiff, 60, 40)); + for kind in ["place", "drop", "queue"] { + let (mut app, _) = saved_and_changed(app(true), &format!("reverted-{kind}")); + let read = |app: &mut VectorcraftApp| match kind { + "queue" => start_queue(app, &json!({"paths": [tif]})), + _ => start(app, &json!({"path": tif}), kind == "drop"), + }; + read(&mut app).unwrap(); + app.run("file.revert", json!({"confirmed": true})).unwrap(); + let reverted = state(&app); + placed(&mut app); + let why = if kind == "drop" { "dropped on" } else { "placed in" }; + assert_eq!(app.ui.status, format!("Couldn't place photo.tif: the document it was {why} was reverted"), "{kind}"); + assert!(state(&app) == reverted, "{kind}: nothing is placed"); + assert_ne!(app.session.tool_id(), "place", "{kind}: the place cursor is not loaded"); + read(&mut app).unwrap(); + placed(&mut app); + if kind == "queue" { + assert_eq!((app.session.tool_id(), app.session.tool_options()["count"].as_u64()), ("place", Some(1))); + } else { + assert_eq!(names(&app), ["photo.tif"], "{kind}: a file read after the Revert is placed"); + } + } +} + +/// A Revert that fails, or that a batch rolls back, leaves a file being read for its document to +/// be placed, and a Revert of one document leaves the files read for another to be placed. +#[test] +fn a_file_is_placed_after_a_revert_that_failed_or_of_another_document() { + let tif = saved("revert-failed", "photo.tif", &picture(Tiff, 60, 40)); + let (mut first, path) = saved_and_changed(app(true), "revert-failed"); + start(&mut first, &json!({"path": tif}), false).unwrap(); + let revert = json!({"command": "file.revert", "params": {"confirmed": true}}); + let failing = json!({"command": "select.set", "params": {"ids": [999999]}}); + assert!(first.run("command.batch", json!({"commands": [revert, failing]})).is_err(), "the batch is rolled back"); + std::fs::remove_file(&path).unwrap(); + assert!(first.run("file.revert", json!({"confirmed": true})).is_err(), "the saved file is gone"); + placed(&mut first); + assert_eq!(names(&first), ["photo.tif"]); + let (mut second, _) = saved_and_changed(app(true), "revert-other"); + second.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + start(&mut second, &json!({"path": tif}), false).unwrap(); + second.session.set_active(0); + second.sync_views(); + second.run("file.revert", json!({"confirmed": true})).unwrap(); + placed(&mut second); + assert_eq!(second.session.active_index(), Some(1)); + assert_eq!(names(&second), ["photo.tif"], "the other document's file is placed"); +} + +/// Files dropped on a canvas that arrive after File › Revert threw away the content of their +/// document (the web reads dropped files asynchronously) are not placed. +#[test] +fn files_dropped_on_a_document_reverted_before_they_arrive_are_not_placed() { + let tif = saved("revert-drop", "photo.tif", &picture(Tiff, 60, 40)); + let (mut app, _) = saved_and_changed(app(true), "revert-drop"); + let (doc, reverts) = app.session.doc().map(|st| (st.uid, st.reverts)).unwrap(); + let drop = DropTarget::Place(DropAt { doc, reverts, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); + app.run("file.revert", json!({"confirmed": true})).unwrap(); + let reverted = state(&app); + crate::place::drop_files(&mut app, vec![(drop, ("photo.tif".into(), Some(tif), vec![]))]); + placed(&mut app); + assert_eq!(app.ui.status, "Couldn't place photo.tif: the document it was dropped on was reverted"); + assert!(state(&app) == reverted, "nothing is placed"); +} + +/// Pastes whose bitmaps are read off the UI thread paste nothing when File › Revert threw away the +/// content of their document before they were read: the clipboard and the selection stay as they +/// are, and the status bar says why. A Paste in Front given after the Revert, while a paste given +/// before it waits, does not follow that paste: it goes in front of what was selected when it was +/// given. +#[test] +fn a_paste_whose_document_was_reverted_meanwhile_pastes_nothing() { + let bitmap = Some(Flavour { mime: PNG, data: picture(Png, 60, 40) }); + let (pasting, _) = pasting_app(true, bitmap, None, true); + let (mut app, _) = saved_and_changed(pasting, "revert-paste"); + app.run("select.set", json!({"ids": []})).unwrap(); + crate::menus::invoke(&mut app, "edit.paste", json!({})); + crate::menus::invoke(&mut app, "edit.paste", json!({})); + assert_eq!(app.placing.pending.len(), 2); + app.run("file.revert", json!({"confirmed": true})).unwrap(); + let (reverted, clipboard) = (state(&app), app.session.clipboard.clone()); + placed(&mut app); + assert_eq!(app.ui.status, "Couldn't paste: the document it was pasted in was reverted"); + assert!(state(&app) == reverted, "nothing is pasted"); + assert!(app.session.clipboard == clipboard, "the clipboard is unchanged"); + assert!(app.placing.chains.is_empty() && app.placing.latest_chain.is_empty(), "no paste waits"); + // A paste given before a Revert, and one given after it with the same (empty) selection. + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + app.run("select.set", json!({"ids": []})).unwrap(); + crate::menus::invoke(&mut app, "edit.pasteInFront", json!({})); + app.run("file.revert", json!({"confirmed": true})).unwrap(); + crate::menus::invoke(&mut app, "edit.pasteInFront", json!({})); + assert_eq!(app.placing.pending.len(), 2); + let below = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("shape.rectangle", json!({"x": 200, "y": 10, "width": 20, "height": 20})).unwrap(); + app.run("select.set", json!({"ids": [below]})).unwrap(); + placed(&mut app); + let st = app.session.doc().unwrap(); + let order: Vec<_> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(order.len(), 3, "one picture is pasted"); + assert_eq!(st.selection.objects, [order[2]], "pasted on top, with nothing selected as when it was given"); + assert!(matches!(st.doc.node(order[2]).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Image(_)))); +} + +/// A paste that waits for its own Swatch Conflict dialog pastes nothing once File › Revert (on its +/// own or in a batch) threw away the content of its document: confirming the dialog gives an +/// error, and the document and the clipboard stay as they are. +#[test] +fn a_deferred_swatch_paste_is_dropped_when_its_document_is_reverted() { + for batch in [false, true] { + let (mut app, _) = pasting_app(true, None, None, true); + app.run("swatch.new", json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + let source = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [source], "swatch": "Brand"})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + let clipboard = app.session.clipboard.clone(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("swatch.new", json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap(); + let (mut app, _) = saved_and_changed(app, &format!("revert-swatch-{batch}")); + crate::menus::invoke(&mut app, "edit.pasteInPlace", json!({})); + placed(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, crate::dialogs::swatch_conflict::KIND); + let revert = json!({"command": "file.revert", "params": {"confirmed": true}}); + if batch { + app.run("command.batch", json!({"commands": [revert]})).unwrap(); + } else { + app.run("file.revert", json!({"confirmed": true})).unwrap(); + } + let reverted = state(&app); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, crate::dialogs::swatch_conflict::KIND, "batch {batch}"); + app.ui.dialog.as_mut().unwrap().fields.insert("choice".into(), json!("add")); + let error = crate::dialogs::confirm(&mut app).unwrap_err(); + assert_eq!(error, "Couldn't paste: the document it was pasted in was reverted"); + assert!(app.ui.dialog.is_none() && app.placing.swatch_paste.is_none()); + assert!(app.placing.chains.is_empty() && app.placing.latest_chain.is_empty()); + assert!(app.session.clipboard == clipboard); + assert!(state(&app) == reverted, "batch {batch}: nothing is pasted"); + } +} + /// Replace swaps the object selected when Place started; when that object was deleted before the /// file was read, nothing is placed and the status bar says why. #[test] diff --git a/crates/ui-egui/src/tests/place.rs b/crates/ui-egui/src/tests/place.rs index 9c67861eeb..19c39879af 100644 --- a/crates/ui-egui/src/tests/place.rs +++ b/crates/ui-egui/src/tests/place.rs @@ -111,7 +111,7 @@ fn a_drop_with_a_position_places_on_the_canvas_and_opens_off_it() { let want = Xf::new(rect, app.view().unwrap()).to_doc(pos); let uid = app.session.active().unwrap().uid; for name in ["a.png", "a.svg", "a.ai", "a.vectorcraft"] { - assert_eq!(app.drop_target(name, Some(pos), true), DropTarget::Place(DropAt { doc: uid, at: want, embed: true }), "{name}"); + assert_eq!(app.drop_target(name, Some(pos), true), DropTarget::Place(DropAt { doc: uid, reverts: 0, at: want, embed: true }), "{name}"); assert_eq!(app.drop_target(name, Some(Pos2::new(300.0, rect.top() - 10.0)), false), DropTarget::Open, "{name} on the tab bar"); } } @@ -129,7 +129,7 @@ fn a_drop_without_a_position_opens_documents_and_places_pictures() { assert_eq!(app.drop_target(name, None, false), DropTarget::Open, "{name}"); } for name in ["a.png", "a.jpg", "a.tiff", "a.webp", "a.txt"] { - assert_eq!(app.drop_target(name, None, false), DropTarget::Place(DropAt { doc: uid, at: center, embed: false }), "{name}"); + assert_eq!(app.drop_target(name, None, false), DropTarget::Place(DropAt { doc: uid, reverts: 0, at: center, embed: false }), "{name}"); } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..c4c9997532 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1668,6 +1668,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Placing a Word 97-2003 `.doc` document works out each style's formatting once and each property run's once, and refuses property pages that are out of order, listed twice or overlap, a style sheet that holds fewer styles than it counts, styles based on each other in a loop, and more than 100,000 runs of character or of paragraph properties, 16 MiB of properties or 100,000 paragraphs, hidden ones included (`file.place`, `crates/engine/src/tests/textimport.rs`). - On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before. - On the desktop, Paste from a menu or shortcut captures another app's bitmap or reads a copied image file on the UI thread, then decodes the image on the file-reading worker. The status bar shows "Pasting" until the image is pasted. Windows device-independent bitmaps and Linux clipboard images are encoded as PNG on that worker. A Copy or Cut made while the image is read keeps its internal clipboard. `edit.paste*` over MCP and scripts reads the bitmap at once. + - File › Revert also drops what was started for the content it discards: a file being read for Place, for the place cursor or dropped on the canvas, a bitmap being read for Paste, and a paste waiting at its Swatch Conflict dialog are not placed or pasted in the reverted document, and the status bar says the document was reverted. Edits made while a file is read, a Revert that fails or that a batch rolls back, and a Revert of another document leave the read to finish as before (`crates/ui-egui/src/placing/tests.rs`). - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, 8 bits each, as a CMYK TIFF. - SVG import shows a raster `` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). From f5de590dee6c6b444502c27c1ed090a8325b1700 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:32:19 -0800 Subject: [PATCH 3/9] SVG Interactivity: replies name JavaScript files outside the read roots by file name With automation read roots in force, svgInteractivity.info and the files reply of addFile and removeFile returned every linked JavaScript file as stored, including paths outside the roots. They now name such a file by its file name alone, as document.json does; a file inside the roots and a URL are named as they are, and the document keeps every path. removeFile also unlinks a file outside the roots by that file name. When the name fits more than one linked file, it returns an error that names no stored path and unlinks nothing. addFile still treats only the same text as already linked. In a frame a request drives, the JavaScript Files dialog lists such a file by its file name while its selection keeps the file as linked, and ui.inspect gives the selected file the same way. The person's frames show full paths. --- crates/engine/src/cmd/interactivity.rs | 43 +++++-- crates/engine/src/cmd/links.rs | 16 ++- crates/engine/src/tests/interactivity.rs | 119 ++++++++++++++++++ crates/ui-egui/src/control.rs | 16 ++- .../ui-egui/src/dialogs/javascript_files.rs | 65 +++++++++- crates/ui-egui/src/tests/automation.rs | 24 ++++ docs/mcp.md | 11 +- docs/target-app-parity.md | 1 + 8 files changed, 273 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/interactivity.rs b/crates/engine/src/cmd/interactivity.rs index 4bcc123dcf..359c2d00d6 100644 --- a/crates/engine/src/cmd/interactivity.rs +++ b/crates/engine/src/cmd/interactivity.rs @@ -3,10 +3,14 @@ //! document links (`svgInteractivity.addFile`, `removeFile`). SVG export writes the events as //! event attributes (`onclick="…"`) and the files as `