mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Merge main into fix/blob-brush-preview-bounded
This commit is contained in:
@@ -194,6 +194,27 @@ impl ImageBlob {
|
||||
.collect();
|
||||
Some((w, h, cover))
|
||||
}
|
||||
|
||||
/// The pixels as straight RGBA, row by row from the top, with the width and the height;
|
||||
/// `None` when the image can't be decoded within [`MAX_RASTER_ALLOC`].
|
||||
pub fn rgba(&self) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
|
||||
let (w, h) = img.dimensions();
|
||||
Some((w, h, img.into_raw()))
|
||||
}
|
||||
|
||||
/// The width and height in pixels the image's file says it has; `None` for data that isn't an
|
||||
/// image the decoder reads. Reads only its header.
|
||||
pub fn pixel_size(&self) -> Option<(u32, u32)> {
|
||||
image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()
|
||||
}
|
||||
|
||||
/// Whether the image's file is a PNG that stores palette indices (an indexed-color image).
|
||||
/// Reads only its header.
|
||||
pub fn indexed_png(&self) -> bool {
|
||||
// The signature, then the IHDR chunk: length, type, width, height, bit depth, color type.
|
||||
self.bytes.starts_with(b"\x89PNG\r\n\x1a\n") && self.bytes.get(12..16) == Some(b"IHDR".as_slice()) && self.bytes.get(25) == Some(&3)
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
//! shared colour visitor [`Recolor`]: fills and strokes (solid colours, gradient stops and text
|
||||
//! runs), gradient meshes, embedded images and pattern fills. Colours linked to a global swatch are
|
||||
//! unlinked when they change; Adjust Color Balance's Global mode shifts their tints instead.
|
||||
//!
|
||||
//! Convert to Indexed Color uses the same visitor for embedded images only, and stores each one as
|
||||
//! a palette PNG.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -15,7 +19,9 @@ use vectorcraft_doc::live::lerp_color;
|
||||
use vectorcraft_doc::pattern::PatternDef;
|
||||
pub(crate) use vectorcraft_doc::recolor::map_paint;
|
||||
use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node};
|
||||
use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Document, ImageBlob, MAX_RASTER_ALLOC, Node, NodeId, NodeKind};
|
||||
use vectorcraft_render::encode::png::{PngOptions, encode_indexed};
|
||||
use vectorcraft_render::encode::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_to};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::{
|
||||
@@ -66,6 +72,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.toIndexed",
|
||||
"Convert to Indexed Color…",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{reduction?: \"selective\" (default)|\"perceptual\"|\"adaptive\"|\"web\"|\"gray\"|\"blackWhite\" (chosen as for a PNG-8 export)|\"uniform\" (evenly spaced levels of red, green and blue: the most levels, 2 to 6, whose cube fits in `colors` with one entry left for transparency)|\"document\" (the colors of the document's swatches, at most 255), colors?: 2..256 (default 256; the most palette entries, the transparent one included; at least 9 for uniform; refused for blackWhite and document), dither?: \"diffusion\" (default)|\"pattern\"|\"noise\"|\"none\", ditherAmount?: 0..100 (default 100), ids?} store each embedded image in the selection (in groups too) as an indexed-color PNG: a palette of at most 256 RGB colors, each pixel the index of one; pixels under half opacity become one transparent entry and the others opaque, keeping their color; linked images, colorized images and the images of color glyphs in outlined type keep their pixels; each image changed becomes a new image, one undo step for all; an image that is an indexed PNG already and would show the same pixels is left as it is, and no undo step is recorded when no image changes; an image of more than 134,217,728 pixels (33,554,432 in the web app) is refused with an error and nothing changes; reduction and dither ids are read as for a PNG-8 export, ignoring case → {changed: images changed, notChanged: the locked and hidden objects Select All left out that hold an image that is not an indexed PNG yet, warning?} (as edit.colors.invert)",
|
||||
has_selection,
|
||||
to_indexed
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.saturate",
|
||||
"Saturate…",
|
||||
@@ -149,6 +164,13 @@ fn gray_level(c: Color) -> f32 {
|
||||
/// gradient stop): `(colour, link, tint)`, true when it changed them.
|
||||
pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
|
||||
|
||||
/// What a pass that remakes whole images makes of one: its new copy, `None` when it stays as it is.
|
||||
type Remake<'a> = dyn Fn(&ImageBlob) -> Option<ImageBlob> + 'a;
|
||||
|
||||
/// The objects a pass recolors, and the locked and hidden objects the selection left out, each
|
||||
/// with whether it is hidden ([`pass_targets`]).
|
||||
type Targets = (Vec<NodeId>, Vec<(NodeId, bool)>);
|
||||
|
||||
/// The identity colour map (a pass that only maps linked colours).
|
||||
fn same(c: Color) -> Color {
|
||||
c
|
||||
@@ -191,6 +213,8 @@ pub(crate) struct Recolor<'a> {
|
||||
scope: Scope,
|
||||
/// Recolored images are stored with one gray channel ([`ImageBlob::map_gray`]).
|
||||
gray_images: bool,
|
||||
/// Makes each embedded image's new copy in place of `f` (`None`: it stays as it is).
|
||||
remake: Option<&'a Remake<'a>>,
|
||||
/// Image key → the key of its recoloured copy (`None`: unchanged).
|
||||
images: HashMap<String, Option<String>>,
|
||||
/// Pattern name → the name of its recoloured copy (`None`: unchanged, or being recoloured).
|
||||
@@ -207,6 +231,7 @@ impl<'a> Recolor<'a> {
|
||||
links: None,
|
||||
scope,
|
||||
gray_images: false,
|
||||
remake: None,
|
||||
images: HashMap::new(),
|
||||
patterns: HashMap::new(),
|
||||
new_images: vec![],
|
||||
@@ -229,7 +254,7 @@ impl<'a> Recolor<'a> {
|
||||
|
||||
/// A pass that maps colors as this one does, with nothing recolored yet.
|
||||
fn again(&self) -> Self {
|
||||
Self { links: self.links, gray_images: self.gray_images, ..Self::new(self.scope, self.f) }
|
||||
Self { links: self.links, gray_images: self.gray_images, remake: self.remake, ..Self::new(self.scope, self.f) }
|
||||
}
|
||||
|
||||
/// The entries of `objects` (ids, each with a flag) whose object this pass would change, other
|
||||
@@ -328,7 +353,9 @@ impl<'a> Recolor<'a> {
|
||||
}
|
||||
let f = self.f;
|
||||
let blob = d.images.get(key)?;
|
||||
let new = if self.gray_images {
|
||||
let new = if let Some(remake) = self.remake {
|
||||
remake(blob)
|
||||
} else if self.gray_images {
|
||||
blob.map_gray(|[r, g, b]| f(Color::rgb8(r, g, b)).to_rgba8(1.0)[0])
|
||||
} else {
|
||||
blob.map_rgb(|[r, g, b]| {
|
||||
@@ -534,6 +561,187 @@ impl NotChanged {
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects a pass recolors (the `ids` param's, default: the selection's) and the locked and
|
||||
/// hidden objects the selection left out ([`left_out`]), each with whether it is hidden.
|
||||
fn pass_targets(s: &Session, p: &Value, cmd: &str) -> Result<Targets> {
|
||||
Ok(match ids_param(p, "ids", cmd)? {
|
||||
Some(v) => (v, vec![]),
|
||||
None => {
|
||||
let roots = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let left = left_out(&st.doc, st.isolation, &roots);
|
||||
(roots, left)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Edit › Edit Colors › Convert to Indexed Color: the embedded images of the objects stored as
|
||||
/// indexed-color PNGs ([`Indexing`]). The pass runs on a copy of the document, so no undo step is
|
||||
/// recorded when no image changes.
|
||||
fn to_indexed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.colors.toIndexed";
|
||||
let indexing = Indexing::of(s, p)?;
|
||||
// A typing session ends here as `s.edit` would end it, before the copy is taken, so that the
|
||||
// copy holds what was typed.
|
||||
if !s.previewing && super::typing_in_progress(s) {
|
||||
super::settle_interaction(s)?;
|
||||
}
|
||||
let remake = |b: &ImageBlob| indexing.convert(b);
|
||||
let images = Scope { fill: false, stroke: false, images: true, patterns: false };
|
||||
let pass = Recolor { remake: Some(&remake), ..Recolor::new(images, &same) };
|
||||
// A left-out image counts as one the pass would change when it is an image that is not an
|
||||
// indexed PNG yet; it is not converted to find out. The empty PNG only gives it a new key.
|
||||
let would = |b: &ImageBlob| (!b.indexed_png() && b.pixel_size().is_some()).then(|| ImageBlob::png(vec![]));
|
||||
let (ids, left) = pass_targets(s, p, C)?;
|
||||
let st = s.doc()?;
|
||||
let colorized_ok = super::paint::check_colorized(&st.doc).is_ok();
|
||||
let not_changed = NotChanged::of(
|
||||
Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(&st.doc, &left).into_iter().map(|(_, hidden)| hidden),
|
||||
);
|
||||
let mut doc = (*st.doc).clone();
|
||||
let n = pass.run(&mut doc, &ids);
|
||||
if let Some((w, h)) = indexing.too_large.get() {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS)));
|
||||
}
|
||||
if n > 0 {
|
||||
s.edit("Convert to Indexed Color", |d, _| {
|
||||
*d = doc;
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))
|
||||
})?;
|
||||
}
|
||||
let mut out = json!({ "changed": n });
|
||||
not_changed.report(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How Convert to Indexed Color reduces an image's colors: the palette options of a PNG-8
|
||||
/// export, with transparency kept and no matte, and the fixed palette of the uniform and document
|
||||
/// reductions.
|
||||
struct Indexing {
|
||||
options: PaletteOptions,
|
||||
fixed: Option<Vec<[u8; 3]>>,
|
||||
/// The size of an image [`Self::convert`] left as it is because it has more than
|
||||
/// [`Self::MAX_PIXELS`] pixels.
|
||||
too_large: Cell<Option<(u32, u32)>>,
|
||||
}
|
||||
|
||||
impl Indexing {
|
||||
/// The most pixels an image may have. Converting one holds at most about 8 bytes a pixel at
|
||||
/// once: decoding (the decoded samples and the RGBA), quantizing (the RGBA, the colors and the
|
||||
/// indices) and writing the PNG after the RGBA is freed (the indices, the packed and filtered
|
||||
/// rows and the compressed data). 16 bytes a pixel keeps that within [`MAX_RASTER_ALLOC`] twice
|
||||
/// over.
|
||||
const MAX_PIXELS: u64 = MAX_RASTER_ALLOC / 16;
|
||||
|
||||
/// The reduction `p` asks for (see `edit.colors.toIndexed`); a value out of range or of the
|
||||
/// wrong type is an error. Reduction and dither ids are read as a PNG-8 export reads them.
|
||||
fn of(s: &Session, p: &Value) -> Result<Self> {
|
||||
const C: &str = "edit.colors.toIndexed";
|
||||
let reduction = text_param(p, "reduction", C)?.unwrap_or("selective");
|
||||
let colors = match p.get("colors") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
Some(v.as_u64().filter(|n| (2..=256).contains(n)).ok_or_else(|| bad(C, "`colors` must be a whole number from 2 to 256"))? as u16)
|
||||
}
|
||||
};
|
||||
let dither = match text_param(p, "dither", C)? {
|
||||
None => Dither::Diffusion,
|
||||
Some(d) => Dither::from_id(d).ok_or_else(|| bad(C, format!("unknown dither `{d}` (diffusion, pattern, noise or none)")))?,
|
||||
};
|
||||
let dither_amount = match p.get("ditherAmount") {
|
||||
None | Some(Value::Null) => 100,
|
||||
Some(v) => {
|
||||
v.as_f64().filter(|x| (0.0..=100.0).contains(x)).ok_or_else(|| bad(C, "`ditherAmount` must be a number from 0 to 100"))?.round() as u8
|
||||
}
|
||||
};
|
||||
let options =
|
||||
|r: Reduction| PaletteOptions { colors: colors.unwrap_or(256), reduction: r, dither, dither_amount, transparency: true, matte: None };
|
||||
let refuse_colors = |name: &str| match colors {
|
||||
Some(_) => Err(bad(C, format!("`colors` does not apply to the {name} reduction"))),
|
||||
None => Ok(()),
|
||||
};
|
||||
let fixed = |palette: Vec<[u8; 3]>| Self { options: options(Reduction::Adaptive), fixed: Some(palette), too_large: Cell::new(None) };
|
||||
Ok(match reduction.to_ascii_lowercase().as_str() {
|
||||
"uniform" => {
|
||||
let n = colors.unwrap_or(256);
|
||||
// One entry is kept for transparent pixels.
|
||||
let levels =
|
||||
(2..=6u16).rev().find(|l| l.pow(3) < n).ok_or_else(|| bad(C, "`colors` must be at least 9 for the uniform reduction"))?;
|
||||
let level = |i: u16| (u32::from(i) * 255 / u32::from(levels - 1)) as u8;
|
||||
let mut palette = Vec::with_capacity(usize::from(levels.pow(3)));
|
||||
for r in 0..levels {
|
||||
for g in 0..levels {
|
||||
for b in 0..levels {
|
||||
palette.push([level(r), level(g), level(b)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
fixed(palette)
|
||||
}
|
||||
"document" => {
|
||||
refuse_colors("document")?;
|
||||
let mut palette: Vec<[u8; 3]> = vec![];
|
||||
for c in s.doc()?.doc.swatches_iter().filter_map(|sw| sw.paint.color()) {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
if !palette.contains(&[r, g, b]) {
|
||||
palette.push([r, g, b]);
|
||||
}
|
||||
}
|
||||
if palette.is_empty() {
|
||||
return Err(bad(C, "the document has no color swatches"));
|
||||
}
|
||||
if palette.len() > 255 {
|
||||
return Err(bad(C, format!("the document's swatches hold {} colors; the document reduction takes at most 255", palette.len())));
|
||||
}
|
||||
fixed(palette)
|
||||
}
|
||||
id => {
|
||||
let r = Reduction::from_id(id).ok_or_else(|| {
|
||||
bad(C, format!("unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document)"))
|
||||
})?;
|
||||
if r == Reduction::BlackWhite {
|
||||
refuse_colors("blackWhite")?;
|
||||
}
|
||||
Self { options: options(r), fixed: None, too_large: Cell::new(None) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Image `b` as an indexed-color PNG; `None` when it can't be decoded, when it has more than
|
||||
/// [`Self::MAX_PIXELS`] pixels (noted in `too_large`), or when it is an indexed PNG already and
|
||||
/// every pixel would show as it does.
|
||||
fn convert(&self, b: &ImageBlob) -> Option<ImageBlob> {
|
||||
let (w, h) = b.pixel_size()?;
|
||||
if u64::from(w) * u64::from(h) > Self::MAX_PIXELS {
|
||||
self.too_large.set(Some((w, h)));
|
||||
return None;
|
||||
}
|
||||
let (w, h, rgba) = b.rgba()?;
|
||||
let ix = match &self.fixed {
|
||||
Some(palette) => quantize_to(&rgba, w, h, &self.options, palette),
|
||||
None => quantize(&rgba, w, h, &self.options),
|
||||
};
|
||||
if b.indexed_png() && shows_same(&ix, &rgba) {
|
||||
return None;
|
||||
}
|
||||
// The PNG writer's buffers take the place of the pixels.
|
||||
drop(rgba);
|
||||
Some(ImageBlob::png(encode_indexed(&ix, &PngOptions::default()).ok()?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether palette image `ix` shows every pixel of straight RGBA `rgba` as it is: transparent
|
||||
/// where they are transparent, and the same color where they are opaque.
|
||||
fn shows_same(ix: &Indexed, rgba: &[u8]) -> bool {
|
||||
let px = rgba.as_chunks::<4>().0;
|
||||
px.len() == ix.indices.len()
|
||||
&& px.iter().zip(&ix.indices).all(|(p, &i)| match (p[3], ix.transparent == Some(i)) {
|
||||
(0, clear) => clear,
|
||||
(255, false) => ix.palette.get(usize::from(i)).is_some_and(|c| c[..] == p[..3]),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn saturate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32;
|
||||
recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| {
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Info",
|
||||
["Window", "Document Info"],
|
||||
None,
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone",
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -425,6 +425,64 @@ fn applied_brushes(d: &Document, n: &Node, members: &HashSet<*const Node>) -> Ve
|
||||
if follows { used } else { vec![] }
|
||||
}
|
||||
|
||||
/// Whether `n` draws, as far as its own state goes: a hidden object, and an object at an opacity
|
||||
/// of 0, paint no color, and neither do their members.
|
||||
fn draws(n: &Node) -> bool {
|
||||
n.visible && n.opacity > 0.0
|
||||
}
|
||||
|
||||
/// The object of `d` at index path `path`, with whether the groups and layers around it draw
|
||||
/// ([`draws`]).
|
||||
fn node_and_drawn<'a>(d: &'a Document, path: &[usize]) -> Option<(&'a Node, bool)> {
|
||||
let (first, rest) = path.split_first()?;
|
||||
let mut at: &Node = d.layers.get(*first)?;
|
||||
let mut drawn = true;
|
||||
for &i in rest {
|
||||
drawn &= draws(at);
|
||||
at = &**at.children()?.get(i)?;
|
||||
}
|
||||
Some((at, drawn))
|
||||
}
|
||||
|
||||
/// The spot colors, the patterns and the gradients (`spots`: [`spot_swatches`]) that object `n`
|
||||
/// paints, each once: those of its own fills and strokes ([`own_paints`], `members` included), of
|
||||
/// its drawn characters and, for a grayscale or 1-bit image, of the color it is colorized with.
|
||||
fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec<String>; 3] {
|
||||
let (spot, pattern, gradient) = (|p: &Paint| spot_labels(spots, p), one_label(pattern_label), one_label(gradient_label));
|
||||
let labels: [&PaintNames<'_>; 3] = [&spot, &pattern, &gradient];
|
||||
let painted_runs = painted_runs(d, spots, n);
|
||||
let mut used = labels.map(|label| own_paints(d, n, members, painted_runs.as_ref(), label));
|
||||
// The characters' own paints: an inline graphic draws no outline and does not take its run's
|
||||
// paint (a list marker set in its run does), and a stroke with no width paints nothing.
|
||||
if let Some((t, painted)) = &painted_runs {
|
||||
for run in painted.iter().filter_map(|i| t.runs.get(*i)) {
|
||||
let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke);
|
||||
for paint in std::iter::once(&run.style.fill).chain(stroke) {
|
||||
for (used, label) in used.iter_mut().zip(labels) {
|
||||
for name in label(paint) {
|
||||
if !used.contains(&name) {
|
||||
used.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A grayscale or 1-bit image colorized with a spot color prints it, while its pixels take the
|
||||
// color, as the renderer draws it.
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& let Some(colorize) = n.extras().colorize.as_deref()
|
||||
&& d.image_blob(im).is_some_and(|b| b.takes_colorize())
|
||||
{
|
||||
for name in spot(colorize) {
|
||||
if !used[0].contains(&name) {
|
||||
used[0].insert(0, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
used
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.info";
|
||||
let category = str_param(p, "category");
|
||||
@@ -440,8 +498,13 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let selection_only = bool_or(p, "selectionOnly", false);
|
||||
let roots: Vec<&Node> =
|
||||
if selection_only { st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() } else { d.layers.iter().map(|l| &**l).collect() };
|
||||
// Each root with whether the groups and layers around it draw: a selected object inside a hidden group or layer, or one
|
||||
// at an opacity of 0, paints nothing.
|
||||
let roots: Vec<(&Node, bool)> = if selection_only {
|
||||
st.selection.objects.iter().filter_map(|id| node_and_drawn(d, &d.index_path(*id)?)).collect()
|
||||
} else {
|
||||
d.layers.iter().map(|l| (&**l, true)).collect()
|
||||
};
|
||||
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
// The paths' total length, as Document Info › Objects shows it (the length of a cut line, a
|
||||
// strip of LEDs along the letters, #1958).
|
||||
@@ -467,7 +530,6 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut spot_objects: Vec<Value> = vec![];
|
||||
let mut spot_rows: Vec<(String, String)> = vec![];
|
||||
let mut spot = BTreeSet::new();
|
||||
let (spot_label, pattern, gradient) = (|p: &Paint| spot_labels(&spot_swatches, p), one_label(pattern_label), one_label(gradient_label));
|
||||
let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() };
|
||||
// `library` holds the Brushes panel's brushes, and `brushes` the brushes the visited objects' strokes draw with, each once,
|
||||
// in walk order.
|
||||
@@ -494,10 +556,18 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
members.extend(children.iter().map(|c| std::ptr::from_ref(&**c)));
|
||||
}
|
||||
});
|
||||
for r in roots {
|
||||
r.walk(&mut |n| {
|
||||
// Depth first, in paint order, as `Node::walk` visits: each object with whether it and the groups and layers around it
|
||||
// draw. An object that does not draw is still counted, and its members are visited.
|
||||
let mut stack: Vec<(&Node, bool)> = vec![];
|
||||
for (r, drawn) in roots {
|
||||
stack.push((r, drawn));
|
||||
while let Some((n, around)) = stack.pop() {
|
||||
if !seen.insert(std::ptr::from_ref(n)) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
let drawn = around && draws(n);
|
||||
if let Some(ch) = n.children() {
|
||||
stack.extend(ch.iter().rev().map(|c| (&**c, drawn)));
|
||||
}
|
||||
let k = match &n.kind {
|
||||
// The Guides row counts paths made into guides; ruler guides are not objects.
|
||||
@@ -545,42 +615,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(k) = k {
|
||||
*counts.entry(k).or_default() += 1;
|
||||
}
|
||||
// The spot colors, the patterns and the gradients this object's fills and strokes paint, each once.
|
||||
let painted_runs = painted_runs(d, &spot_swatches, n);
|
||||
let mut used_spots = own_paints(d, n, &members, painted_runs.as_ref(), &spot_label);
|
||||
let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), &pattern);
|
||||
let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), &gradient);
|
||||
// The characters' own paints: an inline graphic draws no outline and does not take its
|
||||
// run's paint (a list marker set in its run does), and a stroke with no width paints
|
||||
// nothing.
|
||||
if let Some((t, painted)) = &painted_runs {
|
||||
for run in painted.iter().filter_map(|i| t.runs.get(*i)) {
|
||||
let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke);
|
||||
for paint in std::iter::once(&run.style.fill).chain(stroke) {
|
||||
let labels: [(&mut Vec<String>, &PaintNames<'_>); 3] =
|
||||
[(&mut used_spots, &spot_label), (&mut used_patterns, &pattern), (&mut used_gradients, &gradient)];
|
||||
for (used, label) in labels {
|
||||
for name in label(paint) {
|
||||
if !used.contains(&name) {
|
||||
used.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A grayscale or 1-bit image colorized with a spot color prints it, while its pixels take
|
||||
// the color, as the renderer draws it.
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& let Some(colorize) = n.extras().colorize.as_deref()
|
||||
&& d.image_blob(im).is_some_and(|b| b.takes_colorize())
|
||||
{
|
||||
for name in spot_label(colorize) {
|
||||
if !used_spots.contains(&name) {
|
||||
used_spots.insert(0, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The spot colors, the patterns and the gradients this object paints, each once; none when it does not draw.
|
||||
let [used_spots, used_patterns, used_gradients] = if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() };
|
||||
// Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list.
|
||||
if !used_spots.is_empty() {
|
||||
let name = n.display_name();
|
||||
@@ -615,9 +651,11 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
styles.push(&g.name);
|
||||
}
|
||||
for b in applied_brushes(d, n, &members) {
|
||||
if !brushes.contains(&b) {
|
||||
brushes.push(b);
|
||||
if drawn {
|
||||
for b in applied_brushes(d, n, &members) {
|
||||
if !brushes.contains(&b) {
|
||||
brushes.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let NodeKind::Repeat(spec) = &n.kind {
|
||||
@@ -625,7 +663,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
repeats.push(entry);
|
||||
repeat_rows.push(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// The document's fonts include those of the type in opacity masks and in the art of its
|
||||
// symbols and patterns, as Find Font lists them.
|
||||
@@ -1455,6 +1493,115 @@ mod tests {
|
||||
assert_eq!(spots(&mut s), (vec![], None, None));
|
||||
}
|
||||
|
||||
/// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object,
|
||||
/// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of
|
||||
/// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no
|
||||
/// color. Objects still counts them.
|
||||
#[test]
|
||||
fn info_skips_paints_on_hidden_and_clear_objects_and_their_members() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": {"model": "cmyk", "c": 0, "m": 1, "y": 1, "k": 0}, "spot": true})).unwrap();
|
||||
let square = |s: &mut Session, x: f64| {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let tile = square(&mut s, 0.0);
|
||||
s.execute("object.pattern.make", &json!({"ids": [tile], "name": "Dots", "edit": false})).unwrap();
|
||||
// A patterned square that stays drawn, an object at an opacity of 0 and a hidden one.
|
||||
let (shown, clear, hidden) = (square(&mut s, 20.0), square(&mut s, 40.0), square(&mut s, 60.0));
|
||||
s.execute("paint.setFill", &json!({"ids": [shown, clear], "swatch": "Dots"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [hidden], "swatch": "Ink"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [shown, clear, hidden], "none": true})).unwrap();
|
||||
// A brushed square in a group.
|
||||
let (brushed, other) = (square(&mut s, 80.0), square(&mut s, 100.0));
|
||||
s.execute("brush.apply", &json!({"ids": [brushed], "name": "Charcoal"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [brushed, other]})).unwrap();
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
// Type whose characters are filled with the spot color.
|
||||
let word = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [word], "swatch": "Ink"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [word], "none": true})).unwrap();
|
||||
// A grayscale image colorized with the spot color.
|
||||
let mut bytes = Vec::new();
|
||||
image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap())
|
||||
.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let image = s
|
||||
.edit("Place", |d, sel| {
|
||||
d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(bytes));
|
||||
let id = d.alloc_id();
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "gray".into(),
|
||||
width: 2,
|
||||
height: 1,
|
||||
xf: Default::default(),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
|
||||
// A square with a gradient on a layer of its own.
|
||||
let layer = s.execute("layer.new", &json!({"name": "Back"})).unwrap()["id"].as_u64().unwrap();
|
||||
let ramp = square(&mut s, 120.0);
|
||||
s.execute(
|
||||
"paint.setFill",
|
||||
&json!({"ids": [ramp], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [ramp], "none": true})).unwrap();
|
||||
let listed = |s: &mut Session, p: Value| {
|
||||
let i = s.execute("document.info", &p).unwrap();
|
||||
let ids = |k: &str| -> Vec<u64> {
|
||||
let mut ids: Vec<u64> = i[k].as_array().unwrap().iter().map(|o| o["id"].as_u64().unwrap()).collect();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
};
|
||||
(i["brushNames"].clone(), ids("spotObjects"), ids("patternObjects"), ids("gradientObjects"), i["objects"].clone())
|
||||
};
|
||||
// Everything drawn: each object is listed.
|
||||
let (brushes, spots, patterns, gradients, before) = listed(&mut s, json!({}));
|
||||
assert_eq!(brushes, json!(["Charcoal"]));
|
||||
// New squares take the new spot color as their fill, the group's members included.
|
||||
assert_eq!((spots, patterns, gradients), (vec![hidden, brushed, other, word, image.0], vec![shown, clear], vec![ramp]));
|
||||
s.edit("hide", |d, _| {
|
||||
d.node_mut(NodeId(clear)).unwrap().opacity = 0.0;
|
||||
d.node_mut(NodeId(hidden)).unwrap().visible = false;
|
||||
d.node_mut(NodeId(group)).unwrap().opacity = 0.0;
|
||||
d.node_mut(NodeId(word)).unwrap().opacity = 0.0;
|
||||
d.node_mut(image).unwrap().opacity = 0.0;
|
||||
d.node_mut(NodeId(layer)).unwrap().visible = false;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (brushes, spots, patterns, gradients, after) = listed(&mut s, json!({}));
|
||||
assert_eq!((brushes, spots, patterns, gradients), (json!([]), vec![], vec![shown], vec![]));
|
||||
// Objects counts the listed objects, and every object as before.
|
||||
assert_eq!((after["spots"].clone(), after["patterns"].clone(), after["gradients"].clone()), (Value::Null, json!(1), Value::Null));
|
||||
for k in ["paths", "groups", "textObjects", "images"] {
|
||||
assert_eq!(after[k], before[k], "{k}: {after}");
|
||||
}
|
||||
let rows = s.execute("document.info", &json!({"category": "brushes"})).unwrap()["sections"][0]["rows"].clone();
|
||||
assert_eq!(rows, json!([]));
|
||||
// Selection Only: an object selected inside a group at an opacity of 0 or a hidden layer paints nothing either.
|
||||
s.execute("select.set", &json!({"ids": [brushed, ramp]})).unwrap();
|
||||
let (brushes, _, _, gradients, after) = listed(&mut s, json!({"selectionOnly": true}));
|
||||
assert_eq!((brushes, gradients, after["paths"].clone()), (json!([]), vec![], json!(2)));
|
||||
// Drawn again, the group's brush and the layer's gradient are listed.
|
||||
s.edit("show", |d, _| {
|
||||
d.node_mut(NodeId(group)).unwrap().opacity = 1.0;
|
||||
d.node_mut(NodeId(layer)).unwrap().visible = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (brushes, _, _, gradients, _) = listed(&mut s, json!({"selectionOnly": true}));
|
||||
assert_eq!((brushes, gradients), (json!(["Charcoal"]), vec![ramp]));
|
||||
}
|
||||
|
||||
/// Document Info › Objects counts an object with a gradient or a pattern on both its fill and
|
||||
/// its stroke once, and with Selection Only a selection that holds a group and an object inside
|
||||
/// it counts that object once.
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
//! document links (`svgInteractivity.addFile`, `removeFile`). SVG export writes the events as
|
||||
//! event attributes (`onclick="…"`) and the files as `<script xlink:href>` elements; SVG import
|
||||
//! reads both back as text. The panel reads everything with `svgInteractivity.info`.
|
||||
//!
|
||||
//! While read roots are in force, a reply names a linked file outside them by its file name alone
|
||||
//! ([`links::shown_script`]), and `removeFile` also takes that name for it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, MAX_SVG_SCRIPTS, NodeId, SVG_EVENTS};
|
||||
use vectorcraft_doc::{Document, LinkInfo, MAX_SVG_SCRIPTS, NodeId, SVG_EVENTS};
|
||||
|
||||
use super::links::{self, LinkBase};
|
||||
use super::overprint::edit_counted;
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets};
|
||||
|
||||
@@ -17,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"SVG Interactivity",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} → {ids, events: [{event, script}…] in the panel's order | null (the objects' events differ), files: [the linked JavaScript files…], eventNames: [the events the panel lists…]} the SVG Interactivity panel's values for ids or the selection",
|
||||
"{ids?|id?} → {ids, events: [{event, script}…] in the panel's order | null (the objects' events differ), files: [the linked JavaScript files…; with automation read roots in force, a file outside them by its file name alone, a URL as it is], eventNames: [the events the panel lists…]} the SVG Interactivity panel's values for ids or the selection",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -53,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add JavaScript File",
|
||||
[],
|
||||
None,
|
||||
"{file: path or URL (written as given, without surrounding spaces)} link a JavaScript file to the document, after the others: SVG export writes a <script xlink:href> for each linked file. A file already linked stays where it is; a document links at most 64 files → {files}",
|
||||
"{file: path or URL (written as given, without surrounding spaces)} link a JavaScript file to the document, after the others: SVG export writes a <script xlink:href> for each linked file. A file already linked (the same text) stays where it is; a document links at most 64 files → {files} (as svgInteractivity.info gives them)",
|
||||
has_doc,
|
||||
add_file
|
||||
),
|
||||
@@ -62,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove JavaScript File",
|
||||
[],
|
||||
None,
|
||||
"{file} unlink a JavaScript file from the document → {files}",
|
||||
"{file} unlink a JavaScript file from the document: the file linked as `file` or, with automation read roots in force, a file outside them whose file name is `file`; an error, unlinking nothing, when `file` fits more than one → {files} (as svgInteractivity.info gives them)",
|
||||
has_doc,
|
||||
remove_file
|
||||
),
|
||||
@@ -111,7 +115,8 @@ fn edit_events(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl Fn(&m
|
||||
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let lists: Vec<Vec<(String, String)>> = ids.iter().map(|id| events_of(d, *id)).collect();
|
||||
let events = lists
|
||||
.first()
|
||||
@@ -120,7 +125,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({
|
||||
"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||||
"events": events,
|
||||
"files": d.svg_scripts,
|
||||
"files": shown_files(st),
|
||||
"eventNames": SVG_EVENTS,
|
||||
}))
|
||||
}
|
||||
@@ -157,8 +162,14 @@ fn file_param(p: &Value, cmd: &str) -> Result<String> {
|
||||
f.map(str::to_string).ok_or_else(|| bad(cmd, "give `file`, the JavaScript file's path or URL"))
|
||||
}
|
||||
|
||||
/// The JavaScript files `st` links, in order, as a reply to the caller asking now may name them
|
||||
/// ([`links::shown_script`]).
|
||||
fn shown_files(st: &crate::DocState) -> Vec<String> {
|
||||
st.doc.svg_scripts.iter().map(|f| links::shown_script(st, f).into_owned()).collect()
|
||||
}
|
||||
|
||||
fn files(s: &Session) -> Result<Value> {
|
||||
Ok(json!({ "files": s.doc()?.doc.svg_scripts }))
|
||||
Ok(json!({ "files": shown_files(s.doc()?) }))
|
||||
}
|
||||
|
||||
fn add_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -180,11 +191,21 @@ fn add_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn remove_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.removeFile";
|
||||
let file = file_param(p, C)?;
|
||||
if !s.doc()?.doc.svg_scripts.contains(&file) {
|
||||
return Err(bad(C, format!("{file:?} isn't linked")));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let linked = &st.doc.svg_scripts;
|
||||
// A file a reply names by its file name alone is also unlinked by that name, unless the name
|
||||
// also fits another linked file.
|
||||
let doc_path = LinkBase::of(st).doc;
|
||||
let named: Vec<&String> =
|
||||
linked.iter().filter(|f| **f != file && LinkInfo::new(f.as_str()).name() == file && links::script_hidden(f, doc_path)).collect();
|
||||
let stored = match (linked.contains(&file), named.as_slice()) {
|
||||
(true, []) => file,
|
||||
(false, [f]) => (*f).clone(),
|
||||
(false, []) => return Err(bad(C, format!("{file:?} isn't linked"))),
|
||||
_ => return Err(bad(C, format!("{file:?} names more than one linked JavaScript file"))),
|
||||
};
|
||||
s.edit("Remove JavaScript File", |d, _| {
|
||||
d.svg_scripts.retain(|f| *f != file);
|
||||
d.svg_scripts.retain(|f| *f != stored);
|
||||
Ok(())
|
||||
})?;
|
||||
files(s)
|
||||
|
||||
@@ -1173,6 +1173,19 @@ pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
|
||||
crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok())
|
||||
}
|
||||
|
||||
/// Is the linked JavaScript file `f` (of the document saved at `doc_path`) named by its file name
|
||||
/// alone in a reply to the caller asking now? Only a path [`path_shown`] does not allow; a URL is
|
||||
/// always shown as it is.
|
||||
pub(crate) fn script_hidden(f: &str, doc_path: Option<&str>) -> bool {
|
||||
!path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url))
|
||||
}
|
||||
|
||||
/// The linked JavaScript file `f` of the open document `st` as a reply to the caller asking now
|
||||
/// may name it: as it is, or by its file name alone where [`script_hidden`] says so.
|
||||
pub fn shown_script<'a>(st: &crate::DocState, f: &'a str) -> Cow<'a, str> {
|
||||
if script_hidden(f, LinkBase::of(st).doc) { Cow::Owned(LinkInfo::new(f).name().to_string()) } else { Cow::Borrowed(f) }
|
||||
}
|
||||
|
||||
/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`.
|
||||
pub(crate) fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
|
||||
if path_shown(path, doc_path) {
|
||||
@@ -1556,8 +1569,7 @@ pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Co
|
||||
}
|
||||
}
|
||||
}
|
||||
let script_hidden = |f: &str| !path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url));
|
||||
for f in d.svg_scripts.iter_mut().filter(|f| script_hidden(f)) {
|
||||
for f in d.svg_scripts.iter_mut().filter(|f| script_hidden(f, doc_path)) {
|
||||
*f = LinkInfo::new(f.as_str()).name().to_string();
|
||||
}
|
||||
Cow::Owned(d)
|
||||
|
||||
@@ -158,6 +158,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"edit.colors.saturate",
|
||||
"edit.colors.toCMYK",
|
||||
"edit.colors.toGrayscale",
|
||||
"edit.colors.toIndexed",
|
||||
"edit.colors.toRGB",
|
||||
"edit.duplicate",
|
||||
"edit.findNext",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -484,6 +484,250 @@ fn adjust_colors_converting_to_gray_stores_an_image_as_grayscale() {
|
||||
assert!(source(false).is_none(), "the colors keep their model");
|
||||
}
|
||||
|
||||
/// Convert to Indexed Color stores an image as a palette PNG in one undo step; an image with few
|
||||
/// enough colors keeps them, and converting it again changes nothing and adds no undo step.
|
||||
#[test]
|
||||
fn convert_to_indexed_stores_an_image_as_a_palette_png() {
|
||||
let mut s = session();
|
||||
let id = image_node(&mut s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("edit.colors.toIndexed", &json!({})).unwrap();
|
||||
assert_eq!(r["changed"], 1, "{r}");
|
||||
let key = image_key(&s, id);
|
||||
assert!(s.doc().unwrap().doc.images[&key].indexed_png(), "an indexed PNG");
|
||||
assert_eq!(pixels(&s, id), [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 255]], "half opaque becomes opaque");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let again = s.execute("edit.colors.toIndexed", &json!({})).unwrap();
|
||||
assert_eq!(again["changed"], 0, "{again}");
|
||||
assert_eq!(image_key(&s, id), key);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "no undo step when nothing changes");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(pixels(&s, id)[3], [255, 255, 255, 128], "one undo step restores the image");
|
||||
}
|
||||
|
||||
/// Pixels under half opacity become transparent; an indexed image converted to a palette it
|
||||
/// already shows is left as it is, and one converted to a smaller palette changes.
|
||||
#[test]
|
||||
fn convert_to_indexed_keeps_transparency_and_reduces_an_indexed_image() {
|
||||
let mut s = session();
|
||||
let id = image_of(&mut s, [[200, 30, 90, 255], [20, 40, 220, 255], [0, 0, 0, 100], [250, 250, 250, 255]]);
|
||||
s.execute("edit.colors.toIndexed", &json!({"dither": "none"})).unwrap();
|
||||
assert_eq!(pixels(&s, id)[2][3], 0, "under half opacity: transparent");
|
||||
let r = s.execute("edit.colors.toIndexed", &json!({"reduction": "blackWhite", "dither": "none"})).unwrap();
|
||||
assert_eq!(r["changed"], 1, "{r}");
|
||||
let px = pixels(&s, id);
|
||||
assert_eq!([px[0], px[1], px[3]], [[0, 0, 0, 255], [0, 0, 0, 255], [255, 255, 255, 255]]);
|
||||
assert_eq!(px[2][3], 0);
|
||||
}
|
||||
|
||||
/// The uniform reduction takes evenly spaced levels of each channel, and the document reduction
|
||||
/// the colors of the document's swatches.
|
||||
#[test]
|
||||
fn convert_to_indexed_uses_a_uniform_or_the_document_palette() {
|
||||
let mut s = session();
|
||||
let id = image_of(&mut s, [[200, 30, 90, 255], [20, 40, 220, 255], [120, 130, 140, 255], [250, 250, 250, 255]]);
|
||||
s.execute("edit.colors.toIndexed", &json!({"reduction": "uniform", "colors": 9, "dither": "none"})).unwrap();
|
||||
assert_eq!(pixels(&s, id), [[255, 0, 0, 255], [0, 0, 255, 255], [0, 255, 255, 255], [255, 255, 255, 255]]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.colors.toIndexed", &json!({"reduction": "document"})).unwrap();
|
||||
let swatches: Vec<[u8; 3]> = crate::cmd::swatchlib::library_colors(&s, "document")
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
[r, g, b]
|
||||
})
|
||||
.collect();
|
||||
for p in pixels(&s, id) {
|
||||
assert!(swatches.contains(&[p[0], p[1], p[2]]), "{p:?} is a swatch color");
|
||||
}
|
||||
}
|
||||
|
||||
/// The start of a PNG of `w` × `h` RGBA pixels: its header and a first image data chunk, without
|
||||
/// the rest of the pixels.
|
||||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let crc = |data: &[u8]| {
|
||||
let mut c = !0u32;
|
||||
for &b in data {
|
||||
c ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
};
|
||||
let chunk = |ty: &[u8; 4], data: &[u8]| {
|
||||
let body = [ty.as_slice(), data].concat();
|
||||
[(data.len() as u32).to_be_bytes().as_slice(), &body, &crc(&body).to_be_bytes()].concat()
|
||||
};
|
||||
let ihdr = [w.to_be_bytes().as_slice(), &h.to_be_bytes(), &[8, 6, 0, 0, 0]].concat();
|
||||
[b"\x89PNG\r\n\x1a\n".as_slice(), &chunk(b"IHDR", &ihdr), &chunk(b"IDAT", &[0x78, 0x9c, 0x03, 0x00])].concat()
|
||||
}
|
||||
|
||||
/// An image larger than Convert to Indexed Color converts is refused by its size, read from the
|
||||
/// file's header, before any pixel is decoded; nothing changes.
|
||||
#[test]
|
||||
fn convert_to_indexed_refuses_an_image_too_large_to_convert() {
|
||||
let mut s = session();
|
||||
let id = image_node(&mut s);
|
||||
assert_eq!(vectorcraft_doc::ImageBlob::png(png_header(30_000, 30_000)).pixel_size(), Some((30_000, 30_000)));
|
||||
s.edit("Swap", |d, _| {
|
||||
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png_header(30_000, 30_000)));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let e = s.execute("edit.colors.toIndexed", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("an image of 30000 × 30000 pixels is too large to convert"), "{e}");
|
||||
assert_eq!(image_key(&s, id), "pic");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
/// Convert to Indexed Color refuses parameters it can't use, and leaves the image as it is.
|
||||
#[test]
|
||||
fn convert_to_indexed_refuses_bad_params() {
|
||||
let mut s = session();
|
||||
let id = image_node(&mut s);
|
||||
for (p, msg) in [
|
||||
(json!({"colors": 1}), "`colors` must be a whole number from 2 to 256"),
|
||||
(json!({"colors": 257}), "`colors` must be a whole number from 2 to 256"),
|
||||
(json!({"colors": "8"}), "`colors` must be a whole number from 2 to 256"),
|
||||
(json!({"colors": 16.5}), "`colors` must be a whole number from 2 to 256"),
|
||||
(json!({"reduction": "uniform", "colors": 8}), "`colors` must be at least 9 for the uniform reduction"),
|
||||
(json!({"reduction": "document", "colors": 16}), "`colors` does not apply to the document reduction"),
|
||||
(json!({"reduction": "blackWhite", "colors": 2}), "`colors` does not apply to the blackWhite reduction"),
|
||||
(json!({"reduction": "posterize"}), "unknown reduction `posterize`"),
|
||||
(json!({"dither": "random"}), "unknown dither `random`"),
|
||||
(json!({"ditherAmount": 101}), "`ditherAmount` must be a number from 0 to 100"),
|
||||
(json!({"ditherAmount": "50"}), "`ditherAmount` must be a number from 0 to 100"),
|
||||
] {
|
||||
let e = s.execute("edit.colors.toIndexed", &p).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(image_key(&s, id), "pic");
|
||||
}
|
||||
|
||||
/// An embedded `w` × `h` image of the pixels `f(x, y)`, placed and selected.
|
||||
fn placed(s: &mut Session, w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> NodeId {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_fn(w, h, |x, y| image::Rgba(f(x, y))).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
NodeId(r["ids"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// `ids` picks the images to convert; a linked image and a colorized image keep their pixels.
|
||||
#[test]
|
||||
fn convert_to_indexed_takes_ids_and_leaves_linked_and_colorized_images() {
|
||||
let mut s = session();
|
||||
let a = placed(&mut s, 2, 1, |x, _| [200, 30 + x as u8, 90, 255]);
|
||||
let b = placed(&mut s, 2, 1, |x, _| [20, 40, 200 + x as u8, 255]);
|
||||
let (ka, kb) = (image_key(&s, a), image_key(&s, b));
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let r = s.execute("edit.colors.toIndexed", &json!({"ids": [b.0]})).unwrap();
|
||||
assert_eq!(r["changed"], 1, "{r}");
|
||||
assert_eq!(image_key(&s, a), ka, "not in `ids`");
|
||||
assert_ne!(image_key(&s, b), kb);
|
||||
s.edit("Link", |d, _| {
|
||||
let NodeKind::Image(im) = &mut d.node_mut(a).unwrap().kind else { panic!("not an image") };
|
||||
im.link = Some(vectorcraft_doc::LinkInfo::new("art.png"));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("edit.colors.toIndexed", &json!({"ids": [a.0]})).unwrap();
|
||||
assert_eq!(r["changed"], 0, "a linked image: {r}");
|
||||
assert_eq!(image_key(&s, a), ka);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
|
||||
let mut png = Vec::new();
|
||||
image::GrayImage::from_pixel(4, 4, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "gray.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
let gray = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [gray.0], "color": "#ff0000"})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(gray).unwrap().extras().colorize.is_some(), "the fill colorizes the image");
|
||||
let kg = image_key(&s, gray);
|
||||
let r = s.execute("edit.colors.toIndexed", &json!({"ids": [gray.0]})).unwrap();
|
||||
assert_eq!(r["changed"], 0, "a colorized image: {r}");
|
||||
assert_eq!(image_key(&s, gray), kg);
|
||||
}
|
||||
|
||||
/// With the selection Select All makes, locked and hidden images are left out and counted in
|
||||
/// `notChanged`; a left-out image too large to convert is counted without an error.
|
||||
#[test]
|
||||
fn convert_to_indexed_counts_left_out_images() {
|
||||
let mut s = session();
|
||||
let a = placed(&mut s, 2, 1, |x, _| [200, 30 + x as u8, 90, 255]);
|
||||
let locked = placed(&mut s, 2, 1, |x, _| [20, 40, 200 + x as u8, 255]);
|
||||
let hidden = placed(&mut s, 2, 1, |x, _| [120, 140 + x as u8, 20, 255]);
|
||||
s.edit("Huge", |d, _| {
|
||||
let NodeKind::Image(im) = &mut d.node_mut(locked).unwrap().kind else { panic!("not an image") };
|
||||
im.key = "huge".into();
|
||||
d.images.insert("huge".into(), vectorcraft_doc::ImageBlob::png(png_header(30_000, 30_000)));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap();
|
||||
s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let kh = image_key(&s, hidden);
|
||||
let r = s.execute("edit.colors.toIndexed", &json!({})).unwrap();
|
||||
assert_eq!(r["changed"], 1, "{r}");
|
||||
assert_eq!(r["notChanged"], json!({"locked": 1, "hidden": 1}), "{r}");
|
||||
assert!(r["warning"].is_string(), "{r}");
|
||||
assert!(s.doc().unwrap().doc.images[&image_key(&s, a)].indexed_png());
|
||||
assert_eq!((image_key(&s, locked), image_key(&s, hidden)), ("huge".to_string(), kh));
|
||||
}
|
||||
|
||||
/// The uniform reduction takes the most levels a channel whose cube leaves one entry for
|
||||
/// transparency (3 for 64 colors, 6 for 256), and the document reduction takes 1 to 255 swatch
|
||||
/// colors.
|
||||
#[test]
|
||||
fn convert_to_indexed_sizes_the_uniform_and_document_palettes() {
|
||||
let mut s = session();
|
||||
let id = image_of(&mut s, [[200, 30, 90, 255], [20, 40, 220, 255], [120, 130, 140, 255], [250, 250, 250, 255]]);
|
||||
s.execute("edit.colors.toIndexed", &json!({"reduction": "uniform", "colors": 64, "dither": "none"})).unwrap();
|
||||
assert_eq!(pixels(&s, id), [[255, 0, 127, 255], [0, 0, 255, 255], [127, 127, 127, 255], [255, 255, 255, 255]]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.colors.toIndexed", &json!({"reduction": "uniform", "dither": "none"})).unwrap();
|
||||
assert_eq!(pixels(&s, id), [[204, 51, 102, 255], [0, 51, 204, 255], [102, 153, 153, 255], [255, 255, 255, 255]]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.edit("No swatches", |d, _| {
|
||||
d.swatches.clear();
|
||||
d.swatch_groups.clear();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let e = s.execute("edit.colors.toIndexed", &json!({"reduction": "document"})).unwrap_err().to_string();
|
||||
assert!(e.contains("the document has no color swatches"), "{e}");
|
||||
s.edit("Many swatches", |d, _| {
|
||||
for i in 0..256u32 {
|
||||
let paint = Paint::solid(Color::rgb8(i as u8, 7, 9));
|
||||
d.swatches.push(vectorcraft_color::swatch::Swatch { name: format!("c{i}"), paint, global: false, spot: false });
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let e = s.execute("edit.colors.toIndexed", &json!({"reduction": "document"})).unwrap_err().to_string();
|
||||
assert!(e.contains("the document's swatches hold 256 colors; the document reduction takes at most 255"), "{e}");
|
||||
assert_eq!(image_key(&s, id), "pic");
|
||||
}
|
||||
|
||||
/// `dither` and `ditherAmount` reach the quantizer: a gray ramp reduced to black and white is
|
||||
/// dithered by each method, and at an amount of 0 it comes out as with no dither.
|
||||
#[test]
|
||||
fn convert_to_indexed_dithers_by_the_amount_given() {
|
||||
let ramp = |p: Value| {
|
||||
let mut s = session();
|
||||
let id = placed(&mut s, 16, 4, |x, _| [(x * 17) as u8, (x * 17) as u8, (x * 17) as u8, 255]);
|
||||
s.execute("edit.colors.toIndexed", &p).unwrap();
|
||||
pixels(&s, id)
|
||||
};
|
||||
let none = ramp(json!({"reduction": "blackWhite", "dither": "none"}));
|
||||
for d in ["diffusion", "pattern", "noise"] {
|
||||
assert_eq!(ramp(json!({"reduction": "blackWhite", "dither": d, "ditherAmount": 0})), none, "{d} at 0");
|
||||
assert_ne!(ramp(json!({"reduction": "blackWhite", "dither": d})), none, "{d} at 100");
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF of a CMYK document writes an image Convert to Grayscale made with one channel (#5632).
|
||||
#[test]
|
||||
fn a_converted_image_is_written_to_pdf_as_gray_in_a_cmyk_document() {
|
||||
|
||||
@@ -368,6 +368,7 @@ fn ids_cases(d: u64) -> Vec<(&'static str, &'static str, serde_json::Value, Unse
|
||||
("edit.colors.invert", "ids", json!({}), Selected),
|
||||
("edit.colors.toCMYK", "ids", json!({}), Selected),
|
||||
("edit.colors.toGrayscale", "ids", json!({}), Selected),
|
||||
("edit.colors.toIndexed", "ids", json!({}), Selected),
|
||||
("edit.colors.toRGB", "ids", json!({}), Selected),
|
||||
("edit.colors.saturate", "ids", json!({"intensity": 50}), Selected),
|
||||
("edit.colors.adjustBalance", "ids", json!({"r": 20}), Selected),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tests_links::{Folder, save, write};
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
@@ -110,6 +111,124 @@ fn javascript_files_are_linked_once_and_unlinked() {
|
||||
assert_eq!(files(&mut s), json!(["menu.js", "https://example.com/a.js"]));
|
||||
}
|
||||
|
||||
/// `cmd` with `p` for a caller whose read roots are `read` and whose write roots are `write`.
|
||||
fn confined(s: &mut Session, read: &[&Folder], write: &[&Folder], cmd: &str, p: Value) -> std::result::Result<Value, String> {
|
||||
let read: Vec<&std::path::Path> = read.iter().map(|f| f.0.as_path()).collect();
|
||||
let write: Vec<&std::path::Path> = write.iter().map(|f| f.0.as_path()).collect();
|
||||
let roots = crate::file_access::AutomationRoots::new(&read, &write).unwrap();
|
||||
crate::file_access::confine(roots.as_ref(), || s.execute(cmd, &p)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `info`, `addFile` and `removeFile` for a caller with read roots name a linked file outside them
|
||||
/// by its file name, a relative one taken from the document's folder; a file inside them and a URL
|
||||
/// are named as they are, and the document keeps every path.
|
||||
#[test]
|
||||
fn javascript_file_replies_hide_paths_outside_read_roots() {
|
||||
let (away, home) = (Folder::new("js-replies-away"), Folder::new("js-replies-home"));
|
||||
let (outside, inside) = (away.file("menu.js"), home.file("in.js"));
|
||||
for f in [&outside, &inside, &away.file("lib/rel.js")] {
|
||||
write(f, b"void 0;");
|
||||
}
|
||||
let url = "https://example.com/a.js";
|
||||
let mut s = session();
|
||||
for f in [outside.as_str(), inside.as_str(), url, "lib/rel.js"] {
|
||||
run(&mut s, "svgInteractivity.addFile", json!({ "file": f }));
|
||||
}
|
||||
save(&mut s, &away.file("poster.vectorcraft"));
|
||||
let stored = s.doc().unwrap().doc.svg_scripts.clone();
|
||||
let shown = json!(["menu.js", inside, url, "rel.js"]);
|
||||
let info = confined(&mut s, &[&home], &[], "svgInteractivity.info", json!({})).unwrap();
|
||||
assert_eq!(info["files"], shown, "{info}");
|
||||
let added = confined(&mut s, &[&home], &[], "svgInteractivity.addFile", json!({"file": "https://example.com/b.js"})).unwrap();
|
||||
assert_eq!(added["files"], json!(["menu.js", inside, url, "rel.js", "https://example.com/b.js"]), "{added}");
|
||||
let removed = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "https://example.com/b.js"})).unwrap();
|
||||
assert_eq!(removed["files"], shown, "{removed}");
|
||||
assert!(!format!("{info}{added}{removed}").contains("js-replies-away"));
|
||||
// A caller with only write roots reads none of the paths.
|
||||
let info = confined(&mut s, &[], &[&home], "svgInteractivity.info", json!({})).unwrap();
|
||||
assert_eq!(info["files"], json!(["menu.js", "in.js", url, "rel.js"]), "{info}");
|
||||
// A caller whose read roots hold the files, and one without roots, sees every path.
|
||||
assert_eq!(confined(&mut s, &[&away, &home], &[], "svgInteractivity.info", json!({})).unwrap()["files"], json!(stored));
|
||||
assert_eq!(run(&mut s, "svgInteractivity.info", json!({}))["files"], json!(stored));
|
||||
assert_eq!(s.doc().unwrap().doc.svg_scripts, stored, "the document keeps its paths");
|
||||
}
|
||||
|
||||
/// A caller with read roots unlinks a file a reply names by its file name alone by that name, as
|
||||
/// one undo step; the name is not taken for a file it may read, nor without read roots.
|
||||
#[test]
|
||||
fn hidden_javascript_file_can_be_removed_by_unique_shown_name() {
|
||||
let (away, home) = (Folder::new("js-remove-away"), Folder::new("js-remove-home"));
|
||||
let (outside, inside) = (away.file("menu.js"), home.file("other.js"));
|
||||
let mut s = session();
|
||||
for f in [&outside, &inside] {
|
||||
run(&mut s, "svgInteractivity.addFile", json!({ "file": f }));
|
||||
}
|
||||
assert!(s.execute("svgInteractivity.removeFile", &json!({"file": "menu.js"})).is_err(), "no name without read roots");
|
||||
let e = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "other.js"})).unwrap_err();
|
||||
assert!(e.contains("\"other.js\" isn't linked"), "a file inside the roots is unlinked by its path: {e}");
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let r = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "menu.js"})).unwrap();
|
||||
assert_eq!(r["files"], json!([inside]), "{r}");
|
||||
assert_eq!(s.doc().unwrap().doc.svg_scripts, [inside.as_str()]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc.svg_scripts, [outside.as_str(), inside.as_str()]);
|
||||
// A caller that knows the whole path unlinks the file by it.
|
||||
let r = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({ "file": outside })).unwrap();
|
||||
assert_eq!(r["files"], json!([inside]), "{r}");
|
||||
}
|
||||
|
||||
/// Two linked files outside the read roots with the same file name: that name unlinks neither,
|
||||
/// and the error names no path.
|
||||
#[test]
|
||||
fn ambiguous_hidden_javascript_file_name_is_refused_without_edit() {
|
||||
let (away, home) = (Folder::new("js-ambiguous-away"), Folder::new("js-ambiguous-home"));
|
||||
let (a, b) = (away.file("a/menu.js"), away.file("b/menu.js"));
|
||||
let mut s = session();
|
||||
for f in [&a, &b] {
|
||||
run(&mut s, "svgInteractivity.addFile", json!({ "file": f }));
|
||||
}
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let e = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "menu.js"})).unwrap_err();
|
||||
assert!(e.contains("\"menu.js\" names more than one linked JavaScript file") && !e.contains("js-ambiguous-away"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc.svg_scripts, [a.as_str(), b.as_str()]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no undo step");
|
||||
let e = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "nav.js"})).unwrap_err();
|
||||
assert!(e.contains("\"nav.js\" isn't linked") && !e.contains("js-ambiguous-away"), "{e}");
|
||||
// A file linked as the name itself, beside one outside the roots shown by that name: neither.
|
||||
run(&mut s, "svgInteractivity.removeFile", json!({ "file": b }));
|
||||
run(&mut s, "svgInteractivity.addFile", json!({"file": "menu.js"}));
|
||||
let e = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "menu.js"})).unwrap_err();
|
||||
assert!(e.contains("names more than one"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc.svg_scripts, [a.as_str(), "menu.js"]);
|
||||
// Without read roots the name is the file linked as it.
|
||||
run(&mut s, "svgInteractivity.removeFile", json!({"file": "menu.js"}));
|
||||
assert_eq!(s.doc().unwrap().doc.svg_scripts, [a.as_str()]);
|
||||
}
|
||||
|
||||
/// `addFile` takes a file the same text links as already linked: a file name that a reply shows for
|
||||
/// a different file is linked as one more file, within the 64-file limit.
|
||||
#[test]
|
||||
fn adding_a_shown_name_does_not_deduplicate_a_different_hidden_path() {
|
||||
let (away, home) = (Folder::new("js-add-away"), Folder::new("js-add-home"));
|
||||
let mut s = session();
|
||||
let outside = away.file("0.js");
|
||||
run(&mut s, "svgInteractivity.addFile", json!({ "file": outside }));
|
||||
let r = confined(&mut s, &[&home], &[], "svgInteractivity.addFile", json!({"file": "0.js"})).unwrap();
|
||||
assert_eq!(r["files"], json!(["0.js", "0.js"]), "{r}");
|
||||
assert_eq!(s.doc().unwrap().doc.svg_scripts, [outside.as_str(), "0.js"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
for i in 1..64 {
|
||||
run(&mut s, "svgInteractivity.addFile", json!({ "file": away.file(&format!("{i}.js")) }));
|
||||
}
|
||||
let e = confined(&mut s, &[&home], &[], "svgInteractivity.addFile", json!({"file": "0.js"})).unwrap_err();
|
||||
assert!(e.contains("at most 64"), "a 65th file is refused: {e}");
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let r = confined(&mut s, &[&home], &[], "svgInteractivity.addFile", json!({ "file": outside })).unwrap();
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(64), "the same path is already linked");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_links_at_most_64_javascript_files() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -945,6 +945,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step",
|
||||
),
|
||||
(
|
||||
"ui.indexedColorDialog",
|
||||
"Convert to Indexed Color…",
|
||||
"",
|
||||
"{} open Convert to Indexed Color for the selection (dialog `indexedColor`, fields `reduction`, `colors` 2..256, `dither`, `ditherAmount` 0..100, `preview`): previews live, OK runs edit.colors.toIndexed as one undo step",
|
||||
),
|
||||
(
|
||||
"window.swatchLibrary",
|
||||
"Swatch Library",
|
||||
|
||||
@@ -154,18 +154,8 @@ pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Ind
|
||||
pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, locked: &[[u8; 3]]) -> Indexed {
|
||||
let px = rgba.as_chunks::<4>().0;
|
||||
let clear = |p: &[u8; 4]| o.transparency && p[3] < 128;
|
||||
let gray = matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite);
|
||||
let matte = o.matte.unwrap_or([255; 3]);
|
||||
let blend = o.matte.is_some() || !o.transparency;
|
||||
// The opaque colours: blended over the matte, greys for the grey reductions.
|
||||
let colors: Vec<[u8; 3]> = px
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = if blend { p[3] as u32 } else { 255 };
|
||||
let c = [0, 1, 2].map(|i| ((p[i] as u32 * a + matte[i] as u32 * (255 - a) + 127) / 255) as u8);
|
||||
if gray { [luma(c); 3] } else { c }
|
||||
})
|
||||
.collect();
|
||||
let colors = opaque_colors(px, o, matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite));
|
||||
let has_clear = px.iter().any(clear);
|
||||
let k = (o.colors.clamp(2, 256) as usize - usize::from(has_clear)).max(1);
|
||||
let opaque = || colors.iter().zip(px).filter(|(_, p)| !clear(p)).map(|(c, _)| *c);
|
||||
@@ -195,11 +185,46 @@ pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions,
|
||||
if palette.is_empty() {
|
||||
palette.push(matte);
|
||||
}
|
||||
finish(px, &colors, width, height, &palette, o, if exact { Dither::None } else { o.dither })
|
||||
}
|
||||
|
||||
/// [`quantize`] to the fixed `palette`: each pixel takes its nearest entry, with `o`'s dither,
|
||||
/// transparency and matte; `o.reduction` sets only the distance weights, and `o.colors` is not
|
||||
/// used. Only the first 255 colors of `palette` are used, leaving room for the transparent entry.
|
||||
/// Entries no pixel uses are left out.
|
||||
pub fn quantize_to(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, palette: &[[u8; 3]]) -> Indexed {
|
||||
let px = rgba.as_chunks::<4>().0;
|
||||
let colors = opaque_colors(px, o, false);
|
||||
let mut palette: Vec<[u8; 3]> = palette.iter().take(255).copied().collect();
|
||||
if palette.is_empty() {
|
||||
palette.push(o.matte.unwrap_or([255; 3]));
|
||||
}
|
||||
finish(px, &colors, width, height, &palette, o, o.dither)
|
||||
}
|
||||
|
||||
/// The color of each pixel when opaque: blended over the matte, and gray when `gray`.
|
||||
fn opaque_colors(px: &[[u8; 4]], o: &PaletteOptions, gray: bool) -> Vec<[u8; 3]> {
|
||||
let matte = o.matte.unwrap_or([255; 3]);
|
||||
let blend = o.matte.is_some() || !o.transparency;
|
||||
px.iter()
|
||||
.map(|p| {
|
||||
let a = if blend { p[3] as u32 } else { 255 };
|
||||
let c = [0, 1, 2].map(|i| ((p[i] as u32 * a + matte[i] as u32 * (255 - a) + 127) / 255) as u8);
|
||||
if gray { [luma(c); 3] } else { c }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Map the pixels `px` (their opaque `colors`) to `palette` with `dither`, then drop the entries
|
||||
/// no pixel uses and put the transparent entry first when a pixel is transparent.
|
||||
fn finish(px: &[[u8; 4]], colors: &[[u8; 3]], width: u32, height: u32, palette: &[[u8; 3]], o: &PaletteOptions, dither: Dither) -> Indexed {
|
||||
let clear = |p: &[u8; 4]| o.transparency && p[3] < 128;
|
||||
let matte = o.matte.unwrap_or([255; 3]);
|
||||
let has_clear = px.iter().any(clear);
|
||||
let weights = o.reduction.weights();
|
||||
let dither = if exact { Dither::None } else { o.dither };
|
||||
// (A width past the pixels there are would size the error rows from a number nothing checked.)
|
||||
let mut indices =
|
||||
map(&colors, (width as usize).min(colors.len()), &palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i]));
|
||||
map(colors, (width as usize).min(colors.len()), palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i]));
|
||||
// Drop the entries no pixel uses (fixed palettes), keeping the order.
|
||||
let mut used = [false; 256];
|
||||
for (i, &ix) in indices.iter().enumerate() {
|
||||
|
||||
@@ -3,7 +3,7 @@ use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
use super::png::{PngOptions, encode_indexed};
|
||||
use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize};
|
||||
use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_to};
|
||||
use super::*;
|
||||
|
||||
/// A `w`×`h` opaque image of `f(x, y)` colours.
|
||||
@@ -93,6 +93,22 @@ fn fixed_palettes() {
|
||||
assert_eq!(bw.palette, [[0, 0, 0], [255, 255, 255]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_given_palette_keeps_the_entries_pixels_use() {
|
||||
let px: Vec<u8> = [[250, 10, 10, 255], [10, 10, 240, 255], [0, 0, 0, 40], [240, 20, 30, 255]].concat();
|
||||
let given = [[0, 255, 0], [255, 0, 0], [0, 0, 255], [255, 255, 255]];
|
||||
let o = PaletteOptions { dither: Dither::None, matte: None, ..Default::default() };
|
||||
let ix = quantize_to(&px, 4, 1, &o, &given);
|
||||
assert_eq!(ix.transparent, Some(0));
|
||||
assert_eq!(ix.palette[1..], [[255, 0, 0], [0, 0, 255]], "unused entries left out, the order kept");
|
||||
assert_eq!(ix.indices, [1, 2, 0, 1]);
|
||||
// At most 255 of the given colors are used, leaving room for the transparent entry.
|
||||
let many: Vec<[u8; 3]> = (0..300u32).map(|i| [(i % 256) as u8, (i / 256) as u8, 0]).collect();
|
||||
let all = image(256, 2, |x, y| [x as u8, y as u8, 0]);
|
||||
let ix = quantize_to(&all, 256, 2, &o, &many);
|
||||
assert!(ix.palette.len() <= 255 && ix.palette.iter().all(|c| many[..255].contains(c)), "{}", ix.palette.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dithering_keeps_the_average_colour() {
|
||||
let px = image(64, 16, |x, _| [(x * 4) as u8; 3]);
|
||||
|
||||
@@ -34,7 +34,7 @@ mod ruby;
|
||||
mod shaped;
|
||||
mod vertical;
|
||||
|
||||
pub(crate) use bidi::{is_rtl, para_bidi};
|
||||
pub(crate) use bidi::{is_rtl, para_bidi, starts_bidi};
|
||||
use breaking::tab_advance;
|
||||
#[cfg(test)]
|
||||
use breaking::{break_line, candidates};
|
||||
|
||||
@@ -4,15 +4,21 @@ use super::{BidiInfo, Level, ParaDirection, Range, SGlyph};
|
||||
/// without one, from its first strong character. `None` for the usual paragraph with nothing right
|
||||
/// to left in it (no right-to-left character or direction), which skips the algorithm.
|
||||
pub(crate) fn para_bidi(text: &str, direction: Option<ParaDirection>) -> Option<BidiInfo<'_>> {
|
||||
use unicode_bidi::BidiClass::{AL, AN, FSI, R, RLE, RLI, RLO};
|
||||
let rtl_set = direction == Some(ParaDirection::RightToLeft);
|
||||
if !rtl_set && !text.chars().any(|c| matches!(unicode_bidi::bidi_class(c), R | AL | AN | RLE | RLO | RLI | FSI)) {
|
||||
if !rtl_set && !text.chars().any(starts_bidi) {
|
||||
return None;
|
||||
}
|
||||
let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() });
|
||||
Some(BidiInfo::new(text, level))
|
||||
}
|
||||
|
||||
/// Whether character `c` makes [`para_bidi`] resolve a paragraph that holds it: a right-to-left
|
||||
/// letter, an Arabic number, or a right-to-left embedding, override or isolate.
|
||||
pub(crate) fn starts_bidi(c: char) -> bool {
|
||||
use unicode_bidi::BidiClass::{AL, AN, FSI, R, RLE, RLI, RLO};
|
||||
matches!(unicode_bidi::bidi_class(c), R | AL | AN | RLE | RLO | RLI | FSI)
|
||||
}
|
||||
|
||||
/// [`para_bidi`] for a paragraph whose characters in `set` (byte ranges of `text`, in order and not
|
||||
/// overlapping, from [`Ctx::char_directions`]) have a Character Direction: each range is resolved
|
||||
/// as a Unicode bidi embedding, as if an LRE or RLE stood before it and a PDF after it (UAX #9 X2,
|
||||
|
||||
+70
-14
@@ -262,22 +262,41 @@ impl<'a> Rest<'a> {
|
||||
}
|
||||
|
||||
/// How many bytes of `rest` fit in `frame`, measured as `probe` makes the frame for some runs (with
|
||||
/// their paragraph styles, the list counts and the continuation mark).
|
||||
/// their paragraph styles, the list counts and the continuation mark). `no_rtl`: no paragraph of
|
||||
/// the rest resolves right to left on its own (no character [`crate::layout::starts_bidi`] holds
|
||||
/// and no right-to-left Character Direction).
|
||||
///
|
||||
/// Area type without Shrink Text to Fit whose lines align to the top is first measured with the
|
||||
/// whole paragraphs of the rest that end at or after byte `want`, and then with twice as many bytes
|
||||
/// each time, while the rest goes on past them. The layout places lines in order and stops at the
|
||||
/// first line that does not fit, and each paragraph is shaped and broken on its own text. So when
|
||||
/// that line starts before the last paragraph break measured, the frame holds the same bytes with
|
||||
/// the whole rest. Other type, and a rest that ends first, is measured with the whole rest.
|
||||
fn fit_rest(db: &FontDb, probe: impl Fn(Vec<TextRun>) -> TextObject, frame: &TextObject, rest: Rest<'_>, mut want: usize) -> usize {
|
||||
let by_paragraph = matches!(frame.kind, TextKind::Area { .. })
|
||||
&& frame.area.fit.min_scale().is_none()
|
||||
&& frame.area.vertical_align == vectorcraft_doc::VerticalAlign::Top;
|
||||
/// Area type without Shrink Text to Fit is first measured with the whole paragraphs of the rest
|
||||
/// that end at or after byte `want`, and then with twice as many bytes each time, while the rest
|
||||
/// goes on past them. The layout places lines in order and stops at the first line that does not
|
||||
/// fit, and each paragraph is shaped and broken on its own text. So when that line starts before
|
||||
/// the last paragraph break measured, the frame holds the same bytes with the whole rest. Lines
|
||||
/// aligned to the center, the bottom or justified vertically move by the space the placed lines
|
||||
/// leave, and a frame that is not a plain rectangle flows them again from there. A pass that places
|
||||
/// less text than the one before is undone, so no pass places more text than the final layout; when
|
||||
/// that layout stops before the last paragraph break measured, every pass stopped at the same line
|
||||
/// as with the whole rest.
|
||||
///
|
||||
/// Type on a path is measured the same way when its line starts at the start bracket whatever text
|
||||
/// follows: no Dynamic Text, Optical Margin Alignment or Roman Hanging Punctuation, the first
|
||||
/// paragraph aligned Left, Justify Left or Auto, and no right-to-left text (`no_rtl`, and the first
|
||||
/// paragraph's direction not right to left) unless the type is vertical. Each glyph's step along the
|
||||
/// path then depends only on the glyphs before it, so when the first glyph that does not fit comes
|
||||
/// before the last paragraph break measured, the path holds the same bytes with the whole rest.
|
||||
///
|
||||
/// Other type, and a rest that ends first, is measured with the whole rest.
|
||||
fn fit_rest(db: &FontDb, probe: impl Fn(Vec<TextRun>) -> TextObject, frame: &TextObject, rest: Rest<'_>, mut want: usize, no_rtl: bool) -> usize {
|
||||
let on_path = matches!(frame.kind, TextKind::OnPath { .. });
|
||||
let by_paragraph = match frame.kind {
|
||||
TextKind::Area { .. } => frame.area.fit.min_scale().is_none(),
|
||||
TextKind::OnPath { .. } => path_starts_at_bracket(&probe(Vec::new()), no_rtl),
|
||||
_ => false,
|
||||
};
|
||||
if by_paragraph {
|
||||
while let Some(end) = rest.break_after(want.saturating_sub(1)).filter(|&end| end < rest.len) {
|
||||
let (n, unplaced) = fit(db, &probe(rest.before(end)));
|
||||
if unplaced.is_some_and(|u| u + 1 < end) {
|
||||
let first_out = if on_path { Some(n) } else { unplaced };
|
||||
if first_out.is_some_and(|u| u + 1 < end) {
|
||||
return n;
|
||||
}
|
||||
want = end.saturating_mul(2);
|
||||
@@ -286,6 +305,35 @@ fn fit_rest(db: &FontDb, probe: impl Fn(Vec<TextRun>) -> TextObject, frame: &Tex
|
||||
fit(db, &probe(rest.all())).0
|
||||
}
|
||||
|
||||
/// Whether type on a path made as `head` (the frame with its first paragraph's style) places its
|
||||
/// line from the start bracket, glyph by glyph, whatever text follows: see [`fit_rest`].
|
||||
fn path_starts_at_bracket(head: &TextObject, no_rtl: bool) -> bool {
|
||||
use vectorcraft_doc::{Justify, ParaDirection};
|
||||
let para = head.para_at(0);
|
||||
!head.dynamic_text
|
||||
&& !head.optical_margin
|
||||
&& !para.roman_hanging
|
||||
&& matches!(para.justify, Justify::Auto | Justify::Left | Justify::JustifyLeft)
|
||||
&& (head.vertical || (no_rtl && para.direction != Some(ParaDirection::RightToLeft)))
|
||||
}
|
||||
|
||||
/// The end of the last character of `runs` that makes its paragraph resolve right to left
|
||||
/// ([`crate::layout::starts_bidi`], or a run with the right-to-left Character Direction); 0 when
|
||||
/// there is none.
|
||||
fn rtl_end(runs: &[TextRun]) -> usize {
|
||||
let mut off = 0;
|
||||
let mut end = 0;
|
||||
for r in runs {
|
||||
if r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft) && !r.text.is_empty() {
|
||||
end = off + r.text.len();
|
||||
} else if let Some((i, c)) = r.text.char_indices().rev().find(|&(_, c)| crate::layout::starts_bidi(c)) {
|
||||
end = off + i + c.len_utf8();
|
||||
}
|
||||
off += r.text.len();
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
/// A frame's share of a story: its runs, its paragraphs' styles (one per paragraph) and the
|
||||
/// numbered list paragraphs counted before its first one, at level 1 and at the levels past it
|
||||
/// ([`TextObject::list_count`], [`TextObject::list_level_counts`]).
|
||||
@@ -379,7 +427,10 @@ pub fn distribute_continuing_levels(
|
||||
continued: bool,
|
||||
mut counts: ListCounts,
|
||||
) -> Vec<Slice> {
|
||||
let mut story_rest = Rest { runs, run: 0, skip: 0, raw: true, len: runs_len(runs) };
|
||||
let story_len = runs_len(runs);
|
||||
let mut story_rest = Rest { runs, run: 0, skip: 0, raw: true, len: story_len };
|
||||
// Where the story's right-to-left text ends ([`rtl_end`]), read once, for type on a path.
|
||||
let story_rtl_end = std::cell::OnceCell::new();
|
||||
// Once the story is used up, what is left is one empty run with the style there.
|
||||
let mut used_up: Option<TextRun> = None;
|
||||
let mut out = Vec::with_capacity(frames.len());
|
||||
@@ -415,7 +466,12 @@ pub fn distribute_continuing_levels(
|
||||
probe.first_para_continues = continues;
|
||||
probe
|
||||
};
|
||||
fit_rest(db, probe, f, rest, want)
|
||||
// The rest is the story after its first `story_len - len` bytes.
|
||||
let no_rtl = len == 0
|
||||
|| (matches!(f.kind, TextKind::OnPath { .. })
|
||||
&& !f.vertical
|
||||
&& *story_rtl_end.get_or_init(|| rtl_end(runs)) <= story_len.saturating_sub(len));
|
||||
fit_rest(db, probe, f, rest, want, no_rtl)
|
||||
};
|
||||
want = n.saturating_mul(2).max(MIN_MEASURE);
|
||||
let mut head = rest.slice(0, n);
|
||||
|
||||
+189
-10
@@ -19,6 +19,14 @@ fn path_frame(length: f64) -> TextObject {
|
||||
t
|
||||
}
|
||||
|
||||
/// Type on a curved path about 300 long.
|
||||
fn curved_path_frame() -> TextObject {
|
||||
let mut t = path_frame(300.0);
|
||||
let curve = kurbo::CubicBez::new((0.0, 0.0), (100.0, -120.0), (200.0, 120.0), (300.0, 0.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Shape::to_path(&curve, 0.1)), start: 0.0, end: None };
|
||||
t
|
||||
}
|
||||
|
||||
/// Type on a path that overflows ends its line where its text stops fitting, as area type's
|
||||
/// last line does, so a story flows on from there through area type and type on a path alike.
|
||||
#[test]
|
||||
@@ -543,6 +551,7 @@ fn paragraphs(n: usize) -> String {
|
||||
/// text and paragraph style that changes how much a frame holds.
|
||||
#[test]
|
||||
fn frames_take_the_shares_the_whole_rest_gives_them() {
|
||||
use vectorcraft_doc::VerticalAlign;
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let db = FontDb::global();
|
||||
let plain = CharStyle::default();
|
||||
@@ -649,6 +658,123 @@ fn frames_take_the_shares_the_whole_rest_gives_them() {
|
||||
let two = [&frames[0], &frames[1]];
|
||||
took += same_as_whole_rest(db, &two, &story(¶graphs(200)), &[], "overflowing last frame");
|
||||
|
||||
// Lines aligned to the center, the bottom or justified vertically, in rectangles, ellipses and
|
||||
// frames that wrap around an object, with Shrink Text to Fit in one frame, and a story whose
|
||||
// last paragraph runs through several frames.
|
||||
let last_long = format!("{}\n{}", paragraphs(6), "The quick brown fox jumps over the lazy dog. ".repeat(40));
|
||||
for align in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
|
||||
let mut rects = frames.clone();
|
||||
for f in &mut rects {
|
||||
f.area.vertical_align = align;
|
||||
}
|
||||
took += same_as_whole_rest(db, &refs(&rects), &story(¶graphs(120)), &spaced, &format!("{align:?}"));
|
||||
took += same_as_whole_rest(db, &refs(&rects), &story(&breaks), &[], &format!("{align:?}, empty paragraphs"));
|
||||
took += same_as_whole_rest(db, &refs(&rects), &styled, &[], &format!("{align:?}, style runs"));
|
||||
took += same_as_whole_rest(db, &refs(&rects), &story(¶graphs(120)), &listed, &format!("{align:?}, lists"));
|
||||
took += same_as_whole_rest(db, &refs(&rects), &story(&last_long), &[], &format!("{align:?}, long last paragraph"));
|
||||
let mut ellipses = rects.clone();
|
||||
for (k, f) in ellipses.iter_mut().enumerate() {
|
||||
let x = 200.0 * k as f64;
|
||||
f.kind = TextKind::Area { frame: shapes::ellipse(Rect::new(x, 0.0, x + 160.0, 90.0)) };
|
||||
}
|
||||
took += same_as_whole_rest(db, &refs(&ellipses), &story(¶graphs(120)), &[], &format!("{align:?}, ellipses"));
|
||||
took += same_as_whole_rest(db, &refs(&ellipses), &story(&long_paras), &justified, &format!("{align:?}, ellipses, justified"));
|
||||
let mut wrapped = rects.clone();
|
||||
for (k, f) in wrapped.iter_mut().enumerate() {
|
||||
let x = 200.0 * k as f64;
|
||||
f.wrap = vec![vectorcraft_doc::WrapShape { path: shapes::ellipse(Rect::new(x + 40.0, 10.0, x + 90.0, 40.0)), wrap: Default::default() }];
|
||||
}
|
||||
took += same_as_whole_rest(db, &refs(&wrapped), &story(¶graphs(120)), &[], &format!("{align:?}, wrapped"));
|
||||
let mut shrink = rects.clone();
|
||||
shrink[2].area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 60.0 };
|
||||
took += same_as_whole_rest(db, &refs(&shrink), &story(¶graphs(120)), &[], &format!("{align:?}, Shrink Text to Fit"));
|
||||
}
|
||||
|
||||
// Type on a path: lines that start at the start bracket, and lines whose place or length
|
||||
// depends on the text after them (centered or right-aligned, hanging punctuation, Dynamic
|
||||
// Text, right-to-left text), horizontal and vertical.
|
||||
let paths = |edit: Edit| -> Vec<TextObject> {
|
||||
(0..6)
|
||||
.map(|k| {
|
||||
let mut t = if k % 2 == 0 { path_frame(300.0) } else { curved_path_frame() };
|
||||
if k % 3 == 2 {
|
||||
// Brackets inside the path, with room for the line's ends to hang past them.
|
||||
if let TextKind::OnPath { start, end, .. } = &mut t.kind {
|
||||
(*start, *end) = (0.1, Some(0.8));
|
||||
}
|
||||
}
|
||||
edit(&mut t);
|
||||
t
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let hebrew = "\u{5e9}\u{5dc}\u{5d5}\u{5dd} \u{5e2}\u{5d5}\u{5dc}\u{5dd} ".repeat(4);
|
||||
let rtl_early = format!("{}\n{}\n{}", paragraphs(2), hebrew.trim_end(), paragraphs(100));
|
||||
let ends_unhung = format!("{}\nlast words", paragraphs(120));
|
||||
let rtl_late = format!("{}\n{}\n{}", paragraphs(80), hebrew.trim_end(), paragraphs(20));
|
||||
let whole = story(¶graphs(120));
|
||||
let at = paragraphs(60).len();
|
||||
let directed = [
|
||||
slice_runs(&whole, 0, at),
|
||||
vec![TextRun::new(
|
||||
" set right to left ",
|
||||
CharStyle { char_direction: Some(vectorcraft_doc::ParaDirection::RightToLeft), ..Default::default() },
|
||||
)],
|
||||
slice_runs(&whole, at, runs_len(&whole)),
|
||||
]
|
||||
.concat();
|
||||
type Edit = fn(&mut TextObject);
|
||||
let edits: [(&str, Edit); 15] = [
|
||||
("path", |_| {}),
|
||||
("path, Left", |t| t.para.justify = Justify::Left),
|
||||
("path, Auto", |t| t.para.justify = Justify::Auto),
|
||||
("path, Justify Left", |t| t.para.justify = Justify::JustifyLeft),
|
||||
("path, Center", |t| t.para.justify = Justify::Center),
|
||||
("path, Right", |t| t.para.justify = Justify::Right),
|
||||
("path, Justify All", |t| t.para.justify = Justify::JustifyAll),
|
||||
("path, spacing", |t| t.path_spacing = 8.0),
|
||||
("path, Optical Margin Alignment", |t| t.optical_margin = true),
|
||||
("path, Roman Hanging Punctuation", |t| t.para.roman_hanging = true),
|
||||
("path, Dynamic Text", |t| t.dynamic_text = true),
|
||||
("path, right to left", |t| t.para.direction = Some(vectorcraft_doc::ParaDirection::RightToLeft)),
|
||||
("path, Auto right to left", |t| {
|
||||
t.para.justify = Justify::Auto;
|
||||
t.para.direction = Some(vectorcraft_doc::ParaDirection::RightToLeft);
|
||||
}),
|
||||
("vertical path", |t| t.vertical = true),
|
||||
("vertical path, Center", |t| {
|
||||
t.vertical = true;
|
||||
t.para.justify = Justify::Center;
|
||||
}),
|
||||
];
|
||||
for (case, edit) in edits {
|
||||
let on = paths(edit);
|
||||
took += same_as_whole_rest(db, &refs(&on), &story(¶graphs(120)), &[], case);
|
||||
took += same_as_whole_rest(db, &refs(&on), &story(&rtl_early), &[], &format!("{case}, right-to-left paragraph early"));
|
||||
took += same_as_whole_rest(db, &refs(&on), &story(&rtl_late), &[], &format!("{case}, right-to-left paragraph late"));
|
||||
took += same_as_whole_rest(db, &refs(&on), &directed, &[], &format!("{case}, right-to-left Character Direction"));
|
||||
took += same_as_whole_rest(db, &refs(&on), &story(&last_long), &[], &format!("{case}, long last paragraph"));
|
||||
// The story's last character does not hang, the last one measured does.
|
||||
took += same_as_whole_rest(db, &refs(&on), &story(&ends_unhung), &[], &format!("{case}, story ending in a letter"));
|
||||
}
|
||||
let on = paths(|_| {});
|
||||
took += same_as_whole_rest(db, &refs(&on), &styled, &[], "path, style runs");
|
||||
took += same_as_whole_rest(db, &refs(&on), &story(¶graphs(120)), &listed, "path, lists");
|
||||
took += same_as_whole_rest(db, &refs(&on), &story(&breaks), &[], "path, spaces and empty paragraphs");
|
||||
let story_paras: Vec<ParaStyle> = (0..120)
|
||||
.map(|i| ParaStyle { justify: [Justify::Left, Justify::Center, Justify::Auto][i % 3], roman_hanging: i % 5 == 0, ..Default::default() })
|
||||
.collect();
|
||||
took += same_as_whole_rest(db, &refs(&on), &story(¶graphs(120)), &story_paras, "path, paragraph styles");
|
||||
let mut between = frames.clone();
|
||||
for (k, f) in between.iter_mut().enumerate() {
|
||||
if k % 2 == 1 {
|
||||
*f = path_frame(260.0);
|
||||
} else {
|
||||
f.area.vertical_align = VerticalAlign::Bottom;
|
||||
}
|
||||
}
|
||||
took += same_as_whole_rest(db, &refs(&between), &story(¶graphs(120)), &[], "paths between area type");
|
||||
|
||||
// Short stories, an empty one and one of empty runs.
|
||||
took += same_as_whole_rest(db, &refs(&frames), &story("Hi"), &[], "short story");
|
||||
took += same_as_whole_rest(db, &refs(&frames), &story(""), &[], "empty story");
|
||||
@@ -674,6 +800,9 @@ fn seeded_stories_take_the_shares_the_whole_rest_gives_them() {
|
||||
let mut paras = 1;
|
||||
for _ in 0..(150 + next(250)) {
|
||||
let mut text = words[next(words.len() as u64) as usize].to_string();
|
||||
if next(60) == 0 {
|
||||
text = "\u{5e9}\u{5dc}\u{5d5}\u{5dd}".to_string();
|
||||
}
|
||||
text.push(if next(6) == 0 {
|
||||
paras += 1;
|
||||
'\n'
|
||||
@@ -692,30 +821,80 @@ fn seeded_stories_take_the_shares_the_whole_rest_gives_them() {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let frames: Vec<TextObject> = (0..2 + next(6)).map(|k| sized(200.0 * k as f64, 40.0 + next(120) as f64, 10.0 + next(120) as f64)).collect();
|
||||
let mut frames: Vec<TextObject> =
|
||||
(0..2 + next(6)).map(|k| sized(200.0 * k as f64, 40.0 + next(120) as f64, 10.0 + next(120) as f64)).collect();
|
||||
// Lines aligned every way, and type on a path, horizontal or vertical.
|
||||
for f in &mut frames {
|
||||
use vectorcraft_doc::VerticalAlign;
|
||||
f.area.vertical_align = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify][next(4) as usize];
|
||||
if next(4) == 0 {
|
||||
*f = if next(2) == 0 { path_frame(80.0 + next(400) as f64) } else { curved_path_frame() };
|
||||
f.vertical = next(3) == 0;
|
||||
}
|
||||
}
|
||||
same_as_whole_rest(db, &frames.iter().collect::<Vec<_>>(), &runs, &styles, &format!("case {case}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A long story of short paragraphs through many frames: each frame is measured with about the
|
||||
/// text it takes and a little more, not with the whole rest of the story.
|
||||
#[test]
|
||||
fn many_frames_measure_about_the_text_they_take() {
|
||||
let db = FontDb::global();
|
||||
/// Each of `frames` takes its share of a long story of short paragraphs measured with about the text
|
||||
/// it takes and a little more, not with the whole rest of the story.
|
||||
fn measures_about_the_text_taken(db: &FontDb, frames: &[TextObject], case: &str) {
|
||||
let text = paragraphs(1500);
|
||||
let story = vec![TextRun::new(text.clone(), CharStyle::default())];
|
||||
let frames: Vec<TextObject> = (0..40).map(|k| frame(200.0 * k as f64)).collect();
|
||||
let refs: Vec<&TextObject> = frames.iter().collect();
|
||||
let before = fit_bytes();
|
||||
let parts = distribute(db, &refs, &story, &[]);
|
||||
let measured = fit_bytes() - before;
|
||||
let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect();
|
||||
assert_eq!(joined, text, "no character lost or duplicated");
|
||||
assert_eq!(joined, text, "{case}: no character lost or duplicated");
|
||||
let placed: usize = parts[..parts.len() - 1].iter().map(|p| runs_len(&p.runs)).sum();
|
||||
let longest = text.split('\n').map(str::len).max().unwrap_or(0);
|
||||
assert!(placed > 0 && placed < text.len() / 4, "the frames hold part of the story: {placed} of {}", text.len());
|
||||
assert!(placed > 0 && placed < text.len() / 4, "{case}: the frames hold part of the story: {placed} of {}", text.len());
|
||||
let bound = 4 * placed + frames.len() * 2 * (MIN_MEASURE + longest);
|
||||
assert!(measured <= bound, "measured {measured} bytes for {placed} placed in {} frames (at most {bound})", frames.len());
|
||||
assert!(measured <= bound, "{case}: measured {measured} bytes for {placed} placed in {} frames (at most {bound})", frames.len());
|
||||
}
|
||||
|
||||
/// A long story of short paragraphs through many frames: each frame is measured with about the
|
||||
/// text it takes and a little more, not with the whole rest of the story. That holds for area type
|
||||
/// aligned to the top, the center or the bottom or justified vertically, in rectangles and other
|
||||
/// shapes, and for type on a path whose line starts at the start bracket, horizontal or vertical.
|
||||
#[test]
|
||||
fn many_frames_measure_about_the_text_they_take() {
|
||||
use vectorcraft_doc::VerticalAlign;
|
||||
let db = FontDb::global();
|
||||
let frames: Vec<TextObject> = (0..40).map(|k| frame(200.0 * k as f64)).collect();
|
||||
measures_about_the_text_taken(db, &frames, "top");
|
||||
for align in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
|
||||
let mut aligned = frames.clone();
|
||||
for f in &mut aligned {
|
||||
f.area.vertical_align = align;
|
||||
}
|
||||
measures_about_the_text_taken(db, &aligned, &format!("{align:?}"));
|
||||
let mut shaped = aligned.clone();
|
||||
for (k, f) in shaped.iter_mut().enumerate() {
|
||||
let x = 200.0 * k as f64;
|
||||
f.kind = TextKind::Area { frame: shapes::ellipse(Rect::new(x, 0.0, x + 160.0, 90.0)) };
|
||||
}
|
||||
measures_about_the_text_taken(db, &shaped, &format!("{align:?}, ellipses"));
|
||||
}
|
||||
for justify in [Justify::Left, Justify::JustifyLeft, Justify::Auto] {
|
||||
let paths: Vec<TextObject> = (0..40)
|
||||
.map(|_| {
|
||||
let mut t = path_frame(300.0);
|
||||
t.para.justify = justify;
|
||||
t
|
||||
})
|
||||
.collect();
|
||||
measures_about_the_text_taken(db, &paths, &format!("path, {justify:?}"));
|
||||
}
|
||||
let vertical: Vec<TextObject> = (0..40)
|
||||
.map(|_| {
|
||||
let mut t = path_frame(300.0);
|
||||
t.vertical = true;
|
||||
t
|
||||
})
|
||||
.collect();
|
||||
measures_about_the_text_taken(db, &vertical, "vertical path");
|
||||
}
|
||||
|
||||
/// Frames that already hold text and take a story over several measurements each: each frame's own
|
||||
|
||||
@@ -26,12 +26,14 @@
|
||||
mod area;
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, ParamCurve, PathData, PathSeg, Point, Rect, Vec2, shapes};
|
||||
use vectorcraft_pathops as po;
|
||||
|
||||
use super::{FEEDBACK, blob_tip, eraser_tip, points_json, polyline};
|
||||
use super::{FEEDBACK, blob_area, blob_tip, eraser_tip, points_json, polyline};
|
||||
use crate::guides::DrawSnap;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PenPose, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
use area::{AreaKey, AreaPreview};
|
||||
@@ -40,6 +42,32 @@ use area::{AreaKey, AreaPreview};
|
||||
/// Pencil, Paintbrush or Blob Brush uses the same radius.
|
||||
const SMOOTH_RADIUS: f64 = 12.0;
|
||||
|
||||
/// The most points of a drag that one step of its band adds, as when the band is outlined again
|
||||
/// at a new zoom.
|
||||
const BAND_STEP: usize = 32;
|
||||
/// The most steps one call adds to a band: a band outlined again for a long drag shows in full
|
||||
/// over several frames ([`Tool::overlays_pending`]).
|
||||
const BAND_STEPS_PER_CALL: usize = 8;
|
||||
/// The side of the squares a smoothing drag's band is kept in, in radii: a step of the drag adds
|
||||
/// its area only to the squares it reaches, so the work of a step does not grow with the drag.
|
||||
const BAND_TILE_RADII: f64 = 8.0;
|
||||
/// Where the grid of those squares starts, in squares from the origin, so that the edge of the
|
||||
/// band of a drag along whole coordinates does not run along a side of a square.
|
||||
const BAND_TILE_OFFSET: f64 = 0.381_966;
|
||||
/// The most squares one segment of a drag is checked against: a segment longer than 2,048 squares
|
||||
/// is kept outside the squares, and shows as the two sides of its band.
|
||||
const BAND_TILE_SAMPLES: f64 = 4096.0;
|
||||
/// The side of the cells of a coarser grid, in squares: each cell lists the squares and the long
|
||||
/// segments in it, so that a new square or long segment is checked only against those near it.
|
||||
const BAND_CELL_TILES: i64 = 1024;
|
||||
/// The most points, half a cell apart, along a long segment at which its cells are listed: a
|
||||
/// longer segment is listed in no cell and checked against every square and long segment.
|
||||
const BAND_CELL_SAMPLES: f64 = 4096.0;
|
||||
/// The most work one call does on the band's long segments: comparisons of a long segment with a
|
||||
/// square or another long segment, and pieces of an outline tested. The rest waits for the next
|
||||
/// call ([`Tool::overlays_pending`]), and the band shown until then is the last one finished.
|
||||
const BAND_WORK_PER_CALL: usize = 20_000;
|
||||
|
||||
/// The finest Fidelity (screen pixels) a Pencil or Paintbrush stroke is fitted at while it is drawn:
|
||||
/// a fit's cost grows as its Fidelity shrinks (at 0.01 pt a long pen stroke lagged, #4565), and
|
||||
/// a preview within half a pixel of the stroke looks the same. The release fits it at its Fidelity.
|
||||
@@ -150,6 +178,584 @@ pub struct GestureTool {
|
||||
area: Cell<Option<AreaPreview>>,
|
||||
/// The last [`Tool::overlays`] left part of that outline for later calls.
|
||||
area_pending: Cell<bool>,
|
||||
/// The band the smoothing drag has shown so far ([`GestureTool::smooth_band`]).
|
||||
band: Cell<Option<SmoothBand>>,
|
||||
/// The band last shown does not cover the whole drag yet; the next call adds to it.
|
||||
band_pending: Cell<bool>,
|
||||
}
|
||||
|
||||
/// The band a smoothing drag smooths, for the drag's first `used` points at `radius`, kept as its
|
||||
/// area in each square of a grid that it reaches.
|
||||
struct SmoothBand {
|
||||
radius: f64,
|
||||
used: usize,
|
||||
first: Point,
|
||||
last: Point,
|
||||
tiles: BTreeMap<(i64, i64), BandTile>,
|
||||
/// The segments of the drag too long for the squares ([`BAND_TILE_SAMPLES`]).
|
||||
long: Vec<LongSegment>,
|
||||
/// The squares and long segments listed in each cell of a coarser grid ([`BAND_CELL_TILES`]).
|
||||
cells: BTreeMap<(i64, i64), BandCell>,
|
||||
/// The long segments listed in no cell ([`BAND_CELL_SAMPLES`]).
|
||||
wide: Vec<usize>,
|
||||
/// The squares near a long segment whose `cut` is to be made again.
|
||||
dirty_tiles: BTreeSet<(i64, i64)>,
|
||||
/// The long segments whose `drawn` is to be made again.
|
||||
dirty_long: BTreeSet<usize>,
|
||||
/// The band changed since `shown` was made.
|
||||
changed: bool,
|
||||
/// The outline last finished.
|
||||
shown: Option<BezPath>,
|
||||
/// The work done on long segments in the current call ([`BAND_WORK_PER_CALL`]).
|
||||
work: usize,
|
||||
}
|
||||
|
||||
/// The squares and long segments (indexes into [`SmoothBand::long`]) in one cell of the coarser grid.
|
||||
#[derive(Default)]
|
||||
struct BandCell {
|
||||
tiles: Vec<(i64, i64)>,
|
||||
long: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The part of a band in one square of the grid, and its outline less the square's sides.
|
||||
struct BandTile {
|
||||
area: PathData,
|
||||
outline: BezPath,
|
||||
/// The long segments whose band can reach the square (indexes into [`SmoothBand::long`]).
|
||||
near_long: Vec<usize>,
|
||||
/// `outline` less its parts inside the band of a long segment in `near_long`.
|
||||
cut: BezPath,
|
||||
}
|
||||
|
||||
/// A segment of a drag too long for the squares, from `a` to `b`, with the part of it outside the
|
||||
/// squares (`from` and `to`: 0 at `a`, 1 at `b`): the squares hold its band for a square's side
|
||||
/// from each end ([`SmoothBand::add_long`]).
|
||||
struct LongSegment {
|
||||
a: Point,
|
||||
b: Point,
|
||||
from: f64,
|
||||
to: f64,
|
||||
/// The squares its band can reach.
|
||||
near: BTreeSet<(i64, i64)>,
|
||||
/// The other long segments whose band can meet its band.
|
||||
meets: Vec<usize>,
|
||||
/// The parts of the two sides of its band that show.
|
||||
drawn: BezPath,
|
||||
}
|
||||
|
||||
impl LongSegment {
|
||||
/// The two sides of its band at `radius`, over its part outside the squares.
|
||||
fn sides(&self, radius: f64) -> [vectorcraft_geom::Line; 2] {
|
||||
let d = self.b - self.a;
|
||||
let s = Vec2::new(-d.y, d.x) * (radius / d.hypot());
|
||||
let (p, q) = (self.a.lerp(self.b, self.from), self.a.lerp(self.b, self.to));
|
||||
[vectorcraft_geom::Line::new(p + s, q + s), vectorcraft_geom::Line::new(p - s, q - s)]
|
||||
}
|
||||
}
|
||||
|
||||
impl SmoothBand {
|
||||
/// The band of a drag that starts at `first`, before it has moved.
|
||||
fn new(first: Point, radius: f64) -> Self {
|
||||
Self {
|
||||
radius,
|
||||
used: 1,
|
||||
first,
|
||||
last: first,
|
||||
tiles: BTreeMap::new(),
|
||||
long: vec![],
|
||||
cells: BTreeMap::new(),
|
||||
wide: vec![],
|
||||
dirty_tiles: BTreeSet::new(),
|
||||
dirty_long: BTreeSet::new(),
|
||||
changed: true,
|
||||
shown: None,
|
||||
work: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell of the coarser grid that holds square `key`.
|
||||
fn cell_of((ix, iy): (i64, i64)) -> (i64, i64) {
|
||||
(ix.div_euclid(BAND_CELL_TILES), iy.div_euclid(BAND_CELL_TILES))
|
||||
}
|
||||
|
||||
/// The cells around points half a cell apart along the segment from `a` to `b`: every cell
|
||||
/// holding a square its band can reach, or a point within the radius of its band. `None` for
|
||||
/// a segment longer than [`BAND_CELL_SAMPLES`] half cells or reaching far outside any document.
|
||||
fn cells_along(&self, a: Point, b: Point) -> Option<BTreeSet<(i64, i64)>> {
|
||||
let n = (a.distance(b) / (0.5 * BAND_CELL_TILES as f64 * self.side())).ceil().max(1.0);
|
||||
if !n.is_finite() || n > BAND_CELL_SAMPLES {
|
||||
return None;
|
||||
}
|
||||
let mut cells = BTreeSet::new();
|
||||
for k in 0..=(n as u32) {
|
||||
let (cx, cy) = Self::cell_of(self.tile_of(a.lerp(b, f64::from(k) / n))?);
|
||||
for (dx, dy) in [(-1, -1), (0, -1), (1, -1), (-1, 0), (0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
|
||||
cells.insert((cx + dx, cy + dy));
|
||||
}
|
||||
}
|
||||
Some(cells)
|
||||
}
|
||||
|
||||
/// Whether the band of the segment from `a` to `b` can reach square `key`.
|
||||
fn reaches(&self, key: (i64, i64), a: Point, b: Point) -> bool {
|
||||
distance_to_segment(self.tile_rect(key).center(), a, b) <= self.radius + 0.5 * std::f64::consts::SQRT_2 * self.side()
|
||||
}
|
||||
|
||||
/// Whether the segment from `a` to `b` is too long for the squares: longer than
|
||||
/// [`BAND_TILE_SAMPLES`] half squares.
|
||||
fn too_long(&self, a: Point, b: Point) -> bool {
|
||||
let n = a.distance(b) / (0.5 * self.side());
|
||||
n.is_finite() && n > BAND_TILE_SAMPLES
|
||||
}
|
||||
|
||||
/// Add the band of the segment from `a` to `b`, too long for the squares: the squares take its
|
||||
/// band for a square's side from each end, where they can, and the rest is kept as the
|
||||
/// segment.
|
||||
fn add_long(&mut self, a: Point, b: Point) {
|
||||
let d = b - a;
|
||||
let stub = d * (self.side() / d.hypot());
|
||||
let from = if self.grow(&[a, a + stub]).is_some() { self.side() / d.hypot() } else { 0.0 };
|
||||
let to = if self.grow(&[b - stub, b]).is_some() { 1.0 - self.side() / d.hypot() } else { 1.0 };
|
||||
let i = self.long.len();
|
||||
// The squares and long segments listed in its cells, or all of them.
|
||||
let mut tiles = BTreeSet::new();
|
||||
let mut others: BTreeSet<usize> = self.wide.iter().copied().collect();
|
||||
match self.cells_along(a, b) {
|
||||
Some(cells) => {
|
||||
self.work += cells.len();
|
||||
for c in cells {
|
||||
let cell = self.cells.entry(c).or_default();
|
||||
tiles.extend(cell.tiles.iter().copied());
|
||||
others.extend(cell.long.iter().copied());
|
||||
cell.long.push(i);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tiles.extend(self.tiles.keys().copied());
|
||||
others.extend(0..i);
|
||||
self.wide.push(i);
|
||||
}
|
||||
}
|
||||
self.work += tiles.len() + others.len();
|
||||
let near: BTreeSet<_> = tiles.into_iter().filter(|&k| self.reaches(k, a, b)).collect();
|
||||
for k in &near {
|
||||
if let Some(t) = self.tiles.get_mut(k) {
|
||||
t.near_long.push(i);
|
||||
self.dirty_tiles.insert(*k);
|
||||
}
|
||||
}
|
||||
let reach = 2.0 * self.radius + 2e-3 * self.radius;
|
||||
let meets: Vec<usize> = others.into_iter().filter(|&j| self.long.get(j).is_some_and(|o| segment_distance(a, b, o.a, o.b) <= reach)).collect();
|
||||
for &j in &meets {
|
||||
if let Some(o) = self.long.get_mut(j) {
|
||||
o.meets.push(i);
|
||||
self.dirty_long.insert(j);
|
||||
}
|
||||
}
|
||||
self.long.push(LongSegment { a, b, from, to, near, meets, drawn: BezPath::new() });
|
||||
self.dirty_long.insert(i);
|
||||
self.last = b;
|
||||
self.changed = true;
|
||||
}
|
||||
|
||||
/// The side of the grid's squares.
|
||||
fn side(&self) -> f64 {
|
||||
BAND_TILE_RADII * self.radius
|
||||
}
|
||||
|
||||
/// The square of the grid `key` names.
|
||||
fn tile_rect(&self, (ix, iy): (i64, i64)) -> Rect {
|
||||
let s = self.side();
|
||||
let (x0, y0) = ((ix as f64 + BAND_TILE_OFFSET) * s, (iy as f64 + BAND_TILE_OFFSET) * s);
|
||||
Rect::new(x0, y0, x0 + s, y0 + s)
|
||||
}
|
||||
|
||||
/// The square of the grid that holds `p`; `None` far outside any document.
|
||||
fn tile_of(&self, p: Point) -> Option<(i64, i64)> {
|
||||
let s = self.side();
|
||||
let (x, y) = ((p.x / s - BAND_TILE_OFFSET).floor(), (p.y / s - BAND_TILE_OFFSET).floor());
|
||||
(x.abs() < 1e15 && y.abs() < 1e15).then_some((x as i64, y as i64))
|
||||
}
|
||||
|
||||
/// The squares within the radius of `piece`: those around points along it half a square apart.
|
||||
fn tiles_near(&self, piece: &[Point]) -> Option<BTreeSet<(i64, i64)>> {
|
||||
let mut keys = BTreeSet::new();
|
||||
for w in piece.windows(2) {
|
||||
let n = (w[0].distance(w[1]) / (0.5 * self.side())).ceil().max(1.0);
|
||||
if !n.is_finite() || n > BAND_TILE_SAMPLES {
|
||||
return None;
|
||||
}
|
||||
for k in 0..=(n as u32) {
|
||||
let (ix, iy) = self.tile_of(w[0].lerp(w[1], f64::from(k) / n))?;
|
||||
for (dx, dy) in [(-1, -1), (0, -1), (1, -1), (-1, 0), (0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
|
||||
keys.insert((ix + dx, iy + dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(keys)
|
||||
}
|
||||
|
||||
/// Add the area within the radius of `piece`, the drag from the last point covered so far, to
|
||||
/// the squares it reaches. `None`, with the band unchanged, when the outlines cannot be
|
||||
/// combined.
|
||||
fn grow(&mut self, piece: &[Point]) -> Option<()> {
|
||||
let (r, nz) = (self.radius, FillRule::NonZero);
|
||||
let end = *piece.last()?;
|
||||
// Within a hundredth of the radius at any zoom.
|
||||
let precision = (0.01 * r).min(po::DEFAULT_PRECISION);
|
||||
let keys = self.tiles_near(piece)?;
|
||||
let added = blob_area(piece, 2.0 * r, 0.0, 100.0);
|
||||
let mut changed = vec![];
|
||||
for key in keys {
|
||||
let rect = self.tile_rect(key);
|
||||
let clip = po::try_boolean(&added, nz, &shapes::rectangle(rect), nz, po::BoolOp::Intersect, precision).ok()?;
|
||||
if clip.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let area = match self.tiles.get(&key) {
|
||||
Some(t) => po::try_boolean(&t.area, nz, &clip, nz, po::BoolOp::Union, precision).ok().filter(|a| !a.is_empty())?,
|
||||
None => clip,
|
||||
};
|
||||
changed.push((key, area));
|
||||
}
|
||||
// A square's outline depends on the squares across its sides, so those are outlined
|
||||
// again too.
|
||||
let mut redo = BTreeSet::new();
|
||||
for (key @ (ix, iy), area) in changed {
|
||||
redo.extend([(ix, iy), (ix - 1, iy), (ix + 1, iy), (ix, iy - 1), (ix, iy + 1)]);
|
||||
let near_long = match self.tiles.get(&key) {
|
||||
Some(t) => t.near_long.clone(),
|
||||
None => {
|
||||
// The long segments listed in the square's cell, and those in no cell.
|
||||
let cell = self.cells.entry(Self::cell_of(key)).or_default();
|
||||
cell.tiles.push(key);
|
||||
let mut listed: BTreeSet<usize> = cell.long.iter().copied().collect();
|
||||
listed.extend(self.wide.iter().copied());
|
||||
let near: Vec<usize> = listed.into_iter().filter(|&i| self.long.get(i).is_some_and(|l| self.reaches(key, l.a, l.b))).collect();
|
||||
for &i in &near {
|
||||
if let Some(l) = self.long.get_mut(i) {
|
||||
l.near.insert(key);
|
||||
}
|
||||
}
|
||||
near
|
||||
}
|
||||
};
|
||||
// The sides of those long segments are cut where the square's area holds them.
|
||||
self.dirty_long.extend(near_long.iter().copied());
|
||||
self.tiles.insert(key, BandTile { area, outline: BezPath::new(), near_long, cut: BezPath::new() });
|
||||
}
|
||||
self.changed = true;
|
||||
let outlines: Vec<_> = redo.into_iter().filter(|k| self.tiles.contains_key(k)).map(|k| (k, self.tile_outline(k))).collect();
|
||||
for (key, outline) in outlines {
|
||||
if let Some(t) = self.tiles.get_mut(&key) {
|
||||
t.outline = outline;
|
||||
if !t.near_long.is_empty() {
|
||||
self.dirty_tiles.insert(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last = end;
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The band's outline: the last one finished. While the band has changed since, the parts it
|
||||
/// changed are made again, until [`BAND_WORK_PER_CALL`] is spent in the call; when all are
|
||||
/// made, the outline is joined from them ([`SmoothBand::make_outline`]).
|
||||
fn outline(&mut self) -> Option<BezPath> {
|
||||
while self.changed {
|
||||
if !self.dirty_tiles.is_empty() || !self.dirty_long.is_empty() {
|
||||
if self.work >= BAND_WORK_PER_CALL {
|
||||
break;
|
||||
}
|
||||
if let Some(k) = self.dirty_tiles.pop_first() {
|
||||
let (cut, work) = self.tile_cut(k);
|
||||
self.work += work;
|
||||
if let Some(t) = self.tiles.get_mut(&k) {
|
||||
t.cut = cut;
|
||||
}
|
||||
} else if let Some(i) = self.dirty_long.pop_first() {
|
||||
let (drawn, work) = self.long_drawn(i);
|
||||
self.work += work;
|
||||
if let Some(l) = self.long.get_mut(i) {
|
||||
l.drawn = drawn;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.shown = Some(self.make_outline());
|
||||
self.changed = false;
|
||||
}
|
||||
}
|
||||
self.shown.clone()
|
||||
}
|
||||
|
||||
/// The band's outline: every square's, joined, then the parts that show of the two sides of
|
||||
/// the band of each segment too long for the squares. A square near such a segment shows its
|
||||
/// `cut` ([`SmoothBand::tile_cut`]), and such a segment its `drawn` ([`SmoothBand::long_drawn`]).
|
||||
fn make_outline(&self) -> BezPath {
|
||||
let tiles = self.tiles.values().flat_map(|t| (if t.near_long.is_empty() { &t.outline } else { &t.cut }).elements().iter().copied());
|
||||
BezPath::from_vec(tiles.chain(self.long.iter().flat_map(|l| l.drawn.elements().iter().copied())).collect())
|
||||
}
|
||||
|
||||
/// The outline of square `key` less its parts inside the band of a long segment near it (more
|
||||
/// than a thousandth of the radius in), and the work that took.
|
||||
fn tile_cut(&self, key: (i64, i64)) -> (BezPath, usize) {
|
||||
let Some(t) = self.tiles.get(&key) else { return (BezPath::new(), 1) };
|
||||
let (r, eps) = (self.radius, 1e-3 * self.radius);
|
||||
let near: Vec<&LongSegment> = t.near_long.iter().filter_map(|&i| self.long.get(i)).collect();
|
||||
let mut out = BezPath::new();
|
||||
let mut pen = None;
|
||||
let mut work = 1;
|
||||
for seg in t.outline.segments() {
|
||||
// The pieces of the segment between the points where it crosses a side of the band
|
||||
// of a long segment, each left out when it lies inside that band.
|
||||
let mut ts: Vec<f64> = near
|
||||
.iter()
|
||||
.flat_map(|l| l.sides(r))
|
||||
.flat_map(|line| seg.intersect_line(line))
|
||||
.map(|x| x.segment_t)
|
||||
.filter(|t| *t > 0.0 && *t < 1.0)
|
||||
.collect();
|
||||
ts.extend([0.0, 1.0]);
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup();
|
||||
work += near.len() * ts.len();
|
||||
for w in ts.windows(2) {
|
||||
let piece = if ts.len() == 2 { seg } else { seg.subsegment(w[0]..w[1]) };
|
||||
let m = piece.eval(0.5);
|
||||
if near.iter().any(|l| distance_to_segment(m, l.a, l.b) < r - eps) {
|
||||
pen = None;
|
||||
} else {
|
||||
push_seg(&mut out, &mut pen, piece);
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, work)
|
||||
}
|
||||
|
||||
/// The parts that show of the two sides of the band of long segment `i`, outside the squares'
|
||||
/// part of it, and the work that took. Left out: the parts inside the band area of a square,
|
||||
/// inside the band of another long segment (more than a thousandth of the radius in), and on
|
||||
/// a side of an earlier long segment, which already shows. Only the squares and long segments
|
||||
/// near it are checked.
|
||||
fn long_drawn(&self, i: usize) -> (BezPath, usize) {
|
||||
let Some(l) = self.long.get(i) else { return (BezPath::new(), 1) };
|
||||
let (r, eps) = (self.radius, 1e-3 * self.radius);
|
||||
let others: Vec<(usize, &LongSegment)> = l.meets.iter().filter_map(|&j| self.long.get(j).map(|o| (j, o))).collect();
|
||||
// Whether a square's band area holds `q`.
|
||||
let in_tiles = |q: Point| {
|
||||
self.tile_of(q).and_then(|k| self.tiles.get(&k)).is_some_and(|t| vectorcraft_geom::Shape::winding(&t.area.to_bezpath(), q) != 0)
|
||||
};
|
||||
let mut out = BezPath::new();
|
||||
let mut work = 1;
|
||||
for line in l.sides(r) {
|
||||
// Where the side crosses the band area of a square it passes, a side of another long
|
||||
// segment's band, or the end of a side it runs along.
|
||||
let mut ts = vec![0.0, 1.0];
|
||||
for t in l.near.iter().filter_map(|k| self.tiles.get(k)) {
|
||||
ts.extend(t.area.to_bezpath().segments().flat_map(|seg| seg.intersect_line(line)).map(|x| x.line_t));
|
||||
}
|
||||
// The stretches of the side inside the band of another long segment, and those on a
|
||||
// side of an earlier one.
|
||||
let mut inside = vec![];
|
||||
let mut along = vec![];
|
||||
for &(j, other) in &others {
|
||||
inside.extend(capsule_span(line, other.a, other.b, r - eps));
|
||||
for s in other.sides(r) {
|
||||
ts.extend(PathSeg::Line(s).intersect_line(line).into_iter().map(|x| x.line_t));
|
||||
if j < i {
|
||||
ts.extend(
|
||||
[s.p0, s.p1]
|
||||
.into_iter()
|
||||
.filter(|&p| distance_to_segment(p, line.p0, line.p1) <= eps)
|
||||
.map(|p| (p - line.p0).dot(line.p1 - line.p0) / (line.p1 - line.p0).hypot2()),
|
||||
);
|
||||
along.extend(capsule_span(line, s.p0, s.p1, eps));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (inside, along) = (merge_spans(inside), merge_spans(along));
|
||||
ts.retain(|t| (0.0..=1.0).contains(t));
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup();
|
||||
work += l.near.len() + others.len() + ts.len();
|
||||
let mut pen = None;
|
||||
for w in ts.windows(2) {
|
||||
let (p, q) = (line.p0.lerp(line.p1, w[0]), line.p0.lerp(line.p1, w[1]));
|
||||
let mid = 0.5 * (w[0] + w[1]);
|
||||
let hidden = in_tiles(p.midpoint(q)) || spans_cover(&inside, mid, mid) || spans_cover(&along, w[0], w[1]);
|
||||
if !hidden {
|
||||
push_seg(&mut out, &mut pen, PathSeg::Line(vectorcraft_geom::Line::new(p, q)));
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, work)
|
||||
}
|
||||
|
||||
/// The outline of the band's part in square `key`, less the parts along a side of the square
|
||||
/// (within a thousandth of the radius) where the band goes on across that side: where the
|
||||
/// square across it holds band area a thousandth of the radius past it.
|
||||
fn tile_outline(&self, key: (i64, i64)) -> BezPath {
|
||||
let Some(tile) = self.tiles.get(&key) else { return BezPath::new() };
|
||||
let (rect, eps) = (self.tile_rect(key), 1e-3 * self.radius);
|
||||
let (ix, iy) = key;
|
||||
// A point's distance across a side (x for a side that runs up and down) and along it.
|
||||
let split = |upright: bool, p: Point| if upright { (p.x, p.y) } else { (p.y, p.x) };
|
||||
// Each side with band area across it: whether it runs up and down, where it runs, the
|
||||
// direction away from this square, the area across, and where along the side that area's
|
||||
// outline has a point on the side (where the area across can start or stop).
|
||||
let sides: Vec<_> = [
|
||||
(true, rect.x0, (ix - 1, iy), -1.0),
|
||||
(true, rect.x1, (ix + 1, iy), 1.0),
|
||||
(false, rect.y0, (ix, iy - 1), -1.0),
|
||||
(false, rect.y1, (ix, iy + 1), 1.0),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(upright, at, k, away)| {
|
||||
let area = self.tiles.get(&k)?.area.to_bezpath();
|
||||
let cuts: Vec<f64> = area
|
||||
.segments()
|
||||
.flat_map(|s| [s.start(), s.end()])
|
||||
.map(|p| split(upright, p))
|
||||
.filter(|&(off, _)| (off - at).abs() <= eps)
|
||||
.map(|(_, along)| along)
|
||||
.collect();
|
||||
Some((upright, at, away, area, cuts))
|
||||
})
|
||||
.collect();
|
||||
let mut out = BezPath::new();
|
||||
let mut pen = None;
|
||||
for seg in tile.area.to_bezpath().segments() {
|
||||
let (a, b, mid) = (seg.start(), seg.end(), seg.eval(0.5));
|
||||
let Some((upright, at, away, area, cuts)) = sides.iter().find(|s| [a, b, mid].iter().all(|&p| (split(s.0, p).0 - s.1).abs() <= eps))
|
||||
else {
|
||||
push_seg(&mut out, &mut pen, seg);
|
||||
continue;
|
||||
};
|
||||
// The parts of the segment between the points where the area across starts or stops
|
||||
// (more than `eps` from its ends), each kept unless the area across holds the point
|
||||
// `eps` past the side beside it: the square across holds band area there exactly when
|
||||
// its own edge of the band lies farther out than `eps`, and so shows.
|
||||
let (la, lb) = (split(*upright, a).1, split(*upright, b).1);
|
||||
let mut ts: Vec<f64> = cuts
|
||||
.iter()
|
||||
.filter(|&&c| (c - la) * (c - lb) < 0.0 && (c - la).abs() > eps && (c - lb).abs() > eps)
|
||||
.map(|&c| (c - la) / (lb - la))
|
||||
.collect();
|
||||
ts.sort_by(f64::total_cmp);
|
||||
let whole = ts.is_empty();
|
||||
ts.insert(0, 0.0);
|
||||
ts.push(1.0);
|
||||
for w in ts.windows(2) {
|
||||
let (p, q) = (a.lerp(b, w[0]), a.lerp(b, w[1]));
|
||||
let m = if whole { mid } else { p.midpoint(q) };
|
||||
let past = at + away * eps;
|
||||
let probe = if *upright { Point::new(past, m.y) } else { Point::new(m.x, past) };
|
||||
if vectorcraft_geom::Shape::winding(area, probe) != 0 {
|
||||
pen = None;
|
||||
} else if whole {
|
||||
push_seg(&mut out, &mut pen, seg);
|
||||
} else {
|
||||
push_seg(&mut out, &mut pen, PathSeg::Line(vectorcraft_geom::Line::new(p, q)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `seg` to `out`, starting a new subpath unless it starts at `pen`, where the last one ended.
|
||||
fn push_seg(out: &mut BezPath, pen: &mut Option<Point>, seg: PathSeg) {
|
||||
let a = seg.start();
|
||||
if *pen != Some(a) {
|
||||
out.move_to(a);
|
||||
}
|
||||
match seg {
|
||||
PathSeg::Line(l) => out.line_to(l.p1),
|
||||
PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
|
||||
PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
|
||||
}
|
||||
*pen = Some(seg.end());
|
||||
}
|
||||
|
||||
/// How far `q` is from the segment from `a` to `b`.
|
||||
fn distance_to_segment(q: Point, a: Point, b: Point) -> f64 {
|
||||
let d = b - a;
|
||||
let t = if d.hypot2() > 0.0 { ((q - a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
q.distance(a + d * t)
|
||||
}
|
||||
|
||||
/// How far the segment from `a` to `b` is from the segment from `c` to `d`.
|
||||
fn segment_distance(a: Point, b: Point, c: Point, d: Point) -> f64 {
|
||||
let side = |p: Point, q: Point, s: Point| (q - p).cross(s - p);
|
||||
if side(a, b, c) * side(a, b, d) < 0.0 && side(c, d, a) * side(c, d, b) < 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
distance_to_segment(a, c, d).min(distance_to_segment(b, c, d)).min(distance_to_segment(c, a, b)).min(distance_to_segment(d, a, b))
|
||||
}
|
||||
|
||||
/// The stretch of `line`, as `line_t` from and to (0 at its start, 1 at its end, and beyond),
|
||||
/// within `rad` of the segment from `a` to `b`; `None` when no point of the line is.
|
||||
fn capsule_span(line: vectorcraft_geom::Line, a: Point, b: Point, rad: f64) -> Option<(f64, f64)> {
|
||||
let (p, d) = (line.p0, line.p1 - line.p0);
|
||||
let dd = d.hypot2();
|
||||
if dd <= 0.0 || rad.is_nan() || rad < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
// The round ends.
|
||||
for c in [a, b] {
|
||||
let f = p - c;
|
||||
let (half_b, k) = (d.dot(f), f.hypot2() - rad * rad);
|
||||
let disc = half_b * half_b - dd * k;
|
||||
if disc >= 0.0 {
|
||||
let root = disc.sqrt();
|
||||
lo = lo.min((-half_b - root) / dd);
|
||||
hi = hi.max((-half_b + root) / dd);
|
||||
}
|
||||
}
|
||||
// The part between them: along the segment from 0 to its length, and across it within `rad`.
|
||||
let e = b - a;
|
||||
let len = e.hypot();
|
||||
if len > 0.0 {
|
||||
let u = e / len;
|
||||
let n = Vec2::new(-u.y, u.x);
|
||||
let (mut s0, mut s1) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
for (at, rate, min, max) in [((p - a).dot(u), d.dot(u), 0.0, len), ((p - a).dot(n), d.dot(n), -rad, rad)] {
|
||||
if rate == 0.0 {
|
||||
if at < min || at > max {
|
||||
s1 = f64::NEG_INFINITY;
|
||||
}
|
||||
} else {
|
||||
let (x, y) = ((min - at) / rate, (max - at) / rate);
|
||||
s0 = s0.max(x.min(y));
|
||||
s1 = s1.min(x.max(y));
|
||||
}
|
||||
}
|
||||
if s0 <= s1 {
|
||||
lo = lo.min(s0);
|
||||
hi = hi.max(s1);
|
||||
}
|
||||
}
|
||||
(lo <= hi).then_some((lo, hi))
|
||||
}
|
||||
|
||||
/// `spans` sorted and joined where they overlap.
|
||||
fn merge_spans(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
|
||||
spans.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||||
let mut out: Vec<(f64, f64)> = Vec::with_capacity(spans.len());
|
||||
for (a, b) in spans {
|
||||
match out.last_mut() {
|
||||
Some(last) if a <= last.1 => last.1 = last.1.max(b),
|
||||
_ => out.push((a, b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether one of `spans` (from [`merge_spans`]) holds everything from `from` to `to`.
|
||||
fn spans_cover(spans: &[(f64, f64)], from: f64, to: f64) -> bool {
|
||||
let i = spans.partition_point(|s| s.0 <= from);
|
||||
i > 0 && spans.get(i - 1).is_some_and(|s| s.1 >= to)
|
||||
}
|
||||
|
||||
/// The paths a stroke can redraw for selected object `id`: the path itself, or a compound path's
|
||||
@@ -232,6 +838,8 @@ impl GestureTool {
|
||||
length: 0.0,
|
||||
area: Cell::new(None),
|
||||
area_pending: Cell::new(false),
|
||||
band: Cell::new(None),
|
||||
band_pending: Cell::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,6 +983,56 @@ impl GestureTool {
|
||||
self.length = 0.0;
|
||||
self.area.set(None);
|
||||
self.area_pending.set(false);
|
||||
self.band.set(None);
|
||||
self.band_pending.set(false);
|
||||
}
|
||||
|
||||
/// The outline of the band a smoothing drag smooths: every point within `radius` of the drag,
|
||||
/// round at both ends. The band shown last is kept and grown by the steps dragged since, at
|
||||
/// most [`BAND_STEP`] points at a time and [`BAND_STEPS_PER_CALL`] steps a call; a new radius
|
||||
/// (the zoom changed) or another drag starts it again. While the band covers less of the drag
|
||||
/// than it can, [`Tool::overlays_pending`] holds, so the host draws again and the band reaches
|
||||
/// the whole drag with the pointer still. When a step cannot be added whole, its segments are
|
||||
/// added one at a time: a segment longer than [`BAND_TILE_SAMPLES`] half squares is kept
|
||||
/// outside the squares, and one whose outlines cannot be combined adds nothing. Either way the
|
||||
/// band goes on past it. The work on such long segments stops for the call once
|
||||
/// [`BAND_WORK_PER_CALL`] is spent, and the drag is added to the band only once the outline of
|
||||
/// what it holds is finished.
|
||||
fn smooth_band(&self, radius: f64) -> Option<BezPath> {
|
||||
let pts = &self.points;
|
||||
let &first = pts.first()?;
|
||||
let mut b = self
|
||||
.band
|
||||
.take()
|
||||
.filter(|b| b.radius == radius && b.first == first && b.used.checked_sub(1).and_then(|i| pts.get(i)) == Some(&b.last))
|
||||
.unwrap_or_else(|| SmoothBand::new(first, radius));
|
||||
b.work = 0;
|
||||
let mut outline = b.outline();
|
||||
if !b.changed {
|
||||
for _ in 0..BAND_STEPS_PER_CALL {
|
||||
let Some(from) = b.used.checked_sub(1).filter(|_| b.used < pts.len() && b.work < BAND_WORK_PER_CALL) else { break };
|
||||
let mut to = (from + BAND_STEP).min(pts.len() - 1);
|
||||
let Some(piece) = pts.get(from..=to) else { break };
|
||||
if b.grow(piece).is_none() {
|
||||
for (k, w) in piece.windows(2).enumerate() {
|
||||
if b.work >= BAND_WORK_PER_CALL {
|
||||
to = from + k;
|
||||
break;
|
||||
}
|
||||
if b.too_long(w[0], w[1]) {
|
||||
b.add_long(w[0], w[1]);
|
||||
} else if b.grow(w).is_none() {
|
||||
b.last = w[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
b.used = to + 1;
|
||||
}
|
||||
outline = b.outline();
|
||||
}
|
||||
self.band_pending.set(b.used < pts.len() || b.changed);
|
||||
self.band.set(Some(b));
|
||||
outline.filter(|o| !o.elements().is_empty())
|
||||
}
|
||||
|
||||
/// Does the tool's Size take Fixed or Pressure (the Blob Brush and the Eraser)?
|
||||
@@ -752,9 +1410,10 @@ impl Tool for GestureTool {
|
||||
fn wants_samples(&self) -> bool {
|
||||
matches!(self.id, "pencil" | "paintbrush" | "blobBrush" | "eraser" | "knife" | "smooth" | "pathEraser")
|
||||
}
|
||||
/// The outline of a long Blob Brush or Eraser drag reaches the whole drag over several frames.
|
||||
/// The outline of a long Blob Brush or Eraser drag, and a smoothing drag's band outlined again
|
||||
/// after a zoom change, reach the whole drag over several frames.
|
||||
fn overlays_pending(&self) -> bool {
|
||||
self.area_pending.get()
|
||||
self.area_pending.get() || self.band_pending.get()
|
||||
}
|
||||
/// `[` and `]` are the Blob Brush's and the Eraser's: their size (#1661).
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
@@ -807,12 +1466,21 @@ impl Tool for GestureTool {
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
self.area_pending.set(false);
|
||||
self.band_pending.set(false);
|
||||
let mut o = self.snap.guides().to_vec();
|
||||
// A path being drawn shows itself, except with the Pencil's Live preview off.
|
||||
if !self.active || self.points.len() < 2 || (self.began && self.previews()) {
|
||||
o.extend(self.tip_outline(cx));
|
||||
return o;
|
||||
}
|
||||
// A smoothing drag with a selected path it can smooth also shows, dashed, the band its
|
||||
// release smooths.
|
||||
if (self.id == "smooth" || self.smoothing)
|
||||
&& has_selected_smooth_path(cx)
|
||||
&& let Some(path) = self.smooth_band(cx.tol(SMOOTH_RADIUS))
|
||||
{
|
||||
o.push(Overlay::Path { path, color: FEEDBACK, width: 1.0, dashed: true });
|
||||
}
|
||||
// A Blob Brush drag that smooths shows the Smooth tool's thin line.
|
||||
let width = match self.id {
|
||||
"blobBrush" | "eraser" if !self.smoothing => (self.size * cx.zoom) as f32,
|
||||
|
||||
@@ -1037,6 +1037,439 @@ fn pencil_alt_smooths_within_the_smooth_tools_radius() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The dashed paths among the overlays.
|
||||
fn bands(t: &GestureTool, cx: &ToolContext) -> Vec<vectorcraft_geom::BezPath> {
|
||||
t.overlays(cx).into_iter().filter_map(|o| if let Overlay::Path { path, dashed: true, .. } = o { Some(path) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// How far `q` is from the polyline through `pts`.
|
||||
fn dist_to_drag(q: Point, pts: &[Point]) -> f64 {
|
||||
pts.windows(2)
|
||||
.map(|w| {
|
||||
let ab = w[1] - w[0];
|
||||
let t = if ab.hypot2() > 0.0 { ((q - w[0]).dot(ab) / ab.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
q.distance(w[0] + ab * t)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// Check the band a smoothing drag through the tool's points shows at radius `r`: one dashed path,
|
||||
/// every point of which is `r` from the drag, around an area that holds every point nearer than `r`
|
||||
/// and none farther. Returns the path.
|
||||
fn check_band(t: &GestureTool, cx: &ToolContext, r: f64) -> vectorcraft_geom::BezPath {
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
let pts = &t.points;
|
||||
let found = bands(t, cx);
|
||||
let [outline] = found.as_slice() else { panic!("one band, got {found:?}") };
|
||||
for seg in outline.segments() {
|
||||
for s in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
let q = seg.eval(s);
|
||||
let dq = dist_to_drag(q, pts);
|
||||
assert!((dq - r).abs() < 0.02 * r, "{q:?} on the outline is {dq} from the drag, radius {r}");
|
||||
}
|
||||
}
|
||||
let kept = t.band.take();
|
||||
let areas: Vec<_> = kept.iter().flat_map(|b| b.tiles.values().map(|t| t.area.to_bezpath())).collect();
|
||||
t.band.set(kept);
|
||||
let b = outline.bounding_box();
|
||||
let mut probes = 0;
|
||||
for i in 0..=80 {
|
||||
for j in 0..=80 {
|
||||
let q = Point::new(b.x0 - r + (b.width() + 2.0 * r) * f64::from(i) / 80.0, b.y0 - r + (b.height() + 2.0 * r) * f64::from(j) / 80.0);
|
||||
let dq = dist_to_drag(q, pts);
|
||||
let inside = areas.iter().any(|a| a.contains(q));
|
||||
if dq < 0.97 * r {
|
||||
assert!(inside, "{q:?} {dq} from the drag is inside the band, radius {r}");
|
||||
probes += 1;
|
||||
} else if dq > 1.03 * r {
|
||||
assert!(!inside, "{q:?} {dq} from the drag is outside the band, radius {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(probes > 100, "{probes}");
|
||||
outline.clone()
|
||||
}
|
||||
|
||||
/// While a smoothing drag has a selected path to smooth, it shows dashed the band its release smooths:
|
||||
/// everything within the radius it sends of the drag, round at both ends, around the drag's thin
|
||||
/// line. Alt smoothing with the Pencil, Paintbrush and Blob Brush shows it too; a drag with nothing
|
||||
/// selected and a Blob Brush stroke that paints do not.
|
||||
#[test]
|
||||
fn smoothing_drag_shows_the_band_it_smooths() {
|
||||
use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape};
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let none = Selection::default();
|
||||
let p = paint();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let pts = [(50.0, 150.0), (150.0, 150.0)];
|
||||
for zoom in [1.0, 2.0, 0.5, 200.0] {
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
cx.zoom = zoom;
|
||||
let r = match drag(&mut GestureTool::new("smooth"), &cx, &pts, Mods::default()).last().and_then(|a| a.first()) {
|
||||
Some(Action::Exec(c, v)) if c == "path.smoothRegion" => v["radius"].as_f64().unwrap_or(0.0),
|
||||
a => panic!("expected path.smoothRegion, got: {a:?}"),
|
||||
};
|
||||
for (id, alt_smooth, m) in [("smooth", false, Mods::default()), ("pencil", true, alt), ("paintbrush", false, alt), ("blobBrush", false, alt)]
|
||||
{
|
||||
let mut t = GestureTool::new(id);
|
||||
t.set_option("altSmooth", &json!(alt_smooth));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1).with_mods(m));
|
||||
let band = check_band(&t, &cx, r);
|
||||
let b = band.bounding_box();
|
||||
let near = |a: f64, w: f64| (a - w).abs() < 0.02 * r;
|
||||
assert!(near(b.x0, 50.0 - r) && near(b.x1, 150.0 + r) && near(b.y0, 150.0 - r) && near(b.y1, 150.0 + r), "{id} at zoom {zoom}: {b:?}");
|
||||
// The whole outline shows: two sides and two half circles.
|
||||
let length: f64 = band.segments().map(|s| s.arclen(1e-6 * r)).sum();
|
||||
let want = 200.0 + 2.0 * std::f64::consts::PI * r;
|
||||
assert!((length - want).abs() < 0.02 * want, "{id} at zoom {zoom}: outline {length}, band {want}");
|
||||
assert!(
|
||||
t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { width, dashed: false, .. } if *width == 1.0)),
|
||||
"{id}: the drag's line"
|
||||
);
|
||||
}
|
||||
// Nothing selected: the release smooths nothing, so no band shows.
|
||||
let mut empty = crate::testutil::cx(&d, &none, &p);
|
||||
empty.zoom = zoom;
|
||||
let mut t = GestureTool::new("smooth");
|
||||
t.pointer(&empty, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1));
|
||||
t.pointer(&empty, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1));
|
||||
assert!(bands(&t, &empty).is_empty());
|
||||
// A Blob Brush stroke that paints shows its own area, not the band.
|
||||
let mut t = GestureTool::new("blobBrush");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1));
|
||||
assert!(bands(&t, &cx).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// The band shown while a long drag goes on, grown step by step, is one outline that holds every
|
||||
/// point within the radius of the drag and none farther, whether the drag crosses itself or runs
|
||||
/// on across the page; a zoom change mid-drag with the pointer still asks for frames until the band
|
||||
/// covers the whole drag at the new radius, and the next drag does not keep the last one's band.
|
||||
#[test]
|
||||
fn smoothing_band_follows_a_long_drag() {
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
// A curve that crosses itself, and one that runs on across the page.
|
||||
let crossing: Vec<Point> =
|
||||
(0..240).map(|i| f64::from(i) * 0.05).map(|a| Point::new(250.0 + 80.0 * (a * 1.3).sin(), 250.0 + 60.0 * (a * 0.7).cos())).collect();
|
||||
let across: Vec<Point> =
|
||||
(0..900).map(f64::from).map(|i| Point::new(20.0 + 0.5 * i, 250.0 + 100.0 * (i * 0.05).sin() + 30.0 * (i * 0.37).sin())).collect();
|
||||
for pts in [crossing, across] {
|
||||
cx.zoom = 1.0;
|
||||
let mut t = GestureTool::new("smooth");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y));
|
||||
for q in &pts[1..] {
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
|
||||
assert_eq!(bands(&t, &cx).len(), 1);
|
||||
}
|
||||
check_band(&t, &cx, cx.tol(12.0));
|
||||
assert!(!t.overlays_pending(), "the band covers the drag");
|
||||
// The view zooms in mid-drag while the pointer holds still: the tool asks for frames until
|
||||
// the band covers the whole drag at the new radius.
|
||||
cx.zoom = 2.0;
|
||||
let mut frames = 1;
|
||||
bands(&t, &cx);
|
||||
while t.overlays_pending() {
|
||||
assert!(frames <= pts.len(), "the band reaches the whole drag");
|
||||
bands(&t, &cx);
|
||||
frames += 1;
|
||||
}
|
||||
let n = t.points.len();
|
||||
assert_eq!(frames, (n - 1).div_ceil(BAND_STEP * BAND_STEPS_PER_CALL), "frames for {n} points");
|
||||
check_band(&t, &cx, cx.tol(12.0));
|
||||
assert!(!t.overlays_pending());
|
||||
let last = pts[pts.len() - 1];
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, last.x, last.y));
|
||||
// The next drag shows only its own band.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 450.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 420.0, 450.0));
|
||||
let b = check_band(&t, &cx, cx.tol(12.0)).bounding_box();
|
||||
assert!(b.x0 > 390.0 && b.y0 > 440.0, "{b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A segment of a drag too long for the squares at the current zoom (a jump drawn zoomed out,
|
||||
/// viewed zoomed in, or one reaching far off the page) shows as the two sides of its band, and the
|
||||
/// band goes on past it: the parts dragged before and after it show, and nothing stays pending.
|
||||
/// Two such segments that cross show no edge inside each other's band, and one that runs back
|
||||
/// along another shows its sides once.
|
||||
#[test]
|
||||
fn smoothing_band_shows_a_segment_too_long_for_its_squares() {
|
||||
use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape};
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
let before = [Point::new(100.0, 100.0), Point::new(130.0, 100.0)];
|
||||
let after = [Point::new(20_100.0, 100.0), Point::new(20_100.0, 130.0), Point::new(20_130.0, 130.0)];
|
||||
let far = [Point::new(1e17, 100.0)];
|
||||
let crossing = [(100.0, 100.0), (30_100.0, 100.0), (30_100.0, 130.0), (15_100.0, -20_000.0), (15_130.0, -20_000.0)].map(Point::from);
|
||||
let back = [(100.0, 100.0), (30_100.0, 100.0), (130.0, 100.0)].map(Point::from);
|
||||
for (case, pts) in [
|
||||
("jump", [&before[..], &after[..]].concat()),
|
||||
("far", [&before[..], &far[..], &after[..]].concat()),
|
||||
("crossing", crossing.to_vec()),
|
||||
("back", back.to_vec()),
|
||||
] {
|
||||
// Drawn zoomed out, where the jump is about 42 half squares, then viewed at 1,000%, where
|
||||
// it is more than 4,096.
|
||||
cx.zoom = 0.1;
|
||||
let mut t = GestureTool::new("smooth");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y));
|
||||
for q in &pts[1..] {
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
|
||||
bands(&t, &cx);
|
||||
}
|
||||
cx.zoom = 10.0;
|
||||
let r = cx.tol(12.0);
|
||||
let mut frames = 0;
|
||||
let mut found = bands(&t, &cx);
|
||||
while t.overlays_pending() {
|
||||
assert!(frames < pts.len(), "{case}: the band reaches the whole drag");
|
||||
found = bands(&t, &cx);
|
||||
frames += 1;
|
||||
}
|
||||
let [outline] = found.as_slice() else { panic!("{case}: one band, got {found:?}") };
|
||||
for seg in outline.segments() {
|
||||
for s in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
let q = seg.eval(s);
|
||||
let dq = dist_to_drag(q, &pts);
|
||||
assert!((dq - r).abs() < 0.02 * r, "{case}: {q:?} on the outline is {dq} from the drag, radius {r}");
|
||||
}
|
||||
}
|
||||
// The band of each long segment shows on both of its sides, as far as the release reaches,
|
||||
// except where the band of another part of the drag holds the side (on the page: a point
|
||||
// on a segment to 1e17 resolves only to about 10 units).
|
||||
let on_outline = |q: Point| {
|
||||
outline.segments().any(|seg| {
|
||||
(0..=8).any(|k| seg.eval(f64::from(k) / 8.0).distance(q) < 0.02 * r) || dist_to_drag(q, &[seg.start(), seg.end()]) < 0.02 * r
|
||||
})
|
||||
};
|
||||
for w in pts.windows(2).filter(|w| w[0].distance(w[1]) > 10_000.0 && w[0].x < 1e6 && w[1].x < 1e6) {
|
||||
let d = w[1] - w[0];
|
||||
let n = vectorcraft_geom::kurbo::Vec2::new(-d.y, d.x) * (r / d.hypot());
|
||||
for k in 1..40 {
|
||||
for q in [w[0].lerp(w[1], f64::from(k) / 40.0) + n, w[0].lerp(w[1], f64::from(k) / 40.0) - n] {
|
||||
if dist_to_drag(q, &pts) > 0.98 * r {
|
||||
assert!(on_outline(q), "{case}: {q:?} on a side of {w:?} shows");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if case == "back" {
|
||||
// Running back along the jump draws its sides once: two sides and two half circles.
|
||||
let length: f64 = outline.segments().map(|s| s.arclen(1e-6 * r)).sum();
|
||||
let want = 2.0 * 30_000.0 + 2.0 * std::f64::consts::PI * r;
|
||||
assert!((length - want).abs() < 0.01 * want, "{case}: outline {length}, band {want}");
|
||||
}
|
||||
let kept = t.band.take();
|
||||
let areas: Vec<_> = kept.iter().flat_map(|b| b.tiles.values().map(|t| t.area.to_bezpath())).collect();
|
||||
t.band.set(kept);
|
||||
// Every point near each short part dragged on either side of a long segment is in the band.
|
||||
for w in pts.windows(2).filter(|w| w[0].distance(w[1]) < 1_000.0) {
|
||||
for q in (0..=20).map(|k| w[0].lerp(w[1], f64::from(k) / 20.0)) {
|
||||
for (dx, dy) in [(0.0, 0.0), (0.9, 0.0), (-0.9, 0.0), (0.0, 0.9), (0.0, -0.9)] {
|
||||
let probe = q + vectorcraft_geom::kurbo::Vec2::new(dx * r, dy * r);
|
||||
assert!(areas.iter().any(|a| a.contains(probe)), "{case}: {probe:?} near the drag is in the band");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag drawn zoomed out whose every segment is too long for the squares once the view zooms in,
|
||||
/// and whose segments all cross one another, is outlined over several calls: no call does more
|
||||
/// than about [`BAND_WORK_PER_CALL`] of work on the long segments, and the band then shows in full.
|
||||
#[test]
|
||||
fn smoothing_band_of_many_long_segments_is_made_in_bounded_calls() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
// A star whose chords nearly all pass through its middle, 4,000 units each.
|
||||
let step = std::f64::consts::PI * (1.0 - 1.0 / 40.3);
|
||||
let pts: Vec<Point> = (0..161).map(|k| f64::from(k) * step).map(|a| Point::new(5000.0 + 2000.0 * a.cos(), 5000.0 + 2000.0 * a.sin())).collect();
|
||||
cx.zoom = 0.05;
|
||||
let mut t = GestureTool::new("smooth");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y));
|
||||
for q in &pts[1..] {
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
|
||||
bands(&t, &cx);
|
||||
}
|
||||
cx.zoom = 640.0;
|
||||
let r = cx.tol(SMOOTH_RADIUS);
|
||||
let work = |t: &GestureTool| {
|
||||
let kept = t.band.take();
|
||||
let w = kept.as_ref().map_or(0, |b| b.work);
|
||||
t.band.set(kept);
|
||||
w
|
||||
};
|
||||
let mut found = bands(&t, &cx);
|
||||
let mut frames = 1;
|
||||
let mut most = work(&t);
|
||||
while t.overlays_pending() {
|
||||
assert!(frames < 10_000, "the band reaches the whole drag");
|
||||
found = bands(&t, &cx);
|
||||
most = most.max(work(&t));
|
||||
frames += 1;
|
||||
}
|
||||
assert!(most <= 2 * BAND_WORK_PER_CALL, "{most} work in one call");
|
||||
assert!(frames > 2, "{frames} calls");
|
||||
let kept = t.band.take();
|
||||
let long = kept.as_ref().map_or(0, |b| b.long.len());
|
||||
t.band.set(kept);
|
||||
assert_eq!(long, pts.len() - 1, "every segment is long");
|
||||
let [outline] = found.as_slice() else { panic!("one band, got {}", found.len()) };
|
||||
for seg in outline.segments().step_by(7) {
|
||||
for s in [0.0, 0.5, 1.0] {
|
||||
let q = seg.eval(s);
|
||||
let dq = dist_to_drag(q, &pts);
|
||||
assert!((dq - r).abs() < 0.02 * r, "{q:?} on the outline is {dq} from the drag, radius {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The band is kept in squares; a straight drag whose band edge runs along a side of a square, or
|
||||
/// just inside or outside it, still shows that edge, and the sides between two squares the band
|
||||
/// fills do not show.
|
||||
#[test]
|
||||
fn smoothing_band_shows_an_edge_along_a_square_side() {
|
||||
use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape};
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
for zoom in [1.0, 3.0] {
|
||||
cx.zoom = zoom;
|
||||
let r = cx.tol(SMOOTH_RADIUS);
|
||||
let side = BAND_TILE_RADII * r;
|
||||
// A side of the squares near 230 (across and down: the grid is square).
|
||||
let at = (BAND_TILE_OFFSET + (230.0 / side - BAND_TILE_OFFSET).round()) * side;
|
||||
let eps = 1e-3 * r;
|
||||
for off in [0.0, 0.5 * eps, -0.5 * eps, 1.5 * eps, -1.5 * eps] {
|
||||
for (edge, upright) in [(-r, false), (r, false), (-r, true), (r, true)] {
|
||||
let c = at + edge + off;
|
||||
let ends = if upright {
|
||||
[Point::new(c, 100.0), Point::new(c, 100.0 + 2.5 * side)]
|
||||
} else {
|
||||
[Point::new(100.0, c), Point::new(100.0 + 2.5 * side, c)]
|
||||
};
|
||||
let mut t = GestureTool::new("smooth");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, ends[0].x, ends[0].y));
|
||||
for i in 1..=20 {
|
||||
let q = ends[0].lerp(ends[1], f64::from(i) / 20.0);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
|
||||
}
|
||||
let band = check_band(&t, &cx, r);
|
||||
// Two sides and two half circles, each shown once.
|
||||
let length: f64 = band.segments().map(|s| s.arclen(1e-6 * r)).sum();
|
||||
let want = 2.0 * 2.5 * side + 2.0 * std::f64::consts::PI * r;
|
||||
assert!(
|
||||
(length - want).abs() < 0.02 * want,
|
||||
"zoom {zoom}, edge {edge}, off {off}, upright {upright}: outline {length}, band {want}, {:?}",
|
||||
band.bounding_box()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag whose band edge runs along a side of a square and then turns across it shows the edge
|
||||
/// up to the turn and no line where the band goes on across the side.
|
||||
#[test]
|
||||
fn smoothing_band_shows_no_seam_where_it_turns_across_a_square_side() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
for zoom in [1.0, 3.0] {
|
||||
cx.zoom = zoom;
|
||||
let r = cx.tol(SMOOTH_RADIUS);
|
||||
let side = BAND_TILE_RADII * r;
|
||||
let at = (BAND_TILE_OFFSET + (230.0 / side - BAND_TILE_OFFSET).round()) * side;
|
||||
let eps = 1e-3 * r;
|
||||
for off in [0.0, 0.5 * eps, -0.5 * eps] {
|
||||
for (edge, upright) in [(-r, false), (r, false), (-r, true), (r, true)] {
|
||||
let c = at + edge + off;
|
||||
// Along the side, then across it.
|
||||
let turn = 100.0 + 1.5 * side;
|
||||
let corners = [(100.0, c), (turn, c), (turn, c - edge.signum() * 2.0 * side)];
|
||||
let pts: Vec<Point> = corners.into_iter().map(|(u, v)| if upright { Point::new(v, u) } else { Point::new(u, v) }).collect();
|
||||
let mut t = GestureTool::new("smooth");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y));
|
||||
for w in pts.windows(2) {
|
||||
for i in 1..=12 {
|
||||
let q = w[0].lerp(w[1], f64::from(i) / 12.0);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
|
||||
}
|
||||
}
|
||||
check_band(&t, &cx, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One straight piece of a square's outline along a side, where the band goes on across only part
|
||||
/// of it, shows only the part with nothing across.
|
||||
#[test]
|
||||
fn smoothing_band_square_shows_only_the_open_part_of_a_side() {
|
||||
use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape};
|
||||
let mut band = SmoothBand::new(Point::ZERO, 10.0);
|
||||
let r = band.tile_rect((0, 0));
|
||||
let (x, y) = (r.x0, r.y1);
|
||||
let area = |x0: f64, y0: f64, x1: f64, y1: f64| vectorcraft_geom::shapes::rectangle(Rect::new(x0, y0, x1, y1));
|
||||
let tile = |area| BandTile { area, outline: vectorcraft_geom::BezPath::new(), near_long: vec![], cut: vectorcraft_geom::BezPath::new() };
|
||||
// Along the bottom side from 10 to 60, with the band across it from 40 to 60.
|
||||
band.tiles.insert((0, 0), tile(area(x + 10.0, y - 20.0, x + 60.0, y)));
|
||||
band.tiles.insert((0, 1), tile(area(x + 40.0, y, x + 60.0, y + 20.0)));
|
||||
let outline = band.tile_outline((0, 0));
|
||||
let along: f64 = outline.segments().filter(|s| s.bounding_box().height() < 1e-9 && (s.start().y - y).abs() < 1e-9).map(|s| s.arclen(1e-9)).sum();
|
||||
assert!((along - 30.0).abs() < 1e-6, "{along} of the side shows: {}", outline.to_svg());
|
||||
for s in outline.segments() {
|
||||
assert!(!((s.start().y - y).abs() < 1e-9 && (s.end().y - y).abs() < 1e-9 && s.start().x.max(s.end().x) > x + 40.0 + 1e-9), "{s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection with no path the Smooth tool can change (a two-anchor line) shows no band.
|
||||
#[test]
|
||||
fn smoothing_drag_shows_no_band_without_a_path_it_smooths() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
|
||||
vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)),
|
||||
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)),
|
||||
]));
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = GestureTool::new("smooth");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 400.0, 300.0));
|
||||
let o = t.overlays(&cx);
|
||||
assert!(!o.iter().any(|o| matches!(o, Overlay::Path { dashed: true, .. })), "{o:?}");
|
||||
}
|
||||
|
||||
/// An Alt smoothing drag uses the Smooth tool's Fidelity for Pencil, Paintbrush and Blob Brush.
|
||||
#[test]
|
||||
fn alt_smoothing_uses_the_smooth_tools_fidelity() {
|
||||
@@ -1665,7 +2098,7 @@ fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() {
|
||||
/// The Blob Brush's Angle and Roundness (Blob Brush Tool Options, 0° and 100% by default) go with
|
||||
/// its stroke, kept within −180–180° and 0–100%; while it drags, a tip that is not round shows the
|
||||
/// outline of the area it covers with the length label, and an Alt drag that smooths shows the
|
||||
/// Smooth tool's thin line.
|
||||
/// Smooth tool's thin line and the dashed band it smooths.
|
||||
#[test]
|
||||
fn the_blob_brush_sends_its_tip_and_shows_the_area_it_covers() {
|
||||
let (d, id) = doc_with_rect();
|
||||
@@ -1704,7 +2137,7 @@ fn the_blob_brush_sends_its_tip_and_shows_the_area_it_covers() {
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt));
|
||||
let o = t.overlays(&cx);
|
||||
let [Overlay::Path { path, width, .. }] = &o[..] else { panic!("{o:?}") };
|
||||
let [Overlay::Path { dashed: true, .. }, Overlay::Path { path, width, dashed: false, .. }] = &o[..] else { panic!("{o:?}") };
|
||||
assert_eq!(*width, 1.0);
|
||||
assert!(vectorcraft_geom::Shape::bounding_box(path).height() < 0.1, "{o:?}");
|
||||
}
|
||||
|
||||
@@ -137,8 +137,8 @@ pub trait Tool: Send {
|
||||
vec![]
|
||||
}
|
||||
/// Did the last [`Tool::overlays`] leave part of its overlays for later calls (the outline of a
|
||||
/// long Blob Brush or Eraser drag)? The host then draws again on the next frame, even when the
|
||||
/// pointer holds still.
|
||||
/// long Blob Brush or Eraser drag, the band of a long smoothing drag outlined again after a
|
||||
/// zoom change)? The host then draws again on the next frame, even when the pointer holds still.
|
||||
fn overlays_pending(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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::<f64>();
|
||||
// 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<u16> = 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<u16> = (0..17 * 17)
|
||||
.map(|i| match gray(i % 17, i / 17) {
|
||||
0 => 0,
|
||||
50 => 1,
|
||||
200 => 2,
|
||||
_ => 3,
|
||||
})
|
||||
.collect();
|
||||
let own: Vec<bool> = 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<u16> = (0..17 * 17u32)
|
||||
.map(|i| match value(i % 17) {
|
||||
100 => 0,
|
||||
200 => 1,
|
||||
_ => 2,
|
||||
})
|
||||
.collect();
|
||||
let own: Vec<bool> = 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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>());
|
||||
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::<Vec<_>>();
|
||||
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.
|
||||
|
||||
@@ -12,7 +12,9 @@ use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape,
|
||||
|
||||
/// Dispatch canvas pointer, keyboard and drop events through the active document view.
|
||||
pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
|
||||
let (pointer, m, space, hover) = wheel_view(app, ui, resp, rect);
|
||||
// A modal dialog takes the canvas's presses, finger taps, long touches and view navigation, from the frame it opens.
|
||||
let modal = crate::panel_docking::modal_open(app, ui.ctx());
|
||||
let (pointer, m, space, hover) = wheel_view(app, ui, resp, rect, modal);
|
||||
let v = *app.view().unwrap_or(&View::default());
|
||||
let xf = Xf::at(rect, &v, app.canvas_ui_zoom);
|
||||
// A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`),
|
||||
@@ -21,11 +23,11 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
|
||||
let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p)));
|
||||
let view = app.view_info();
|
||||
let (_, backquote_before) = backquote_seen(ui);
|
||||
let f = Pointing { resp, rect, pointer: &pointer, m, space, xf, v, view, backquote_before };
|
||||
touch_gestures(app, ui, rect, view);
|
||||
let f = Pointing { resp, rect, pointer: &pointer, m, space, xf, v, view, backquote_before, modal };
|
||||
touch_gestures(app, ui, rect, view, modal);
|
||||
// Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the
|
||||
// tool's press is dropped, as a right-click presses nothing.
|
||||
if resp.long_touched() {
|
||||
// tool's press is dropped, as a right-click presses nothing. Under a modal dialog it drops nothing.
|
||||
if resp.long_touched() && !modal {
|
||||
drop_press(app, ui, view);
|
||||
}
|
||||
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
|
||||
@@ -84,9 +86,11 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
|
||||
// egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a
|
||||
// press on the canvas itself reaches the tools, else it would land at the canvas's centre.
|
||||
// A finger pressing with others down, or with another landing in its frame, presses nothing
|
||||
// (`touch_gestures` made the press a gesture, #4198).
|
||||
// (`touch_gestures` made the press a gesture, #4198). While a modal dialog is open no press
|
||||
// reaches the tools or pans, also in the frame it opens, before its backdrop covers the canvas.
|
||||
if pointer.primary_pressed()
|
||||
&& resp.hovered()
|
||||
&& !f.modal
|
||||
&& !middle_pan
|
||||
&& drag != Some(Drag::Gesture)
|
||||
&& let Some(p) = at
|
||||
@@ -110,6 +114,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
|
||||
}
|
||||
} else if drag.is_none()
|
||||
&& resp.hovered()
|
||||
&& !f.modal
|
||||
&& pointer.button_pressed(egui::PointerButton::Middle)
|
||||
&& let Some(start) = hover
|
||||
{
|
||||
@@ -169,6 +174,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
|
||||
select_named_artboard(app, i, shift || release_shift);
|
||||
}
|
||||
if resp.double_clicked()
|
||||
&& !f.modal
|
||||
&& let Some(p) = at
|
||||
{
|
||||
double_click(app, ui, &f, p);
|
||||
@@ -190,21 +196,37 @@ struct Pointing<'a> {
|
||||
view: vectorcraft_engine::ViewInfo,
|
||||
/// Whether Backtick was held at the end of the frame before ([`backquote_seen`]).
|
||||
backquote_before: Option<bool>,
|
||||
/// Whether a modal dialog is open ([`crate::panel_docking::modal_open`]).
|
||||
modal: bool,
|
||||
}
|
||||
|
||||
/// Zoom around the pointer, and scroll ([`wheel`]); a trackpad rotation turns the view. Returns the pointer, the
|
||||
/// Zoom around the pointer, and scroll ([`wheel`]); a trackpad rotation turns the view. While a modal dialog is open
|
||||
/// (`modal`), and for the rest of a wheel turn made under it, the view does not move. Returns the pointer, the
|
||||
/// modifiers, whether Space is held and where the pointer hovers over the canvas.
|
||||
fn wheel_view(
|
||||
app: &mut VectorcraftApp,
|
||||
ui: &Ui,
|
||||
resp: &egui::Response,
|
||||
rect: egui::Rect,
|
||||
modal: bool,
|
||||
) -> (egui::PointerState, egui::Modifiers, bool, Option<Pos2>) {
|
||||
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
|
||||
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
|
||||
// A wheel turn that goes on under a modal dialog moves nothing after the dialog closes either: what egui lets out
|
||||
// of it later is dropped until egui ends the turn.
|
||||
// `carried` is set only while a turn that went on under the dialog is still scrolling, so a new turn
|
||||
// made after it closes moves the view at once.
|
||||
let carried_id = egui::Id::unique("canvas-wheel-muted");
|
||||
let was_carried = ui.data(|d| d.get_temp::<bool>(carried_id)).unwrap_or(false);
|
||||
let carried = (modal || was_carried) && ui.input(egui::InputState::is_scrolling);
|
||||
if carried != was_carried {
|
||||
ui.data_mut(|d| d.insert_temp(carried_id, carried));
|
||||
}
|
||||
let muted = modal || carried;
|
||||
let navigates = resp.hovered() && !muted;
|
||||
// A docked field may be drawn later and has not consumed its wheel event yet. Only canvas
|
||||
// hover may start a zoom glide; existing glides still decay outside the canvas in `wheel`.
|
||||
let animate = animated_zoom(&app.session.prefs) && resp.hovered();
|
||||
let animate = animated_zoom(&app.session.prefs) && navigates;
|
||||
let turn_id = egui::Id::unique("canvas-wheel-turn");
|
||||
let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default();
|
||||
let mut turn = turn_before;
|
||||
@@ -216,6 +238,10 @@ fn wheel_view(
|
||||
wheel(i, &app.session.prefs, wheel_zooms, animate, line, rect.height(), &mut turn),
|
||||
)
|
||||
});
|
||||
// A zoom glide still running when a modal dialog opens stops.
|
||||
if muted {
|
||||
turn.glide = 0.0;
|
||||
}
|
||||
if turn != turn_before {
|
||||
ui.data_mut(|d| d.insert_temp(turn_id, turn));
|
||||
}
|
||||
@@ -226,7 +252,7 @@ fn wheel_view(
|
||||
// Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also
|
||||
// slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The
|
||||
// tool's events below take their document points from the view this moves to.
|
||||
if resp.hovered() {
|
||||
if navigates {
|
||||
let ui_zoom = app.canvas_ui_zoom;
|
||||
if (factor - 1.0).abs() > 1e-6
|
||||
&& let (Some(p), Some(vm)) = (hover, app.view_mut())
|
||||
@@ -247,10 +273,11 @@ fn wheel_view(
|
||||
}
|
||||
|
||||
/// In Presentation Mode a click anywhere goes to the next artboard. The tool receives no press, drag or hover, and a
|
||||
/// drag does not pan the view. A drag that began before the mode was turned on ends as usual.
|
||||
/// drag does not pan the view. A drag that began before the mode was turned on ends as usual. While a modal dialog is
|
||||
/// open, a click does not step.
|
||||
fn present_step(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, drag: Option<Drag>, at: Option<Pos2>) {
|
||||
let (resp, pointer) = (f.resp, f.pointer);
|
||||
let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some();
|
||||
let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some() && !f.modal;
|
||||
// `press` is this frame's new press, or else the press held so far. A quick tap can be
|
||||
// pressed and released in one frame.
|
||||
let press = if pressed { Some(Drag::Present) } else { drag };
|
||||
@@ -258,7 +285,7 @@ fn present_step(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, drag: Option<Dr
|
||||
ui.data_mut(|dd| dd.remove::<Drag>(drag_id()));
|
||||
// A tap of several fingers leaves `Drag::Gesture` (`touch_gestures`), and its release
|
||||
// does not move the view to the next artboard.
|
||||
if press == Some(Drag::Present) && resp.clicked() {
|
||||
if press == Some(Drag::Present) && resp.clicked() && !f.modal {
|
||||
crate::shortcuts::presentation_step(app, true);
|
||||
}
|
||||
} else if pressed {
|
||||
|
||||
@@ -1030,6 +1030,278 @@ fn two_finger_tap_in_touch_switches_selection_and_drawing_modes() {
|
||||
assert_eq!(count(&app), before - 1, "Essentials: undone");
|
||||
}
|
||||
|
||||
/// One frame of the canvas with `events`, with the modal backdrop drawn over it when `backdrop` is set, as the app
|
||||
/// draws it for an open dialog.
|
||||
fn modal_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, backdrop: bool) {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
show(app, ui);
|
||||
if backdrop {
|
||||
crate::dialogs::modal::show(ui.ctx(), "Test", egui::Id::unique("test-dialog"), 0.0, 8, |ui| ui.label("A dialog"));
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Finger `n` touching at `pos`.
|
||||
fn finger(n: u64, phase: egui::TouchPhase, pos: Pos2) -> egui::Event {
|
||||
egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }
|
||||
}
|
||||
|
||||
/// The primary button pressed or released at `pos`.
|
||||
fn finger_button(pos: Pos2, pressed: bool) -> egui::Event {
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// A tap of `fingers` fingers beside `c`, the first also pressing as the pointer and moving a little, in frames drawn
|
||||
/// with the modal backdrop when `backdrop` is set ([`modal_frame`]).
|
||||
fn finger_tap(app: &mut VectorcraftApp, ctx: &egui::Context, c: Pos2, fingers: u64, backdrop: bool) {
|
||||
use egui::TouchPhase::{End, Move, Start};
|
||||
let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0);
|
||||
let moved = at(0) + vec2(6.0, 6.0);
|
||||
modal_frame(app, ctx, vec![finger(0, Start, at(0)), egui::Event::PointerMoved(at(0)), finger_button(at(0), true)], backdrop);
|
||||
modal_frame(app, ctx, vec![finger(0, Move, moved), egui::Event::PointerMoved(moved)], backdrop);
|
||||
modal_frame(app, ctx, (1..fingers).map(|n| finger(n, Start, at(n))).collect(), backdrop);
|
||||
modal_frame(app, ctx, vec![finger(0, End, at(0)), finger_button(moved, false), egui::Event::PointerGone], backdrop);
|
||||
modal_frame(app, ctx, (1..fingers).map(|n| finger(n, End, at(n))).collect(), backdrop);
|
||||
modal_frame(app, ctx, vec![], backdrop);
|
||||
}
|
||||
|
||||
/// While a modal is open, a two-finger tap on the canvas does not undo, a three-finger tap does not redo, and in the
|
||||
/// Touch workspace a two-finger tap does not switch modes: with the app's dialog open, before its backdrop is drawn
|
||||
/// (the frame it opens) and after, and under a modal backdrop alone.
|
||||
#[test]
|
||||
fn modal_blocks_two_and_three_finger_canvas_taps() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap();
|
||||
app.select_tool("selection");
|
||||
let ctx = egui::Context::default();
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len();
|
||||
let c = app.canvas_rect.unwrap().center();
|
||||
let settle = |app: &mut VectorcraftApp| {
|
||||
app.ui.dialog = None;
|
||||
modal_frame(app, &ctx, vec![], false);
|
||||
modal_frame(app, &ctx, vec![], false);
|
||||
};
|
||||
for (dialog, backdrop) in [(true, true), (true, false), (false, true)] {
|
||||
let open = |app: &mut VectorcraftApp| app.ui.dialog = dialog.then(|| crate::state::Dialog::new("preferences", json!({})));
|
||||
open(&mut app);
|
||||
finger_tap(&mut app, &ctx, c, 2, backdrop);
|
||||
assert_eq!(count(&app), 1, "dialog {dialog}, backdrop {backdrop}: two fingers undo nothing");
|
||||
settle(&mut app);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(count(&app), 0);
|
||||
open(&mut app);
|
||||
finger_tap(&mut app, &ctx, c, 3, backdrop);
|
||||
assert_eq!(count(&app), 0, "dialog {dialog}, backdrop {backdrop}: three fingers redo nothing");
|
||||
settle(&mut app);
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(count(&app), 1);
|
||||
}
|
||||
crate::workspaces::switch(&mut app.ui, crate::workspaces::TOUCH).unwrap();
|
||||
app.select_tool("selection");
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({})));
|
||||
finger_tap(&mut app, &ctx, c, 2, true);
|
||||
assert_eq!((app.session.tool_id(), count(&app)), ("selection", 1), "Touch: no switch to drawing mode under the dialog");
|
||||
settle(&mut app);
|
||||
finger_tap(&mut app, &ctx, c, 2, false);
|
||||
assert_ne!(app.session.tool_id(), "selection", "closed: two fingers switch to drawing mode again");
|
||||
}
|
||||
|
||||
/// Fingers down on the canvas when a modal dialog opens make no tap: lifting them while the dialog is open, or after it
|
||||
/// closes, runs nothing. A tap begun after it closes undoes again.
|
||||
#[test]
|
||||
fn opening_a_modal_discards_a_pending_canvas_tap() {
|
||||
use egui::TouchPhase::{End, Start};
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap();
|
||||
app.select_tool("selection");
|
||||
let ctx = egui::Context::default();
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len();
|
||||
let c = app.canvas_rect.unwrap().center();
|
||||
let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0);
|
||||
modal_frame(&mut app, &ctx, vec![finger(0, Start, at(0)), egui::Event::PointerMoved(at(0)), finger_button(at(0), true)], false);
|
||||
modal_frame(&mut app, &ctx, vec![finger(1, Start, at(1))], false);
|
||||
// The dialog opens in a frame with no touch, and one finger lifts while it is open.
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({})));
|
||||
modal_frame(&mut app, &ctx, vec![], true);
|
||||
modal_frame(&mut app, &ctx, vec![finger(0, End, at(0)), finger_button(at(0), false), egui::Event::PointerGone], true);
|
||||
assert_eq!(count(&app), 1, "nothing undone while the dialog is open");
|
||||
// The other finger lifts after it closes.
|
||||
app.ui.dialog = None;
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
modal_frame(&mut app, &ctx, vec![finger(1, End, at(1))], false);
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
assert_eq!(count(&app), 1, "the tap begun before the dialog runs nothing after it closes");
|
||||
finger_tap(&mut app, &ctx, c, 2, false);
|
||||
assert_eq!(count(&app), 0, "a tap begun after it closes undoes");
|
||||
}
|
||||
|
||||
/// While a modal is open and the canvas is not yet covered by its backdrop (the frame a dialog opens), a click in
|
||||
/// Presentation Mode does not step to the next artboard, and a pinch or a wheel does not move the view. Closed, both
|
||||
/// act again.
|
||||
#[test]
|
||||
fn modal_blocks_presentation_tap_and_touch_navigation() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 80, "name": "Far"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let dialog = || Some(crate::state::Dialog::new("preferences", json!({})));
|
||||
app.ui.screen_mode = 3;
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
let c = app.canvas_rect.unwrap().center();
|
||||
let click = |app: &mut VectorcraftApp| {
|
||||
modal_frame(app, &ctx, vec![egui::Event::PointerMoved(c)], false);
|
||||
modal_frame(app, &ctx, vec![finger_button(c, true)], false);
|
||||
modal_frame(app, &ctx, vec![finger_button(c, false)], false);
|
||||
modal_frame(app, &ctx, vec![], false);
|
||||
};
|
||||
let at = |app: &VectorcraftApp| crate::panels::artboards::selected(app, 2);
|
||||
assert_eq!(at(&app), 0);
|
||||
app.ui.dialog = dialog();
|
||||
click(&mut app);
|
||||
assert_eq!(at(&app), 0, "no step under the dialog");
|
||||
app.ui.dialog = None;
|
||||
click(&mut app);
|
||||
assert_eq!(at(&app), 1, "closed: a click steps");
|
||||
|
||||
app.ui.screen_mode = 0;
|
||||
modal_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c)], false);
|
||||
let view = |app: &VectorcraftApp| {
|
||||
let v = app.view().unwrap();
|
||||
(v.zoom, v.center)
|
||||
};
|
||||
let wheel = egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, 40.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
let before = view(&app);
|
||||
app.ui.dialog = dialog();
|
||||
modal_frame(&mut app, &ctx, vec![egui::Event::Zoom(2.0)], false);
|
||||
modal_frame(&mut app, &ctx, vec![wheel.clone()], false);
|
||||
for _ in 0..30 {
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
}
|
||||
assert_eq!(view(&app), before, "the view does not move under the dialog");
|
||||
// A wheel turn of whole lines, which egui lets out over several frames, made just before the dialog closes.
|
||||
let lines = egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: vec2(0.0, 3.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
modal_frame(&mut app, &ctx, vec![lines], false);
|
||||
app.ui.dialog = None;
|
||||
for _ in 0..30 {
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
}
|
||||
assert_eq!(view(&app), before, "the rest of a wheel turn made under the dialog does not move the view after it closes");
|
||||
modal_frame(&mut app, &ctx, vec![egui::Event::Zoom(2.0)], false);
|
||||
assert_ne!(view(&app).0, before.0, "closed: a pinch zooms");
|
||||
let zoomed = view(&app);
|
||||
modal_frame(&mut app, &ctx, vec![wheel.clone()], false);
|
||||
for _ in 0..30 {
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
}
|
||||
assert_ne!(view(&app), zoomed, "closed: a new wheel turn moves the view");
|
||||
// A dialog opened and closed with no wheel turn under it: a wheel turn in the first frame after it closes moves
|
||||
// the view.
|
||||
let scrolled = view(&app);
|
||||
app.ui.dialog = dialog();
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
app.ui.dialog = None;
|
||||
modal_frame(&mut app, &ctx, vec![wheel], false);
|
||||
for _ in 0..30 {
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
}
|
||||
assert_ne!(view(&app), scrolled, "a wheel turn just after the dialog closes moves the view");
|
||||
}
|
||||
|
||||
/// While a modal is open, a pen or finger held still on the canvas does not drop the press it began.
|
||||
#[test]
|
||||
fn modal_long_touch_does_not_cancel_canvas_press() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width":800,"height":600})).unwrap();
|
||||
app.select_tool("rectangle");
|
||||
let ctx = egui::Context::default();
|
||||
let tick = |app: &mut VectorcraftApp, time, events| {
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||||
time: Some(time),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
tick(&mut app, 0.0, vec![]);
|
||||
let pos = app.canvas_rect.unwrap().center();
|
||||
tick(&mut app, 0.1, pen(egui::TouchPhase::Start, pos, 0.5));
|
||||
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Tool)));
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({})));
|
||||
tick(&mut app, 1.5, vec![]);
|
||||
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Tool)), "the press is kept under the dialog");
|
||||
}
|
||||
|
||||
/// While a modal is open and its backdrop does not yet cover the canvas (the frame a dialog opens), a drag on the canvas
|
||||
/// draws nothing, a two-finger tap with a drawing tool draws nothing, a middle-button drag does not pan and a
|
||||
/// double-click on type does not start editing it. Closed, a drag draws again.
|
||||
#[test]
|
||||
fn modal_blocks_new_canvas_presses() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Hello world", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
app.select_tool("rectangle");
|
||||
let ctx = egui::Context::default();
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len();
|
||||
let from = app.canvas_rect.unwrap().center() + vec2(0.0, 80.0);
|
||||
let drag = |app: &mut VectorcraftApp, button: egui::PointerButton| {
|
||||
let to = from + vec2(60.0, 40.0);
|
||||
let b = |pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() };
|
||||
modal_frame(app, &ctx, vec![egui::Event::PointerMoved(from), b(from, true)], false);
|
||||
modal_frame(app, &ctx, vec![egui::Event::PointerMoved(from + vec2(30.0, 20.0))], false);
|
||||
modal_frame(app, &ctx, vec![egui::Event::PointerMoved(to)], false);
|
||||
modal_frame(app, &ctx, vec![b(to, false)], false);
|
||||
modal_frame(app, &ctx, vec![], false);
|
||||
};
|
||||
let before = count(&app);
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({})));
|
||||
drag(&mut app, egui::PointerButton::Primary);
|
||||
assert_eq!(count(&app), before, "a drag with the Rectangle tool draws nothing under the dialog");
|
||||
finger_tap(&mut app, &ctx, from, 2, false);
|
||||
assert_eq!(count(&app), before, "a two-finger tap with the Rectangle tool draws nothing under the dialog");
|
||||
let center = app.view().unwrap().center;
|
||||
drag(&mut app, egui::PointerButton::Middle);
|
||||
assert_eq!(app.view().unwrap().center, center, "a middle-button drag does not pan under the dialog");
|
||||
app.select_tool("selection");
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
let at = xf.to_screen(b.center());
|
||||
let button = |pressed| finger_button(at, pressed);
|
||||
modal_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)], false);
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
assert_eq!(app.session.tool_id(), "selection", "a double-click on type does not edit it under the dialog");
|
||||
app.ui.dialog = None;
|
||||
app.select_tool("rectangle");
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
modal_frame(&mut app, &ctx, vec![], false);
|
||||
drag(&mut app, egui::PointerButton::Primary);
|
||||
assert_eq!(count(&app), before + 1, "closed: a drag draws");
|
||||
}
|
||||
|
||||
/// Switch Selection and Drawing Modes from the command: Pencil when no drawing tool was chosen yet,
|
||||
/// a view tool is left for Selection without becoming the drawing tool, and every selection tool
|
||||
/// switches to drawing mode.
|
||||
|
||||
@@ -534,10 +534,18 @@ pub(super) fn wheel(
|
||||
/// finger drops the press the first one began (its tool interaction rolled back; the rest of the
|
||||
/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes. In the Touch
|
||||
/// workspace a tap of two fingers switches between selection and drawing modes instead.
|
||||
pub(super) fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo) {
|
||||
///
|
||||
/// While a modal dialog is open (`modal`) the canvas takes no finger contacts: no tap runs a command and no gesture
|
||||
/// begins. The fingers already down are forgotten, so lifting them, during the dialog or after it closes, runs nothing.
|
||||
pub(super) fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo, modal: bool) {
|
||||
if !app.session.prefs.touch_gestures {
|
||||
return;
|
||||
}
|
||||
let key = egui::Id::unique("canvas-touch");
|
||||
if modal {
|
||||
ui.data_mut(|d| d.remove::<crate::touch::Taps>(key));
|
||||
return;
|
||||
}
|
||||
let (touches, now): (Vec<_>, f64) = ui.input(|i| {
|
||||
let t = i.events.iter().filter_map(|e| match e {
|
||||
egui::Event::Touch { id, phase, pos, .. } => Some((*id, *phase, *pos)),
|
||||
@@ -548,7 +556,6 @@ pub(super) fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect
|
||||
if touches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let key = egui::Id::unique("canvas-touch");
|
||||
let mut taps: crate::touch::Taps = ui.data(|d| d.get_temp(key)).unwrap_or_default();
|
||||
let mut tap = None;
|
||||
for (id, phase, pos) in touches {
|
||||
|
||||
@@ -251,7 +251,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
|
||||
// the layout.
|
||||
if let Some(fields) = ui.as_object_mut() {
|
||||
fields.remove("floating_panels");
|
||||
leave_out_unreadable_paths(fields);
|
||||
leave_out_unreadable_paths(app, fields);
|
||||
}
|
||||
json!({
|
||||
"scriptRunning": app.session.script_running(),
|
||||
@@ -1091,8 +1091,10 @@ fn leave_out_unreadable_values(v: &mut Value) {
|
||||
|
||||
/// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files,
|
||||
/// a library panel's file, folder preferences (saved, and in an open Preferences dialog) and the paths
|
||||
/// in recorded actions' steps outside the read root. The UI state itself is not changed.
|
||||
fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) {
|
||||
/// in recorded actions' steps outside the read root. The file selected in an open JavaScript Files
|
||||
/// dialog is named as `svgInteractivity.info` names the active document's linked files. The UI state
|
||||
/// itself is not changed.
|
||||
fn leave_out_unreadable_paths(app: &VectorcraftApp, ui: &mut serde_json::Map<String, Value>) {
|
||||
if let Some(Value::Array(files)) = ui.get_mut("recent_files") {
|
||||
files.retain(|f| f.as_str().is_some_and(readable));
|
||||
}
|
||||
@@ -1115,6 +1117,14 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) {
|
||||
{
|
||||
leave_out_unreadable_folders(fields);
|
||||
}
|
||||
if let Some(Value::Object(dialog)) = ui.get_mut("dialog")
|
||||
&& dialog.get("kind").and_then(Value::as_str) == Some(crate::dialogs::javascript_files::KIND)
|
||||
&& let Some(Value::Object(fields)) = dialog.get_mut("fields")
|
||||
&& let Some(Value::String(selected)) = fields.get_mut("selected")
|
||||
&& let Some(st) = app.session.active()
|
||||
{
|
||||
*selected = vectorcraft_engine::cmd::links::shown_script(st, selected).into_owned();
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read, as written
|
||||
|
||||
@@ -60,6 +60,7 @@ pub mod graphic_style_options;
|
||||
pub mod halftone;
|
||||
pub mod hyphenation;
|
||||
pub mod import_pdf;
|
||||
pub mod indexed_color;
|
||||
pub mod javascript_files;
|
||||
pub mod justification;
|
||||
pub mod layer_options;
|
||||
@@ -347,6 +348,7 @@ registry! {
|
||||
ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC,
|
||||
ColorBalance: [color_balance::KIND] => color_balance::SPEC,
|
||||
Saturate: [saturate::KIND] => saturate::SPEC,
|
||||
IndexedColor: [indexed_color::KIND] => indexed_color::SPEC,
|
||||
SaveSwatchLibrary: [save_swatch_library::KIND] => save_swatch_library::SPEC,
|
||||
TileEdgeColor: [tile_edge_color::KIND] => tile_edge_color::SPEC,
|
||||
EyedropperOptions: [eyedropper::KIND] => eyedropper::SPEC,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Edit → Edit Colors → Convert to Indexed Color: the palette (reduction and colors) and the
|
||||
//! dither of the selection's embedded images, previewed live on the canvas; OK keeps the preview
|
||||
//! as one undo step (`edit.colors.toIndexed`).
|
||||
//!
|
||||
//! Fields: `reduction`, `colors`, `dither`, `ditherAmount` and `preview`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_render::encode::quantize::{Dither, Reduction};
|
||||
|
||||
use super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Convert to Indexed Color.
|
||||
pub const KIND: &str = "indexedColor";
|
||||
|
||||
const CMD: &str = "edit.colors.toIndexed";
|
||||
|
||||
/// The reductions besides those of a PNG-8 export, as (value, label).
|
||||
const MORE_REDUCTIONS: [(&str, &str); 2] = [("uniform", "Uniform"), ("document", "Document Swatches")];
|
||||
|
||||
/// Label column and dropdown widths.
|
||||
const WIDTHS: (f32, f32) = (110.0, 160.0);
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("Convert to Indexed Color").into(), body, confirm, preview: true, min_width: 320.0, ..DialogSpec::FORM };
|
||||
|
||||
/// Open the dialog with the defaults of `edit.colors.toIndexed`.
|
||||
pub fn open(app: &mut VectorcraftApp) {
|
||||
let fields = json!({"reduction": "selective", "colors": 256, "dither": "diffusion", "ditherAmount": 100, "preview": true});
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
}
|
||||
|
||||
/// Whether reduction `r` takes a number of colors.
|
||||
fn takes_colors(r: &str) -> bool {
|
||||
!matches!(r, "blackWhite" | "document")
|
||||
}
|
||||
|
||||
/// The fewest colors reduction `r` takes.
|
||||
fn least_colors(r: &str) -> f64 {
|
||||
if r == "uniform" { 9.0 } else { 2.0 }
|
||||
}
|
||||
|
||||
/// `edit.colors.toIndexed` parameters: the reduction, its colors when it takes them, and the dither.
|
||||
fn params(d: &Dialog) -> Value {
|
||||
let reduction = d.str("reduction");
|
||||
let dither = d.str("dither");
|
||||
let mut p = json!({"reduction": reduction, "dither": dither});
|
||||
if takes_colors(&reduction) {
|
||||
p["colors"] = json!(d.f64("colors", 256.0).round().clamp(least_colors(&reduction), 256.0) as u16);
|
||||
}
|
||||
if dither != Dither::None.id() {
|
||||
p["ditherAmount"] = json!(d.f64("ditherAmount", 100.0).round().clamp(0.0, 100.0));
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let reductions: Vec<(&str, &str)> = Reduction::ALL.iter().map(|r| (r.id(), r.label())).chain(MORE_REDUCTIONS).collect();
|
||||
form::choice(ui, d, "reduction", tl!("Reduction:"), WIDTHS, &reductions);
|
||||
let reduction = d.str("reduction");
|
||||
if takes_colors(&reduction) {
|
||||
// The field shows the number the command gets: at least the reduction's fewest.
|
||||
let least = least_colors(&reduction);
|
||||
let n = d.f64("colors", 256.0).round().clamp(least, 256.0);
|
||||
d.fields.insert("colors".into(), json!(n as u16));
|
||||
widgets::label_row(ui, tl!("Colors:"), WIDTHS.0, |ui| {
|
||||
if let Some(n) = widgets::range_field(ui, "indexed-colors", n, least..=256.0, "", 0, 60.0) {
|
||||
d.fields.insert("colors".into(), json!(n.round() as u16));
|
||||
}
|
||||
});
|
||||
}
|
||||
let dithers: Vec<(&str, &str)> = Dither::ALL.iter().map(|x| (x.id(), x.label())).collect();
|
||||
form::choice(ui, d, "dither", tl!("Dither:"), WIDTHS, &dithers);
|
||||
if d.str("dither") != Dither::None.id() {
|
||||
widgets::label_row(ui, tl!("Amount:"), WIDTHS.0, |ui| {
|
||||
if let Some(n) = widgets::range_field(ui, "indexed-dither-amount", d.f64("ditherAmount", 100.0), 0.0..=100.0, "%", 0, 60.0) {
|
||||
d.fields.insert("ditherAmount".into(), json!(n.round()));
|
||||
}
|
||||
});
|
||||
}
|
||||
let p = params(d);
|
||||
form::preview(app, ui, d, "Convert to Indexed Color", CMD, p);
|
||||
false
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
form::commit_preview(app, CMD, params(d))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn commits_the_chosen_palette_as_one_step() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
let mut png = Vec::new();
|
||||
let img = image::RgbaImage::from_raw(2, 1, vec![200, 30, 90, 255, 20, 40, 220, 255]).unwrap();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let id = app
|
||||
.session
|
||||
.edit("Place", |d, sel| {
|
||||
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png));
|
||||
let id = d.alloc_id();
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 2,
|
||||
height: 1,
|
||||
xf: Default::default(),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
d.insert(d.default_layer(), 0, vectorcraft_doc::Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(im))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
app.run("ui.indexedColorDialog", json!({})).unwrap();
|
||||
let fields = &mut app.ui.dialog.as_mut().unwrap().fields;
|
||||
fields.insert("reduction".into(), json!("blackWhite"));
|
||||
fields.insert("dither".into(), json!("none"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let st = app.session.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
let vectorcraft_doc::NodeKind::Image(im) = &st.doc.node(id).unwrap().kind else { panic!("not an image") };
|
||||
let blob = &st.doc.images[&im.key];
|
||||
assert!(blob.indexed_png());
|
||||
assert_eq!(blob.rgba().unwrap().2, [0, 0, 0, 255, 0, 0, 0, 255], "black and white only");
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,12 @@
|
||||
//! linked file. Add and Remove act at once. The dialog has only Close.
|
||||
//!
|
||||
//! Fields: `file` (the path or URL to link), `selected` (a linked file).
|
||||
//!
|
||||
//! In a frame automation drives with read roots in force, the list names a file outside them by
|
||||
//! its file name alone, as `svgInteractivity.info` does; `selected` keeps the file as linked.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::links::shown_script;
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::picks::PickRequest;
|
||||
@@ -63,18 +67,36 @@ enum Action {
|
||||
Remove,
|
||||
}
|
||||
|
||||
/// The active document's linked files, each as it is linked and as the list shows it
|
||||
/// ([`shown_script`]).
|
||||
fn listed(app: &VectorcraftApp) -> Vec<(String, String)> {
|
||||
app.session.active().map_or_else(Vec::new, |st| st.doc.svg_scripts.iter().map(|f| (f.clone(), shown_script(st, f).into_owned())).collect())
|
||||
}
|
||||
|
||||
/// The row of `files` ([`listed`]) that `selected` names: the one row linked or shown as it. A
|
||||
/// click selects a file as it is linked; a request may also give it as the list shows it. A name
|
||||
/// that fits several rows selects none, as `svgInteractivity.removeFile` refuses it.
|
||||
fn selected_row(files: &[(String, String)], selected: &str) -> Option<usize> {
|
||||
let mut rows = files.iter().enumerate().filter(|(_, (linked, shown))| linked == selected || shown == selected);
|
||||
match (rows.next(), rows.next()) {
|
||||
(Some((i, _)), None) => Some(i),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let files: Vec<String> = app.session.active().map(|st| st.doc.svg_scripts.clone()).unwrap_or_default();
|
||||
let files = listed(app);
|
||||
let selected = d.str("selected");
|
||||
let row = selected_row(&files, &selected);
|
||||
let mut act = None;
|
||||
widgets::list_box(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("javascript-files").min_scrolled_height(140.0).max_height(140.0).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.set_min_height(140.0);
|
||||
// Paths and URLs are data: shown as they are.
|
||||
for f in &files {
|
||||
if ui.selectable_label(*f == selected, f.as_str()).clicked() {
|
||||
act = Some(Action::Select(f.clone()));
|
||||
for (i, (linked, shown)) in files.iter().enumerate() {
|
||||
if ui.selectable_label(row == Some(i), shown.as_str()).clicked() {
|
||||
act = Some(Action::Select(linked.clone()));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -94,7 +116,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
if ui.add_enabled_ui(!d.str("file").trim().is_empty(), |ui| widgets::flat_button(ui, tl!("Add"), 64.0)).inner.clicked() {
|
||||
act = Some(Action::Add);
|
||||
}
|
||||
if ui.add_enabled_ui(files.contains(&selected), |ui| widgets::flat_button(ui, tl!("Remove"), 64.0)).inner.clicked() {
|
||||
if ui.add_enabled_ui(row.is_some(), |ui| widgets::flat_button(ui, tl!("Remove"), 64.0)).inner.clicked() {
|
||||
act = Some(Action::Remove);
|
||||
}
|
||||
});
|
||||
@@ -164,6 +186,39 @@ mod tests {
|
||||
assert!(texts.iter().any(|t| t == "https://example.com/b.js"), "the field shows `file`: {texts:?}");
|
||||
}
|
||||
|
||||
/// In a frame drawn with read roots in force, the list names a linked file outside them by its
|
||||
/// file name alone, and that name unlinks it; the person sees the whole path.
|
||||
#[test]
|
||||
fn automation_javascript_files_dialog_hides_outside_root_paths() {
|
||||
let base = std::env::temp_dir().join(format!("vc-js-dialog-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join("in")).unwrap();
|
||||
let outside = base.join("outside").join("menu.js").to_string_lossy().into_owned();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
app.session.execute("svgInteractivity.addFile", &json!({ "file": outside })).unwrap();
|
||||
let mut d = Dialog::new(KIND, json!({ "file": "", "selected": "" }));
|
||||
let texts = frame(&mut app, &mut d);
|
||||
assert!(texts.contains(&outside), "{texts:?}");
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
|
||||
let texts = vectorcraft_engine::file_access::confine(roots.as_ref(), || frame(&mut app, &mut d));
|
||||
assert!(texts.iter().any(|t| t == "menu.js") && !texts.iter().any(|t| t.contains("vc-js-dialog")), "{texts:?}");
|
||||
// A row keeps the file as linked, so a click selects it by its whole path, which stays
|
||||
// selected in the person's list; a name a request gives selects it too.
|
||||
let rows = vectorcraft_engine::file_access::confine(roots.as_ref(), || listed(&app));
|
||||
assert_eq!(rows, [(outside.clone(), "menu.js".to_string())]);
|
||||
assert_eq!((selected_row(&rows, &outside), selected_row(&rows, "menu.js")), (Some(0), Some(0)));
|
||||
// A name two rows show selects neither.
|
||||
let twins = [rows[0].clone(), ("/elsewhere/menu.js".to_string(), "menu.js".to_string())];
|
||||
assert_eq!((selected_row(&twins, "menu.js"), selected_row(&twins, "/elsewhere/menu.js")), (None, Some(1)));
|
||||
assert_eq!(listed(&app), [(outside.clone(), outside.clone())], "the person's");
|
||||
// Remove sends the selected name.
|
||||
let removed = vectorcraft_engine::file_access::confine(roots.as_ref(), || app.run("svgInteractivity.removeFile", json!({"file": "menu.js"})));
|
||||
assert!(removed.is_ok(), "{removed:?}");
|
||||
assert!(app.session.active().is_some_and(|st| st.doc.svg_scripts.is_empty()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Choose… puts the picked file in File or URL as a `file:///` URL, with no `\` and with
|
||||
/// spaces percent-encoded.
|
||||
#[test]
|
||||
|
||||
@@ -428,6 +428,7 @@ fn dialogs_do_not_stretch_to_the_screen() {
|
||||
("ui.colorGuideOptions", json!({})),
|
||||
("ui.colorBalanceDialog", json!({})),
|
||||
("ui.saturateDialog", json!({})),
|
||||
("ui.indexedColorDialog", json!({})),
|
||||
("ui.saveSwatchLibrary", json!({})),
|
||||
("ui.flattenTransparencyDialog", json!({})),
|
||||
("ui.expandDialog", json!({})),
|
||||
|
||||
@@ -614,6 +614,8 @@ font-technology Variable Променливи
|
||||
Convert to Artboards Конвертиране в работни области
|
||||
Convert to CMYK Конвертиране в CMYK
|
||||
Convert to Grayscale Конвертиране в ска̀ла на сивото
|
||||
Convert to Indexed Color Конвертиране в индексирани цветове
|
||||
Convert to Indexed Color… Конвертиране в индексирани цветове…
|
||||
Convert To Outlines Конвертиране в контури
|
||||
Convert To Point Type Конвертиране в точков текст
|
||||
Convert to RGB Конвертиране в RGB
|
||||
@@ -4130,6 +4132,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 Създаване на контури: изберете текстови обекти
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -103,6 +103,8 @@
|
||||
Blend Vertically Stopi okomito
|
||||
Convert to CMYK Pretvori u CMYK
|
||||
Convert to Grayscale Pretvori u nijanse sive
|
||||
Convert to Indexed Color Pretvori u indeksirane boje
|
||||
Convert to Indexed Color… Pretvori u indeksirane boje…
|
||||
Convert to RGB Pretvori u RGB
|
||||
Invert Colors Obrni boje
|
||||
Overprint Black… Pretisak crne…
|
||||
@@ -4494,6 +4496,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -906,6 +906,8 @@ antiAlias Sharp Nítid
|
||||
Convert to destination (preserve numbers) Converteix al destí (conserva els números)
|
||||
Convert to Global Converteix en global
|
||||
Convert to Grayscale Converteix a escala de grisos
|
||||
Convert to Indexed Color Converteix a color indexat
|
||||
Convert to Indexed Color… Converteix a color indexat…
|
||||
Convert To Outlines Converteix en contorns
|
||||
Convert To Point Type Converteix en text de punt
|
||||
Convert to RGB Converteix a RGB
|
||||
@@ -5542,6 +5544,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}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -108,6 +108,8 @@
|
||||
Blend Vertically Prolnout svisle
|
||||
Convert to CMYK Převést na CMYK
|
||||
Convert to Grayscale Převést na stupně šedi
|
||||
Convert to Indexed Color Převést na indexované barvy
|
||||
Convert to Indexed Color… Převést na indexované barvy…
|
||||
Convert to RGB Převést na RGB
|
||||
Invert Colors Invertovat barvy
|
||||
Overprint Black… Přetisknout černou…
|
||||
@@ -4651,6 +4653,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -96,6 +96,8 @@
|
||||
Blend Vertically Bland lodret
|
||||
Convert to CMYK Konvertér til CMYK
|
||||
Convert to Grayscale Konvertér til gråtoner
|
||||
Convert to Indexed Color Konvertér til indekserede farver
|
||||
Convert to Indexed Color… Konvertér til indekserede farver…
|
||||
Convert to RGB Konvertér til RGB
|
||||
Invert Colors Invertér farver
|
||||
Overprint Black… Overprint sort…
|
||||
@@ -4246,6 +4248,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -607,6 +607,8 @@ font-technology Variable Variabel
|
||||
Convert to Artboards In Zeichenflächen umwandeln
|
||||
Convert to CMYK In CMYK konvertieren
|
||||
Convert to Grayscale In Graustufen konvertieren
|
||||
Convert to Indexed Color In indizierte Farben konvertieren
|
||||
Convert to Indexed Color… In indizierte Farben konvertieren…
|
||||
Convert To Outlines In Pfade umwandeln
|
||||
Convert To Point Type In Punkttext umwandeln
|
||||
Convert to RGB In RGB konvertieren
|
||||
@@ -4264,6 +4266,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -588,6 +588,8 @@ font-technology Variable Μεταβλητές
|
||||
Convert to Artboards Μετατροπή σε πίνακες σχεδίασης
|
||||
Convert to CMYK Μετατροπή σε CMYK
|
||||
Convert to Grayscale Μετατροπή σε κλίμακα του γκρι
|
||||
Convert to Indexed Color Μετατροπή σε χρώματα με ευρετήριο
|
||||
Convert to Indexed Color… Μετατροπή σε χρώματα με ευρετήριο…
|
||||
Convert To Outlines Μετατροπή σε περιγράμματα
|
||||
Convert To Point Type Μετατροπή σε κείμενο σημείου
|
||||
Convert to RGB Μετατροπή σε RGB
|
||||
@@ -2421,6 +2423,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 Δημιουργία περιγραμμάτων: επιλέξτε αντικείμενα κειμένου
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -629,6 +629,8 @@ font-technology Variable Variables
|
||||
Convert to Artboards Convertir en mesas de trabajo
|
||||
Convert to CMYK Convertir a CMYK
|
||||
Convert to Grayscale Convertir a escala de grises
|
||||
Convert to Indexed Color Convertir a color indexado
|
||||
Convert to Indexed Color… Convertir a color indexado…
|
||||
Convert To Outlines Convertir en contornos
|
||||
Convert To Point Type Convertir en texto de punto
|
||||
Convert to RGB Convertir a RGB
|
||||
@@ -4341,6 +4343,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -116,6 +116,8 @@
|
||||
Blend Vertically Sulauta pystysuunnassa
|
||||
Convert to CMYK Muunna CMYK:ksi
|
||||
Convert to Grayscale Muunna harmaasävyiseksi
|
||||
Convert to Indexed Color Muunna indeksoiduiksi väreiksi
|
||||
Convert to Indexed Color… Muunna indeksoiduiksi väreiksi…
|
||||
Convert to RGB Muunna RGB:ksi
|
||||
Invert Colors Käännä värit
|
||||
Overprint Black… Päällepainata musta…
|
||||
@@ -4565,6 +4567,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -630,6 +630,8 @@ font-technology Variable Variables
|
||||
Convert to Artboards Convertir en plans de travail
|
||||
Convert to CMYK Convertir en CMJN
|
||||
Convert to Grayscale Convertir en niveaux de gris
|
||||
Convert to Indexed Color Convertir en couleurs indexées
|
||||
Convert to Indexed Color… Convertir en couleurs indexées…
|
||||
Convert To Outlines Vectoriser
|
||||
Convert To Point Type Convertir en texte de point
|
||||
Convert to RGB Convertir en RVB
|
||||
@@ -4330,6 +4332,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -866,7 +866,7 @@ antiAlias Sharp शार्प
|
||||
Close All सभी बंद करें
|
||||
Close Gaps With Paths पाथ के साथ अंतराल बंद करें
|
||||
Close Library लाइब्रेरी
|
||||
Close or Join बंद करें या शामिल हों
|
||||
Close or Join बंद करें या जोड़ें
|
||||
Close Panel पैनल बंद करें
|
||||
Close Path बंद पाथ
|
||||
Close Path: बंद पाथ:
|
||||
@@ -1036,6 +1036,8 @@ antiAlias Sharp शार्प
|
||||
Convert to destination (preserve numbers) गंतव्य में कनवर्ट करें (संख्या संरक्षित करें)
|
||||
Convert to Global ग्लोबल में कनवर्ट करें
|
||||
Convert to Grayscale ग्रेस्केल में बदलें
|
||||
Convert to Indexed Color अनुक्रमित रंग में बदलें
|
||||
Convert to Indexed Color… अनुक्रमित रंग में बदलें…
|
||||
Convert To Outlines कन्वर्ट करने के लिए रूपरेखा
|
||||
Convert to Parametric Shape पैरामीट्रिक शेप में कन्वर्ट करें
|
||||
Convert To Point Type पॉइंट टाइप में कनवर्ट करें
|
||||
@@ -1232,7 +1234,7 @@ antiAlias Sharp शार्प
|
||||
Default Position डिफ़ॉल्ट स्थिति
|
||||
Default Printer डिफ़ॉल्ट प्रिंटर
|
||||
Default Swatches डिफ़ॉल्ट स्वॉचेस
|
||||
Define a variable first पहले एक चर को परिभाषित करें
|
||||
Define a variable first पहले एक वेरिएबल परिभाषित करें
|
||||
Define Grid… ग्रिड परिभाषित…
|
||||
Define Perspective Grid पर्सपेक्टिव ग्रिड डिफ़ाइन करें
|
||||
Define Perspective Grid… पर्सपेक्टिव ग्रिड डिफ़ाइन करें…
|
||||
@@ -1308,7 +1310,7 @@ antiAlias Sharp शार्प
|
||||
Delete this saved preset इस सेव किए गए प्रीसेट को हटा दें
|
||||
Delete Toolbar टूलबार हटाएं
|
||||
Delete Transparency Flattener Preset पारदर्शिता फ़्लैटनर प्रीसेट हटाएँ
|
||||
Delete Variable चर हटाएँ
|
||||
Delete Variable वेरिएबल हटाएँ
|
||||
Delete Variables वेरिएबल्स हटाएँ
|
||||
Delete View व्यू हटाएँ
|
||||
Delete Width Point चौड़ाई बिंदु हटाएँ
|
||||
@@ -2251,7 +2253,7 @@ antiAlias Sharp शार्प
|
||||
Highlight Substituted Fonts हाईलाइट सबस्टिट्यूटेड फ़ॉन्ट
|
||||
Highlight Substituted Glyphs बदले हुए ग्लिफ़ हाइलाइट करें
|
||||
Highlight Tracking हाइलाइट ट्रैकिंग
|
||||
Highlight Variable उच्च प्रकाश परिवर्तनीय
|
||||
Highlight Variable वेरिएबल हाइलाइट करें
|
||||
Highlight: हाइलाइट:
|
||||
Hindi हिन्दी
|
||||
History हिस्ट्री
|
||||
@@ -2440,9 +2442,9 @@ antiAlias Sharp शार्प
|
||||
Join जोड़ें
|
||||
Join our Discord हमारे डिसकॉर्ड में शामिल हों
|
||||
Join Our Discord हमारे डिसकॉर्ड में शामिल हों
|
||||
Join Point Text पॉइंट टेक्स्ट में शामिल हों
|
||||
Join Point Text पॉइंट टेक्स्ट जोड़ें
|
||||
Join the ArtCraft community on Discord (discord.gg/artcraft) Discord (discord.gg/artcraft) पर ArtCraft समुदाय में शामिल हों
|
||||
Join the lines of each paragraph प्रत्येक पैराग्राफ की लाइनों में शामिल हों
|
||||
Join the lines of each paragraph हर पैराग्राफ़ की लाइनें जोड़ें
|
||||
Join Tool जॉइन टूल
|
||||
Joins जुड़ना
|
||||
Joins: जुड़ना:
|
||||
@@ -2644,7 +2646,7 @@ antiAlias Sharp शार्प
|
||||
List Preferences सूची प्राथमिकताएं
|
||||
List Slices सूची स्लाइस
|
||||
List Toolbars सूची टूलबार
|
||||
List Variables सूची चर
|
||||
List Variables वेरिएबल्स सूचीबद्ध करें
|
||||
List View सूची दृश्य
|
||||
List Workspaces सूची वर्कस्पेस
|
||||
Live curve fitting लाइव वक्र फिटिंग
|
||||
@@ -2667,8 +2669,8 @@ antiAlias Sharp शार्प
|
||||
Load SVG into Clipboard SVG को क्लिपबोर्ड में लोड करें
|
||||
Load Text into Clipboard क्लिपबोर्ड में टेक्स्ट लोड करें
|
||||
Load Texture… लोड बनावट…
|
||||
Load Variable Library लोड परिवर्तनीय लाइब्रेरी
|
||||
Load Variable Library… लोड परिवर्तनीय लाइब्रेरी…
|
||||
Load Variable Library वेरिएबल लाइब्रेरी लोड करें
|
||||
Load Variable Library… वेरिएबल लाइब्रेरी लोड करें…
|
||||
Loaded Texture भारित बनावट
|
||||
Loads native code मूल कोड लोड करता है
|
||||
Local Libraries स्थानीय लाइब्रेरी
|
||||
@@ -2690,7 +2692,7 @@ antiAlias Sharp शार्प
|
||||
Lock Selected लॉक सिलेक्टेड
|
||||
Lock Slices स्लाइस लॉक करें
|
||||
Lock Station Point लॉक स्टेशन पॉइंट
|
||||
Lock Variables लॉक चर
|
||||
Lock Variables वेरिएबल्स लॉक करें
|
||||
Locked लॉक्ड
|
||||
Logo 1000×1000 लोगो 1000 × 1000
|
||||
Long लंबा
|
||||
@@ -3007,8 +3009,8 @@ antiAlias Sharp शार्प
|
||||
New theme from the current color वर्तमान रंग से नया विषय
|
||||
New Toolbar नया टूलबार
|
||||
New Toolbar… नया टूलबार…
|
||||
New Variable नया परिवर्तनीय
|
||||
New Variable… नई परिवर्तनीय…
|
||||
New Variable नया वेरिएबल
|
||||
New Variable… नया वेरिएबल…
|
||||
New View नया दृश्य
|
||||
New View… नया दृश्य…
|
||||
New Window नई विंडो
|
||||
@@ -3073,7 +3075,7 @@ antiAlias Sharp शार्प
|
||||
No text selected कोई टेक्स्ट नहीं चुना गया
|
||||
No Thumbnails कोई थंबनेल नहीं
|
||||
No unused symbols अप्रयुक्त सिंबल
|
||||
no variable to rename नाम बदलने के लिए कोई चर नहीं
|
||||
no variable to rename नाम बदलने के लिए कोई वेरिएबल नहीं
|
||||
Noise नॉइज़
|
||||
Nonbreaking Hyphen न टूटने वाला हाइफ़न
|
||||
Nonbreaking Space न टूटने वाला स्पेस
|
||||
@@ -3734,7 +3736,7 @@ antiAlias Sharp शार्प
|
||||
Rays रे
|
||||
Read Action Set File एक्शन सेट फ़ाइल पढ़ें
|
||||
Read Shortcut Set File शॉर्टकट सेट फ़ाइल पढ़ें
|
||||
Reads or sets environment variables पर्यावरण चर पढ़ता है या सेट करता है
|
||||
Reads or sets environment variables एनवायरनमेंट वेरिएबल्स पढ़ता है या सेट करता है
|
||||
Ready तैयार
|
||||
Real name वास्तविक नाम
|
||||
Real World: रियल वर्ल्ड:
|
||||
@@ -3909,7 +3911,7 @@ antiAlias Sharp शार्प
|
||||
Rename Preset प्रीसेट का नाम बदलें
|
||||
Rename Style स्टाइल का नाम बदलें
|
||||
Rename Toolbar टूलबार नाम दें
|
||||
Rename Variable चर का नाम बदलें
|
||||
Rename Variable वेरिएबल का नाम बदलें
|
||||
Rename View व्यू का नाम बदलें
|
||||
Rename Workspace वर्कस्पेस का नाम बदलें
|
||||
Rename… नाम बदलें…
|
||||
@@ -4205,7 +4207,7 @@ antiAlias Sharp शार्प
|
||||
Save To: यहाँ सेव करें:
|
||||
Save Transparency Flattener Preset ट्रांसपेरेंसी फ़्लैटनर प्रीसेट सेव करें
|
||||
Save Transparency Flattener Preset… ट्रांसपेरेंसी फ़्लैटनर प्रीसेट सेव करें…
|
||||
Save Variable Library… वैरिएबल लाइब्रेरी सेव करें…
|
||||
Save Variable Library… वेरिएबल लाइब्रेरी सेव करें…
|
||||
Save… सेव…
|
||||
Saved सेव किए गए
|
||||
SAVED सेव किए गए
|
||||
@@ -5377,11 +5379,11 @@ antiAlias Sharp शार्प
|
||||
V Spacing: वी स्पेसिंग:
|
||||
V: V:
|
||||
Value Axis मूल्य एक्सिस
|
||||
Variable चर
|
||||
Variable वेरिएबल
|
||||
Variable Font वेरिएबल फ़ॉन्ट
|
||||
Variable Options वैरिएबल ऑप्शन्स
|
||||
Variable Options… परिवर्तनीय ऑप्शन्स…
|
||||
Variable Width Profile वैरिएबल चौड़ाई प्रोफ़ाइल
|
||||
Variable Options वेरिएबल ऑप्शन्स
|
||||
Variable Options… वेरिएबल ऑप्शन्स…
|
||||
Variable Width Profile वेरिएबल चौड़ाई प्रोफ़ाइल
|
||||
Variables वेरिएबल्स
|
||||
Variation वैरिएशन
|
||||
Variation: भिन्नता:
|
||||
@@ -6030,7 +6032,7 @@ antiAlias Sharp शार्प
|
||||
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` {size} bytes (limit {MAX_DOCUMENT_DATA}) है।
|
||||
@msg `each` is the value one design stands for, more than 0 `each` एक डिज़ाइन का मान है, जो 0 से अधिक होना चाहिए
|
||||
@msg `end` must be "start" or "end" `end` को "start" या "end" होना चाहिए।
|
||||
@msg `ends` applies to joining exactly two paths `ends` वास्तव में दो पाथ में शामिल होने के लिए लागू होता है
|
||||
@msg `ends` applies to joining exactly two paths `ends` ठीक दो पाथ को जोड़ने पर लागू होता है
|
||||
@msg `event` must be one of {_1} `event` is one of the {_1}
|
||||
@msg `every` must be "all", "odd" or "even", not {v} `every` होना चाहिए "all", "odd" या "even", नहीं {v}
|
||||
@msg `expand` must be true or false `expand` को true या false होना चाहिए
|
||||
@@ -6246,7 +6248,7 @@ antiAlias Sharp शार्प
|
||||
@msg a multi-layer mask needs one clipping path बहुलेयर मास्क को एक क्लिपिंग पाथ की आवश्यकता होती है
|
||||
@msg a multi-layer mask's operators are out of order बहुलेयर मास्क ऑपरेटर क्रम से बाहर हैं
|
||||
@msg a name names one asset: give multiple: false, or one object एक नाम एक ही एसेट को दर्शाता है: multiple: false दें, या एक ऑब्जेक्ट
|
||||
@msg a new document refuses this file because of these variables: {names} एक नया डॉक्यूमेंट इन चरों के कारण इस फ़ाइल को मना कर देता है: {names}
|
||||
@msg a new document refuses this file because of these variables: {names} इन वेरिएबल्स के कारण नया डॉक्यूमेंट इस फ़ाइल को स्वीकार नहीं करता: {names}
|
||||
@msg a new document won't read these variables back with their kind: {names} नया डॉक्यूमेंट इन वेरिएबल्स का प्रकार वापस नहीं पढ़ पाएगा: {names}
|
||||
@msg a page can't be read एक पेज पढ़ा नहीं जा सकता
|
||||
@msg a page is missing एक पेज गायब हो रहा है
|
||||
@@ -6298,8 +6300,8 @@ antiAlias Sharp शार्प
|
||||
@msg a toolbar name has at most {MAX_NAME} characters टूलबार नाम में सबसे {MAX_NAME} वर्ण हैं
|
||||
@msg a value has the wrong type for the operator एक मूल्य ऑपरेटर के लिए गलत प्रकार है
|
||||
@msg a value is outside the range the operator takes एक मूल्य सीमा के बाहर है जो ऑपरेटर लेता है
|
||||
@msg a variable library holds text variables, and the document has none एक परिवर्तनीय लाइब्रेरी टेक्स्ट चर रखती है, और डॉक्यूमेंट में कोई नहीं है
|
||||
@msg a variable of unknown kind can't be bound to objects अज्ञात प्रकार की एक चर ऑब्जेक्ट्स के लिए बाध्य नहीं किया जा सकता है
|
||||
@msg a variable library holds text variables, and the document has none वेरिएबल लाइब्रेरी में टेक्स्ट वेरिएबल होते हैं, लेकिन डॉक्यूमेंट में कोई नहीं है
|
||||
@msg a variable of unknown kind can't be bound to objects अज्ञात प्रकार का वेरिएबल ऑब्जेक्ट्स से नहीं बाँधा जा सकता
|
||||
@msg a view named `{_1}` exists `{_1}` नामक दृश्य मौजूद है
|
||||
@msg a view named `{name}` exists `{name}` नामक दृश्य मौजूद है
|
||||
@msg a workspace keeps at most {MAX} toolbars एक वर्कस्पेस सबसे {MAX} टूलबार में रहता है
|
||||
@@ -6438,7 +6440,7 @@ antiAlias Sharp शार्प
|
||||
@msg batches cannot nest बैच घोंसला नहीं कर सकते
|
||||
@msg before must name a panel before: पहले एक पैनल का नाम देना चाहिए
|
||||
@msg below and above must name a panel floating in another group नीचे और ऊपर एक पैनल दूसरे समूह में चल रहा है नाम होना चाहिए
|
||||
@msg bind something to a variable first पहले एक चर के लिए कुछ बांधना
|
||||
@msg bind something to a variable first पहले किसी ऑब्जेक्ट को वेरिएबल से बाँधें
|
||||
@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed होना चाहिए 0-{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}}
|
||||
@msg Blend colors: select at least three objects with filled colours मिश्रित रंग: भरे हुए रंगों के साथ कम से कम तीन ऑब्जेक्ट चुनें
|
||||
@msg BMP files are RGB or grayscale: colorModel rgb or gray BMP फ़ाइलें RGB या ग्रेस्केल होती हैं: colorModel को rgb या gray रखें
|
||||
@@ -6512,20 +6514,20 @@ antiAlias Sharp शार्प
|
||||
@msg colour groups can't go into colour groups रंग समूह रंग समूहों में नहीं जा सकते हैं
|
||||
@msg colour, mode and spot apply to solid-colour swatches only रंग, मोड और स्पॉट केवल ठोस रंग वाले स्वॉच पर लागू होते हैं
|
||||
@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read कॉलम {column}: `{cell}` ग्राफ़ डेटा फ़ाइल का `%` कॉलम है, जिसे पढ़ा नहीं जाता
|
||||
@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable कॉलम {column}: `{cell}` नाम `{name}`, जो एक {_1} चर है
|
||||
@msg column {column}: `{cell}` names no variable कॉलम {column}: `{cell}` नाम कोई चर नहीं
|
||||
@msg column {column}: the variable name is longer than {MAX_NAME} characters कॉलम {column}: चर नाम {MAX_NAME} अक्षरों से लंबा है
|
||||
@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable कॉलम {column}: `{cell}` में `{name}` नाम का {_1} वेरिएबल है
|
||||
@msg column {column}: `{cell}` names no variable कॉलम {column}: `{cell}` किसी वेरिएबल का नाम नहीं है
|
||||
@msg column {column}: the variable name is longer than {MAX_NAME} characters कॉलम {column}: वेरिएबल का नाम {MAX_NAME} कैरेक्टर से लंबा है
|
||||
@msg column docks a set as a dock column, with no anchor or icon कॉलम एक डॉक कॉलम के रूप में सेट करता है, जिसमें कोई एंकर या आइकन नहीं होता है
|
||||
@msg column must be true or a number from 1 column को true या 1 या उससे बड़ी संख्या होना चाहिए
|
||||
@msg column: true floats the whole column and takes no onto, above or below column: true पूरे कॉलम को फ़्लोट करता है और onto, above या below नहीं लेता
|
||||
@msg columns {first} and {column} both name the variable `{name}` कॉलम {first} और {column} दोनों नाम चर `{name}`
|
||||
@msg columns {first} and {column} both name the variable `{name}` कॉलम {first} और {column} दोनों में वेरिएबल `{name}` का नाम है
|
||||
@msg Combine Slices: the slices have no bounds स्लाइस को मिलाएं: स्लाइस में कोई सीमा नहीं है
|
||||
@msg command `{0}` is not available right now: {1} आदेश `{0}` अभी उपलब्ध नहीं है: {1}
|
||||
@msg command `{id}` is not available right now आदेश `{id}` अभी उपलब्ध नहीं है
|
||||
@msg command must be a shape.* command command: आदेश एक आकार होना चाहिए।* आदेश
|
||||
@msg commands[{i}]: missing `command` आदेश [{i}]: लापता `command`
|
||||
@msg composer must be "singleLine" or "everyLine" composer: संगीतकार "singleLine" या "everyLine" होना चाहिए।
|
||||
@msg compound {_1} must contain exactly one open path to join यौगिक {_1} में शामिल होने के लिए बिल्कुल एक खुला रास्ता होना चाहिए
|
||||
@msg compound {_1} must contain exactly one open path to join जोड़ने के लिए कंपाउंड {_1} में ठीक एक खुला पाथ होना चाहिए
|
||||
@msg compound paths can only contain paths यौगिक पाथ में केवल पाथ हो सकते हैं
|
||||
@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes कंपाउंड शेप पाथ, कंपाउंड पाथ, समूह, कंपाउंड शेप, टाइप, ब्लेंड और एन्वेलप लेते हैं
|
||||
@msg compressed (RLE) bitmaps can't be read yet संकुचित (RLE) bitmaps अभी तक पढ़ा नहीं जा सकता है
|
||||
@@ -6659,6 +6661,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
|
||||
@@ -6686,11 +6691,11 @@ antiAlias Sharp शार्प
|
||||
@msg data must be an object of key: value, not {v} data: डेटा कुंजी का एक ऑब्जेक्ट होना चाहिए: मान, नहीं {v}
|
||||
@msg Data Recovery can't keep copies in {path}: {e} डेटा रिकवरी {path} में प्रतियां नहीं रख सकती: {e}
|
||||
@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) डेटा रिकवरी की प्रतिलिपियाँ रखने के लिए कहीं नहीं है: निर्धारित प्राथमिकता → फ़ाइल हैंडलिंग → डेटा रिकवरी → फ़ोल्डर (recoveryFolder)
|
||||
@msg data set `{name}` holds a control character, which a variable library can't hold डेटा सेट `{name}` में एक नियंत्रण चरित्र होता है, जो एक परिवर्तनीय लाइब्रेरी पकड़ नहीं सकता है
|
||||
@msg data set `{name}` holds a control character, which a variable library can't hold डेटा सेट `{name}` में एक कंट्रोल कैरेक्टर है, जिसे वेरिएबल लाइब्रेरी में नहीं रखा जा सकता
|
||||
@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare डेटा सेट `{name}` `{var}` के लिए एक मान रखता है, जिसे लाइब्रेरी घोषित नहीं करती है
|
||||
@msg data set `{name}` holds two values for `{var}` डेटा सेट `{name}` में `{var}` के लिए दो मान हैं।
|
||||
@msg data set `{name}` is declared twice डेटा सेट `{name}` को दो बार घोषित किया गया है
|
||||
@msg data set `{name}` starts or ends with white space, which a variable library can't hold डेटा सेट `{name}` शुरू होता है या व्हाइट स्पेस के साथ समाप्त होता है, जो एक परिवर्तनीय लाइब्रेरी को नहीं पकड़ सकता है
|
||||
@msg data set `{name}` starts or ends with white space, which a variable library can't hold डेटा सेट `{name}` की शुरुआत या अंत में व्हाइटस्पेस है, जिसे वेरिएबल लाइब्रेरी में नहीं रखा जा सकता
|
||||
@msg data takes at most {MAX_DATA} keys at once data: डेटा एक बार में सबसे {MAX_DATA} कुंजी लेता है
|
||||
@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none प्रोग्राम पढ़ने वाले डेटा को क्षतिग्रस्त या छोटा काट दिया जाता है, या प्रोग्राम एक फ़ाइल में एक स्थिति के लिए पूछता है जिसमें कोई नहीं है।
|
||||
@msg dataset `{name}` already exists डेटासेट `{name}` पहले से मौजूद है
|
||||
@@ -6698,7 +6703,7 @@ antiAlias Sharp शार्प
|
||||
@msg date `{d}`: YYYY-MM-DD दिनांक `{d}`: YYYYY-MM-DD
|
||||
@msg decimals must be {_1}–{_2} (got {_3}) decimals: दशमलव होना चाहिए {_1}-{_2} ({_3})
|
||||
@msg decoded data is too large डीकोडेड डेटा बहुत बड़ा है
|
||||
@msg define a variable first पहले एक चर को परिभाषित करना
|
||||
@msg define a variable first पहले एक वेरिएबल परिभाषित करें
|
||||
@msg detect must be true or false detect को true या false होना चाहिए
|
||||
@msg Dimension is locked or hidden आयाम बंद या छिपा हुआ है
|
||||
@msg Dimension mode does not match its source आयाम मोड अपने स्रोत से मेल नहीं खाता है
|
||||
@@ -7018,9 +7023,9 @@ antiAlias Sharp शार्प
|
||||
@msg its Windows metafile preview was opened instead, which may simplify the art इसके बजाय विंडोज मेटाफ़ाइल प्रीव्यू ओपन किया गया था, जो कला को सरल बना सकता है
|
||||
@msg its zlib compression is damaged इसके zlib संपीड़न क्षतिग्रस्त है
|
||||
@msg its Zstandard compression is damaged इसके Zstandard संपीड़न क्षतिग्रस्त है
|
||||
@msg join needs open paths join: खुले रास्ते की जरूरत
|
||||
@msg join two different paths दो अलग-अलग पाथ में शामिल हों
|
||||
@msg Join: a subpath went missing जॉइन: कोई सबपाथ नहीं मिला
|
||||
@msg join needs open paths जोड़ने के लिए खुले पाथ चाहिए
|
||||
@msg join two different paths दो अलग पाथ जोड़ें
|
||||
@msg Join: a subpath went missing जोड़ते समय एक सबपाथ नहीं मिला
|
||||
@msg justify must be auto|left|center|right|justifyAll justify को auto|left|center|right|justifyAll होना चाहिए
|
||||
@msg kbps must be a positive number, not {kbps} kbps एक सकारात्मक संख्या होना चाहिए, नहीं {kbps}
|
||||
@msg kerning must be a number, "auto", or "optical" kerning एक संख्या, "auto", या "optical" होना चाहिए
|
||||
@@ -7047,7 +7052,7 @@ antiAlias Sharp शार्प
|
||||
@msg line {line}: unclosed quote लाइन {line}: unclosed उद्धरण
|
||||
@msg line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes लाइन {line}: `{var}` के लिए मूल्य {MAX_GRAPH_CSV} बाइट्स से अधिक है
|
||||
@msg line {line}: value for `{var}` must be true or false लाइन {line}: `{var}` के लिए मान true या false होना चाहिए
|
||||
@msg line {line}: value for `{var}` must match its variable kind लाइन {line}: `{var}` के लिए मान इसके परिवर्तनीय प्रकार से मेल खाता होना चाहिए
|
||||
@msg line {line}: value for `{var}` must match its variable kind लाइन {line}: `{var}` का मान वेरिएबल के प्रकार से मेल खाना चाहिए
|
||||
@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf या crlf
|
||||
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel लिंक किए गए चित्रों से पता चलता है कि फ़ाइल में सेव किए जाने वाले निम्न-रिज़ॉल्यूशन प्रीव्यू को उनकी फ़ाइलों को मिलने तक: लिंक पैनल में उन्हें फिर से लिंक करें
|
||||
@msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel फ़ाइल में कोई प्रीव्यू के साथ जुड़े चित्र एक प्लेसहोल्डर दिखाते हैं जब तक कि उनकी फ़ाइलें नहीं मिलती: उन्हें लिंक पैनल में फिर से लिंक करें
|
||||
@@ -7162,7 +7167,7 @@ antiAlias Sharp शार्प
|
||||
@msg module is {_1} bytes (limit {_2}) मॉड्यूल {_1} बाइट्स का है (सीमा {_2})
|
||||
@msg more than {MAX_BATCH_FILES} data sets {MAX_BATCH_FILES} डेटा सेट से अधिक
|
||||
@msg more than {MAX_BATCH_FILES} files {MAX_BATCH_FILES} फ़ाइलों से अधिक
|
||||
@msg more than {MAX_CSV_COLS} variables: Import Data Sets reads at most {MAX_CSV_COLS} {MAX_CSV_COLS} से अधिक चर: आयात डेटा सेट अधिकांश {MAX_CSV_COLS} पर पढ़ा जाता है।
|
||||
@msg more than {MAX_CSV_COLS} variables: Import Data Sets reads at most {MAX_CSV_COLS} {MAX_CSV_COLS} से अधिक वेरिएबल्स: डेटा सेट आयात अधिकतम {MAX_CSV_COLS} वेरिएबल्स पढ़ता है
|
||||
@msg more than {MAX_CSV_ROWS} data rows {MAX_CSV_ROWS} डेटा पंक्तियों से अधिक
|
||||
@msg more than {MAX_CSV_ROWS} data sets: Import Data Sets reads at most {MAX_CSV_ROWS} {MAX_CSV_ROWS} डेटा सेट से अधिक: आयात डेटा सेट अधिकांश {MAX_CSV_ROWS} पर पढ़ा जाता है।
|
||||
@msg more than {MAX_FILES} images {MAX_FILES} इमेज से अधिक
|
||||
@@ -7418,10 +7423,10 @@ antiAlias Sharp शार्प
|
||||
@msg no user library folder here यहां कोई उपयोगकर्ता लाइब्रेरी फ़ोल्डर नहीं
|
||||
@msg no user library folder here (save with a path, or without one to get the data) यहाँ कोई यूज़र लाइब्रेरी फ़ोल्डर नहीं है (पाथ के साथ सेव करें, या डेटा पाने के लिए बिना पाथ के)
|
||||
@msg no user workspace `{from}` कोई उपयोगकर्ता वर्कस्पेस `{from}`
|
||||
@msg no variable named `{from}` कोई चर नाम `{from}`
|
||||
@msg no variable named `{name}` कोई चर नाम `{name}`
|
||||
@msg no variable named `{v}` कोई चर नाम `{v}`
|
||||
@msg no variable named `{var}` कोई चर नाम `{var}`
|
||||
@msg no variable named `{from}` `{from}` नाम का कोई वेरिएबल नहीं है
|
||||
@msg no variable named `{name}` `{name}` नाम का कोई वेरिएबल नहीं है
|
||||
@msg no variable named `{v}` `{v}` नाम का कोई वेरिएबल नहीं है
|
||||
@msg no variable named `{var}` `{var}` नाम का कोई वेरिएबल नहीं है
|
||||
@msg no view named `{name}` `{name}` नाम का कोई दृश्य
|
||||
@msg node {_1} is not a graph node {_1} एक graph नहीं है
|
||||
@msg node {_1} is not an image node {_1} एक इमेज नहीं है
|
||||
@@ -7480,7 +7485,7 @@ antiAlias Sharp शार्प
|
||||
@msg not JSON: {e} JSON नहीं है: {e}
|
||||
@msg not type on a path outside perspective दृष्टिकोण के बाहर एक पाथ पर टाइप न करें
|
||||
@msg nothing encoded कुछ भी नहीं
|
||||
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for डेटा सेट {name} में कुछ भी लागू नहीं किया गया था: कोई ऑब्जेक्ट एक चर के लिए बाध्य नहीं है, इसके लिए एक मूल्य है।
|
||||
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for डेटा सेट {name} से कुछ भी लागू नहीं हुआ: उसमें मान वाले किसी भी वेरिएबल से कोई ऑब्जेक्ट नहीं बाँधा गया है
|
||||
@msg nothing prints कुछ भी प्रिंट
|
||||
@msg nothing selected कुछ भी नहीं चुना
|
||||
@msg nothing selected is in a group कुछ भी नहीं चुना गया है एक समूह में
|
||||
@@ -7531,7 +7536,7 @@ antiAlias Sharp शार्प
|
||||
@msg warped text objects that came in as drawn art, not as editable type: {_1} मोड़े गए टेक्स्ट ऑब्जेक्ट जो संपादन योग्य टेक्स्ट के बजाय कला के रूप में आए: {_1}
|
||||
@msg objects that drew differently from the file's own page came in as its art: {_1} उन ऑब्जेक्ट्स को जो फ़ाइल के स्वयं के पृष्ठ से अलग-अलग तरीके से आकर्षित होते हैं, उनकी कला के रूप में आया: {_1}
|
||||
@msg objects were left out ऑब्जेक्ट्स को छोड़ दिया गया था
|
||||
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet कई भराव या स्ट्रोक, परिवर्तनीय चौड़ाई वाले स्ट्रोक, इफ़ेक्ट या ब्रश के साथ ऑब्जेक्टएं उनके ड्रॉइंग लुक ({_1}) के रूप में आईं: उनकी उपस्थिति अभी तक संपादन योग्य नहीं है
|
||||
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet कई भराव या स्ट्रोक, वेरिएबल चौड़ाई वाले स्ट्रोक, इफ़ेक्ट या ब्रश के साथ ऑब्जेक्टएं उनके ड्रॉइंग लुक ({_1}) के रूप में आईं: उनकी उपस्थिति अभी तक संपादन योग्य नहीं है
|
||||
@msg offset out of range सीमा से बाहर ऑफसेट
|
||||
@msg Offset Path: {e} ऑफसेट पाथ: {e}
|
||||
@msg Offset Path: select paths ऑफसेट पाथ: सिलेक्शन पाथ
|
||||
@@ -7688,7 +7693,7 @@ antiAlias Sharp शार्प
|
||||
@msg redraw needs id redraw: ई.डी. की आवश्यकता
|
||||
@msg redraw within must be a finite distance of 0 or more भीतर वापस लेना 0 या उससे अधिक की एक सीमित दूरी होना चाहिए
|
||||
@msg registration must be 0..8 registration को 0..8 होना चाहिए
|
||||
@msg Rejoin Path Pieces: too many paths to rejoin at once Rejoin पाथ के टुकड़े: एक बार में फिर से जुड़ने के लिए कई पाथ
|
||||
@msg Rejoin Path Pieces: too many paths to rejoin at once पाथ के हिस्से फिर से जोड़ें: एक बार में फिर से जोड़ने के लिए बहुत अधिक पाथ हैं
|
||||
@msg release a layer or a group एक लेयर या एक समूह को छोड़ दें
|
||||
@msg Release Guides: no guides रिलीज़ गाइड: कोई गाइड नहीं
|
||||
@msg Release: no layer to put the rectangle in रिलीज: आयत को डालने के लिए कोई लेयर नहीं
|
||||
@@ -7755,7 +7760,7 @@ antiAlias Sharp शार्प
|
||||
@msg select a path एक पाथ चुनें
|
||||
@msg select a repeat दोहराने सिलेक्ट करें
|
||||
@msg select a Shaper group एक शेपर समूह चुनें
|
||||
@msg select a stroke with a variable width एक चर चौड़ाई के साथ एक स्ट्रोक सिलेक्ट करें
|
||||
@msg select a stroke with a variable width वेरिएबल चौड़ाई वाला स्ट्रोक सिलेक्ट करें
|
||||
@msg select a symbol instance एक सिंबल उदाहरण चुनें
|
||||
@msg select a text object एक टेक्स्ट ऑब्जेक्ट चुनें
|
||||
@msg select an envelope एक लिफाफे सिलेक्ट करें
|
||||
@@ -7800,7 +7805,7 @@ antiAlias Sharp शार्प
|
||||
@msg select at least two objects कम से कम दो ऑब्जेक्ट चुनें
|
||||
@msg Select dimensions to update अद्यतन करने के लिए आयाम सिलेक्ट करें
|
||||
@msg select exactly two objects वास्तव में दो ऑब्जेक्ट चुनें
|
||||
@msg select exactly two path ends to join वास्तव में दो पाथ में शामिल होने के लिए समाप्त होता है चुनें
|
||||
@msg select exactly two path ends to join जोड़ने के लिए ठीक दो पाथ एंड पॉइंट्स सिलेक्ट करें
|
||||
@msg Select horizontal point or area type whose text fits ऐसा क्षैतिज पॉइंट टाइप या एरिया टाइप सिलेक्ट करें जिसका टेक्स्ट फ़िट होता है
|
||||
@msg select instances of one symbol एक सिंबल के उदाहरण चुनें
|
||||
@msg select members of a compound shape एक मिश्रित आकार के सदस्यों सिलेक्ट करें
|
||||
@@ -7817,7 +7822,7 @@ antiAlias Sharp शार्प
|
||||
@msg select paths पाथ चुनें
|
||||
@msg select paths or anchor points पाथ या एंकर अंक चुनें
|
||||
@msg select paths or compound paths पाथ या यौगिक पाथ सिलेक्ट करें
|
||||
@msg select paths to join शामिल होने के लिए पाथ सिलेक्ट करें
|
||||
@msg select paths to join जोड़ने के लिए पाथ सिलेक्ट करें
|
||||
@msg select paths with dashed strokes to expand विस्तृत करने के लिए डैश स्ट्रोक के साथ पाथ सिलेक्ट करें
|
||||
@msg select paths, or segments, to divide विभाजित करने के लिए पाथ, या खंड चुनें
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert बदलने के लिए सादे आयत, अंडाकार या नियमित बहुभुजों सिलेक्ट करें
|
||||
@@ -7846,7 +7851,7 @@ antiAlias Sharp शार्प
|
||||
@msg select the objects to attach to the path ऑब्जेक्ट्स का सिलेक्ट करने के लिए संलग्न करने के लिए पाथ
|
||||
@msg select the objects to bind ऑब्जेक्ट्स को बांधने के लिए चुनें
|
||||
@msg select the objects to transform in perspective ऑब्जेक्ट्स को पर्सपेक्टिव में बदलने के लिए चुनें
|
||||
@msg select two open path ends, or two open paths, to average and join दो खुले पाथ सिलेक्ट करें, या दो खुले रास्ते, औसतन और जुड़ना
|
||||
@msg select two open path ends, or two open paths, to average and join औसत निकालकर जोड़ने के लिए दो खुले पाथ एंड पॉइंट्स या दो खुले पाथ सिलेक्ट करें
|
||||
@msg select two or more graphic styles to merge दो या अधिक ग्राफिक स्टाइल्स का सिलेक्ट करने के लिए विलय
|
||||
@msg select two or more objects to distribute वितरित करने के लिए दो या दो से अधिक ऑब्जेक्ट चुनें
|
||||
@msg select two or more objects to intertwine दो या दो से अधिक ऑब्जेक्ट्स सिलेक्ट करें
|
||||
@@ -7903,7 +7908,7 @@ antiAlias Sharp शार्प
|
||||
@msg strokeOptions must be an object of stroke.set options strokeOptions को stroke.set ऑप्शन्स का ऑब्जेक्ट होना चाहिए
|
||||
@msg strokeWidth must be a positive number of pixels strokeWidth को पिक्सेल की धनात्मक संख्या होना चाहिए
|
||||
@msg subpath {si} is not an open subpath उपपाथ {si} एक खुला उपपाथ नहीं है
|
||||
@msg subpaths required when joining the same path एक ही पाथ में शामिल होने पर आवश्यक उप-पाथ
|
||||
@msg subpaths required when joining the same path एक ही पाथ को जोड़ने के लिए सबपाथ चाहिए
|
||||
@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG UTF-8, UTF-16 या ISO 8859-1 नहीं है।
|
||||
@msg SVG parse error: {0} SVG पार्स त्रुटि: {0}
|
||||
@msg swatchConflict must be "merge" or "add", not {v} swatchConflict को "merge" या "add" होना चाहिए, {v} नहीं
|
||||
@@ -8058,9 +8063,9 @@ antiAlias Sharp शार्प
|
||||
@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer फ़ाइल की लेयरें नहीं पढ़ी गईं ({_1}): यह अपने पृष्ठ के रूप में आता है, एक लेयर में
|
||||
@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part फ़ाइल की लेयर्स को इसके संपादन डेटा ({_1}) से नहीं पढ़ा गया था: वे अपने PDF भाग से आते हैं
|
||||
@msg the file's preview header points outside the file फ़ाइल के प्रीव्यू शीर्षलेख फ़ाइल के बाहर अंक
|
||||
@msg the file's variable bindings weren't all read (bindings skipped: {_1}): those objects aren't bound to their variables फ़ाइल के परिवर्तनीय बंधन सभी नहीं पढ़े गए थे (बाइंडिंग्स स्किप: {_1}): वे ऑब्जेक्ट अपने चर के लिए बाध्य नहीं हैं
|
||||
@msg the file's variable library couldn't be read: the Variables panel doesn't list it फ़ाइल के परिवर्तनीय लाइब्रेरी को पढ़ा नहीं जा सकता: चर पैनल इसे सूचीबद्ध नहीं करता है
|
||||
@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest फ़ाइल की परिवर्तनीय लाइब्रेरी आंशिक रूप से पढ़ी गई थी (प्रवेशियों को छोड़ दिया: {_1}): वेरिएबल्स पैनल बाकी हिस्सों को सूचीबद्ध करता है
|
||||
@msg the file's variable bindings weren't all read (bindings skipped: {_1}): those objects aren't bound to their variables फ़ाइल की कुछ वेरिएबल बाइंडिंग नहीं पढ़ी गईं (छोड़ी गई बाइंडिंग: {_1}): वे ऑब्जेक्ट अपने वेरिएबल्स से नहीं बँधे हैं
|
||||
@msg the file's variable library couldn't be read: the Variables panel doesn't list it फ़ाइल की वेरिएबल लाइब्रेरी पढ़ी नहीं जा सकी: वेरिएबल्स पैनल उसे सूचीबद्ध नहीं करता
|
||||
@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest फ़ाइल की वेरिएबल लाइब्रेरी आंशिक रूप से पढ़ी गई (छोड़ी गई प्रविष्टियाँ: {_1}): वेरिएबल्स पैनल शेष प्रविष्टियाँ दिखाता है
|
||||
@msg the files come to more than 1 GB: add fewer at a time फ़ाइलों को 1 GB से अधिक करने के लिए आते हैं: एक समय में कम जोड़ें
|
||||
@msg the first character of a line has no character before it to kern: it moves only up and down (dy) एक पंक्ति के पहले चरित्र में बर्न से पहले कोई चरित्र नहीं है: यह केवल ऊपर और नीचे चला जाता है (डी)
|
||||
@msg the font {_1} is not available फ़ॉन्ट {_1} उपलब्ध नहीं है
|
||||
@@ -8073,7 +8078,7 @@ antiAlias Sharp शार्प
|
||||
@msg The graphics were lost and couldn't be restarted. ग्राफिक्स खो गए थे और फिर से शुरू नहीं किया जा सकता था।
|
||||
@msg The graphics were lost and have been restarted ग्राफिक्स खो गए थे और फिर से शुरू किया गया है
|
||||
@msg the guide is on a hidden or locked layer गाइड एक छिपे हुए या बंद लेयर पर है
|
||||
@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them हेडर `{cell}` के लिए `{name}` वापस चर `{other}` के रूप में पढ़ेगा: उनमें से एक का नाम बदलें
|
||||
@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them हेडर `{cell}` के लिए `{name}` वापस वेरिएबल `{other}` के रूप में पढ़ेगा: उनमें से एक का नाम बदलें
|
||||
@msg the header `{header}` for `{name}` would read back as the variable `{other}`: rename one of them `{name}` का हेडर `{header}` दोबारा पढ़ने पर वेरिएबल `{other}` बन जाएगा: किसी एक का नाम बदलें
|
||||
@msg the image `{path}` isn't followed: {r} इमेज `{path}` का पालन नहीं किया गया है: {r}
|
||||
@msg the image can't be read as a texture इमेज को बनावट के रूप में नहीं पढ़ा जा सकता है
|
||||
@@ -8238,14 +8243,14 @@ antiAlias Sharp शार्प
|
||||
@msg the trailer has no size ट्रेलर में कोई आकार नहीं है
|
||||
@msg the type object is flattened to nothing फ़्लैटन करने पर टाइप ऑब्जेक्ट में कुछ नहीं रहता
|
||||
@msg the value of axis `{tag}` must be a number or null धुरी का मूल्य `{tag}` होना चाहिए एक नंबर या null होना चाहिए
|
||||
@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets परिवर्तनीय लाइब्रेरी {MAX_LIBRARY_DATASETS} डेटा सेट से अधिक घोषित करता है
|
||||
@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets वेरिएबल लाइब्रेरी में {MAX_LIBRARY_DATASETS} से अधिक डेटा सेट घोषित हैं
|
||||
@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities वेरिएबल लाइब्रेरी {MAX_LIBRARY_ENTITIES} संस्थाओं से अधिक की घोषणा करती है।
|
||||
@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables परिवर्तनीय लाइब्रेरी {MAX_LIBRARY_VARIABLES} चर से अधिक घोषित करता है।
|
||||
@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables वेरिएबल लाइब्रेरी {MAX_LIBRARY_VARIABLES} से अधिक वेरिएबल्स घोषित करती है
|
||||
@msg the variable library declares no variables वेरिएबल लाइब्रेरी ने कोई वेरिएबल घोषित नहीं किया है
|
||||
@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes परिवर्तनीय लाइब्रेरी {MAX_LIBRARY_BYTES} बाइट्स से बड़ा है
|
||||
@msg the variable library is not UTF-8 चर लाइब्रेरी UTF-8 नहीं है
|
||||
@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes वेरिएबल लाइब्रेरी {MAX_LIBRARY_BYTES} बाइट से बड़ी है
|
||||
@msg the variable library is not UTF-8 वेरिएबल लाइब्रेरी UTF-8 में नहीं है
|
||||
@msg the variable library is not well-formed XML: {e} वेरिएबल लाइब्रेरी अच्छी तरह से निर्मित XML नहीं है: {e}
|
||||
@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep परिवर्तनीय लाइब्रेरी घोंसला तत्वों से अधिक {MAX_LIBRARY_NESTING} गहरा
|
||||
@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep वेरिएबल लाइब्रेरी में एलिमेंट {MAX_LIBRARY_NESTING} स्तर से अधिक गहराई तक नेस्ट किए गए हैं
|
||||
@msg the variable library's document type declaration holds something other than entities of plain text वेरिएबल लाइब्रेरी के डॉक्यूमेंट प्रकार की घोषणा सादे टेक्स्ट की संस्थाओं के अलावा कुछ अन्य रखती है
|
||||
@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes वेरिएबल लाइब्रेरी की संस्थाएं {MAX_LIBRARY_BYTES} बाइट्स से अधिक विस्तार करती हैं
|
||||
@msg the window has no dock विंडो में कोई डॉक नहीं है
|
||||
@@ -8464,14 +8469,14 @@ antiAlias Sharp शार्प
|
||||
@msg unsupported: {0} unsupported: {0}
|
||||
@msg use paths or compound paths पाथ या यौगिक पाथ का उपयोग करें
|
||||
@msg useArtboards must be true or false, not {v} useArtboards को true या false होना चाहिए, {v} नहीं
|
||||
@msg value for `{var}` must match its variable kind `{var}` के लिए मूल्य अपने परिवर्तनीय प्रकार से मेल खाता होना चाहिए
|
||||
@msg variable `{_1}` is already defined चर `{_1}` पहले से ही परिभाषित किया गया है
|
||||
@msg value for `{var}` must match its variable kind `{var}` का मान वेरिएबल के प्रकार से मेल खाना चाहिए
|
||||
@msg variable `{_1}` is already defined वेरिएबल `{_1}` पहले से परिभाषित है
|
||||
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` वेरिएबल `{name}` वेरिएबल लाइब्रेरी में सेव किया नहीं जा सकता: नाम अक्षर या `_` से शुरू करें, स्पेस और `-`, `_`, `.` के अलावा अन्य चिह्न न रखें
|
||||
@msg variable `{name}` has no `trait` चर `{name}` में कोई `trait` नहीं है
|
||||
@msg variable `{name}` is already defined चर `{name}` को पहले से ही परिभाषित किया गया है
|
||||
@msg variable `{name}` is declared twice परिवर्तनीय `{name}` को दो बार घोषित किया गया है
|
||||
@msg variable `{to}` already exists चर `{to}` पहले से मौजूद
|
||||
@msg variables are locked चर लॉक कर रहे हैं
|
||||
@msg variable `{name}` has no `trait` वेरिएबल `{name}` में `trait` नहीं है
|
||||
@msg variable `{name}` is already defined वेरिएबल `{name}` पहले से परिभाषित है
|
||||
@msg variable `{name}` is declared twice वेरिएबल `{name}` दो बार घोषित है
|
||||
@msg variable `{to}` already exists वेरिएबल `{to}` पहले से मौजूद है
|
||||
@msg variables are locked वेरिएबल्स लॉक हैं
|
||||
@msg variables that aren't text were left out: {kinds} वेरिएबल जो टेक्स्ट नहीं छोड़े गए थे: {kinds}
|
||||
@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version ने {v} लौटाया; यह होस्ट संस्करण {ABI_VERSION} लागू करता है
|
||||
@msg vc_alloc returned 0 (out of memory) vc_alloc ने 0 लौटाया (मेमोरी खत्म)
|
||||
@@ -8641,7 +8646,7 @@ file-filter Swatch libraries स्वॉच लाइब्रेरीज़
|
||||
file-filter Templates टेम्पलेट्स
|
||||
file-filter VectorCraft Template वेक्टर क्राफ्ट टेम्पलेट
|
||||
font-script Japanese जापानी
|
||||
font-technology Variable चर
|
||||
font-technology Variable वेरिएबल
|
||||
gradient-kind Radial रेडियल
|
||||
imageTrace Outline रूपरेखा
|
||||
lighting Light लाइट
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -820,6 +820,8 @@ font-technology Variable Változó
|
||||
Convert to destination (preserve numbers) Átalakítás a célprofilra (az értékek megőrzésével)
|
||||
Convert to Global Átalakítás globálissá
|
||||
Convert to Grayscale Átalakítás szürkeárnyalatossá
|
||||
Convert to Indexed Color Átalakítás indexelt színűvé
|
||||
Convert to Indexed Color… Átalakítás indexelt színűvé…
|
||||
Convert To Outlines Átalakítás útvonallá
|
||||
Convert To Point Type Átalakítás pontszöveggé
|
||||
Convert to RGB Átalakítás RGB-re
|
||||
@@ -5149,6 +5151,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}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -96,6 +96,8 @@
|
||||
Blend Vertically Blend vertikal
|
||||
Convert to CMYK Konversi ke CMYK
|
||||
Convert to Grayscale Konversi ke skala kelabu
|
||||
Convert to Indexed Color Konversi ke warna terindeks
|
||||
Convert to Indexed Color… Konversi ke warna terindeks…
|
||||
Convert to RGB Konversi ke RGB
|
||||
Invert Colors Balikkan warna
|
||||
Overprint Black… Overprint hitam…
|
||||
@@ -4246,6 +4248,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2424 and column 5.
|
@@ -629,6 +629,8 @@ font-technology Variable Variabili
|
||||
Convert to Artboards Converti in tavole da disegno
|
||||
Convert to CMYK Converti in CMYK
|
||||
Convert to Grayscale Converti in scala di grigi
|
||||
Convert to Indexed Color Converti in colore indicizzato
|
||||
Convert to Indexed Color… Converti in colore indicizzato…
|
||||
Convert To Outlines Converti in contorni
|
||||
Convert To Point Type Converti in testo per punti
|
||||
Convert to RGB Converti in RGB
|
||||
@@ -4393,6 +4395,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -182,6 +182,8 @@ digits Malayalam マラヤーラム数字
|
||||
Blend Vertically 上下にブレンド
|
||||
Convert to CMYK CMYK に変換
|
||||
Convert to Grayscale グレースケールに変換
|
||||
Convert to Indexed Color インデックスカラーに変換
|
||||
Convert to Indexed Color… インデックスカラーに変換…
|
||||
Convert to RGB RGB に変換
|
||||
Invert Colors カラー反転
|
||||
Overprint Black… オーバープリントブラック…
|
||||
@@ -5182,6 +5184,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 つ以上指定してください
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -107,6 +107,8 @@
|
||||
Blend Vertically ვერტიკალურად შერევა
|
||||
Convert to CMYK CMYK-ად გარდაქმნა
|
||||
Convert to Grayscale ნაცრისფერ ტონებად გარდაქმნა
|
||||
Convert to Indexed Color ინდექსირებულ ფერებად გარდაქმნა
|
||||
Convert to Indexed Color… ინდექსირებულ ფერებად გარდაქმნა…
|
||||
Convert to RGB RGB-ად გარდაქმნა
|
||||
Invert Colors ფერების ინვერსია
|
||||
Overprint Black… შავის ზედდაბეჭდვა…
|
||||
@@ -4045,6 +4047,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 კონტურების შექმნა: მონიშნეთ ტექსტური ობიექტები
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -175,6 +175,8 @@ digits Malayalam 말라얄람어
|
||||
Blend Vertically 세로 혼합
|
||||
Convert to CMYK CMYK로 변환
|
||||
Convert to Grayscale 회색조로 변환
|
||||
Convert to Indexed Color 인덱스 색상으로 변환
|
||||
Convert to Indexed Color… 인덱스 색상으로 변환…
|
||||
Convert to RGB RGB로 변환
|
||||
Invert Colors 색상 반전
|
||||
Overprint Black… 검정 겹쳐찍기…
|
||||
@@ -5109,6 +5111,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: 파일을 하나 이상 지정하세요
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -598,6 +598,8 @@
|
||||
Blend Vertically Verticaal mengen
|
||||
Convert to CMYK Converteren naar CMYK
|
||||
Convert to Grayscale Converteren naar grijswaarden
|
||||
Convert to Indexed Color Converteren naar geïndexeerde kleuren
|
||||
Convert to Indexed Color… Converteren naar geïndexeerde kleuren…
|
||||
Convert to RGB Converteren naar RGB
|
||||
Invert Colors Kleuren omkeren
|
||||
Overprint Black… Zwart overprinten…
|
||||
@@ -4762,6 +4764,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -111,6 +111,8 @@
|
||||
Blend Vertically Przejście w pionie
|
||||
Convert to CMYK Konwertuj na CMYK
|
||||
Convert to Grayscale Konwertuj na skalę szarości
|
||||
Convert to Indexed Color Konwertuj na kolory indeksowane
|
||||
Convert to Indexed Color… Konwertuj na kolory indeksowane…
|
||||
Convert to RGB Konwertuj na RGB
|
||||
Invert Colors Odwróć kolory
|
||||
Overprint Black… Nadruk czerni…
|
||||
@@ -4475,6 +4477,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -573,6 +573,8 @@ antiAlias Sharp Nítido
|
||||
Convert to Artboards Converter em Mesas de Trabalho
|
||||
Convert to CMYK Converter para CMYK
|
||||
Convert to Grayscale Converter para Escala de Cinza
|
||||
Convert to Indexed Color Converter para Cores Indexadas
|
||||
Convert to Indexed Color… Converter para Cores Indexadas…
|
||||
Convert to RGB Converter para RGB
|
||||
Convert to Shape Converter em Forma
|
||||
Convert to sRGB Converter para sRGB
|
||||
@@ -3810,6 +3812,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -627,6 +627,8 @@ font-technology Variable Вариативные
|
||||
Convert to Artboards Преобразовать в монтажные области
|
||||
Convert to CMYK Преобразовать в CMYK
|
||||
Convert to Grayscale Преобразовать в оттенки серого
|
||||
Convert to Indexed Color Преобразовать в индексированные цвета
|
||||
Convert to Indexed Color… Преобразовать в индексированные цвета…
|
||||
Convert To Outlines Преобразовать в контуры
|
||||
Convert To Point Type Преобразовать в точечный текст
|
||||
Convert to RGB Преобразовать в RGB
|
||||
@@ -4378,6 +4380,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 Создание контуров: выделите текстовые объекты
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -616,6 +616,8 @@ font-technology Variable Variabla
|
||||
Convert to Artboards Konvertera till ritytor
|
||||
Convert to CMYK Konvertera till CMYK
|
||||
Convert to Grayscale Konvertera till gråskala
|
||||
Convert to Indexed Color Konvertera till indexerade färger
|
||||
Convert to Indexed Color… Konvertera till indexerade färger…
|
||||
Convert To Outlines Konvertera till konturbanor
|
||||
Convert To Point Type Konvertera till punkttext
|
||||
Convert to RGB Konvertera till RGB
|
||||
@@ -4251,6 +4253,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
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -63,6 +63,36 @@ fn hindi_rows_use_the_glossary_term_for_what_their_english_names() {
|
||||
assert!(wrong.is_empty(), "{} Hindi rows use the wrong term:\n{}", wrong.len(), wrong.join("\n"));
|
||||
}
|
||||
|
||||
/// Every Hindi row that names a variable says वेरिएबल, never चर, परिवर्तनीय or वैरिएबल, and joining
|
||||
/// paths, points, type or lines is जोड़ें, never शामिल हों, which stays for joining the community.
|
||||
#[test]
|
||||
fn hindi_rows_use_one_term_for_a_variable_and_for_joining_paths() {
|
||||
let hi = Lang::from_code("hi").expect("hi registered");
|
||||
let (entries, _) = parse_entries(hi.0.source);
|
||||
let mut wrong = Vec::new();
|
||||
for (_, src, translation) in &entries {
|
||||
let english = src.to_lowercase();
|
||||
let words: Vec<&str> = english.split(|c: char| !c.is_ascii_alphabetic()).collect();
|
||||
let hindi: Vec<&str> = translation.split(|c: char| c.is_whitespace() || c.is_ascii_punctuation() || "।…".contains(c)).collect();
|
||||
let names = |list: &[&str]| words.iter().any(|w| list.contains(w));
|
||||
let other_variable = hindi.iter().any(|w| ["चर", "चरों", "परिवर्तनीय"].contains(w)) || translation.contains("वैरिएबल");
|
||||
if names(&["variable", "variables"]) && (other_variable || !translation.contains("वेरिएबल")) {
|
||||
wrong.push(format!("variable in {src:?} -> {translation:?}"));
|
||||
}
|
||||
if names(&["join", "joining"]) && !names(&["discord"]) && translation.contains("शामिल") {
|
||||
wrong.push(format!("join in {src:?} -> {translation:?}"));
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{} Hindi rows use the wrong term:\n{}", wrong.len(), wrong.join("\n"));
|
||||
assert_eq!(tr(hi, "Variable"), "वेरिएबल");
|
||||
assert_eq!(tr(hi, "Load Variable Library…"), "वेरिएबल लाइब्रेरी लोड करें…");
|
||||
assert_eq!(tr(hi, "Join"), "जोड़ें");
|
||||
assert_eq!(tr(hi, "Close or Join"), "बंद करें या जोड़ें");
|
||||
assert_eq!(message(hi, "join needs open paths"), "जोड़ने के लिए खुले पाथ चाहिए");
|
||||
assert_eq!(message(hi, "Join: a subpath went missing"), "जोड़ते समय एक सबपाथ नहीं मिला");
|
||||
assert_eq!(tr(hi, "Join our Discord"), "हमारे डिसकॉर्ड में शामिल हों");
|
||||
}
|
||||
|
||||
/// Hindi messages and labels keep the condition, quantity and clauses of their English.
|
||||
#[test]
|
||||
fn hindi_rows_keep_the_meaning_of_their_english() {
|
||||
|
||||
@@ -608,6 +608,8 @@ font-technology Variable Değişken
|
||||
Convert to Artboards Çalışma Yüzeylerine Dönüştür
|
||||
Convert to CMYK CMYK'ye dönüştür
|
||||
Convert to Grayscale Gri Tonlamaya Dönüştür
|
||||
Convert to Indexed Color Dizinlenmiş Renge Dönüştür
|
||||
Convert to Indexed Color… Dizinlenmiş Renge Dönüştür…
|
||||
Convert To Outlines Anahatlara Dönüştür
|
||||
Convert To Point Type Nokta Türüne Dönüştür
|
||||
Convert to RGB RGB'ye dönüştür
|
||||
@@ -4129,6 +4131,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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -623,6 +623,8 @@ font-technology Variable Варіативні
|
||||
Convert to Artboards Перетворити на монтажні області
|
||||
Convert to CMYK Перетворити на CMYK
|
||||
Convert to Grayscale Перетворити на відтінки сірого
|
||||
Convert to Indexed Color Перетворити на індексовані кольори
|
||||
Convert to Indexed Color… Перетворити на індексовані кольори…
|
||||
Convert To Outlines Перетворити на контури
|
||||
Convert To Point Type Перетворити на точковий текст
|
||||
Convert to RGB Перетворити на RGB
|
||||
@@ -4377,6 +4379,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 Створення контурів: виділіть текстові об’єкти
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -721,6 +721,8 @@ font-technology Variable Biến thiên
|
||||
Convert to Artboards Chuyển thành bảng vẽ
|
||||
Convert to CMYK Chuyển sang CMYK
|
||||
Convert to Grayscale Chuyển sang thang xám
|
||||
Convert to Indexed Color Chuyển sang màu chỉ mục
|
||||
Convert to Indexed Color… Chuyển sang màu chỉ mục…
|
||||
Convert To Outlines Chuyển thành đường bao
|
||||
Convert To Point Type Chuyển sang chữ điểm
|
||||
Convert to RGB Chuyển sang RGB
|
||||
@@ -4805,6 +4807,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}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -575,6 +575,8 @@ antiAlias Sharp 锐利
|
||||
Convert to Artboards 转换为画板
|
||||
Convert to CMYK 转换为 CMYK
|
||||
Convert to Grayscale 转换为灰度
|
||||
Convert to Indexed Color 转换为索引颜色
|
||||
Convert to Indexed Color… 转换为索引颜色…
|
||||
Convert To Outlines 转换为轮廓
|
||||
Convert To Point Type 转换为点文字
|
||||
Convert to RGB 转换为 RGB
|
||||
@@ -4865,6 +4867,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:请至少给出一个文件
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -591,6 +591,8 @@ font-technology Variable 可變
|
||||
Convert to Artboards 轉換為工作區域
|
||||
Convert to CMYK 轉換為 CMYK
|
||||
Convert to Grayscale 轉換為灰階
|
||||
Convert to Indexed Color 轉換為索引色彩
|
||||
Convert to Indexed Color… 轉換為索引色彩…
|
||||
Convert To Outlines 轉換為外框
|
||||
Convert To Point Type 轉換為點狀文字
|
||||
Convert to RGB 轉換為 RGB
|
||||
@@ -2614,6 +2616,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:請提供至少一個檔案
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -814,6 +814,10 @@ fn run_ui_part8(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
||||
crate::dialogs::saturate::open(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.indexedColorDialog" => {
|
||||
crate::dialogs::indexed_color::open(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"window.swatchLibrary" => crate::panels::swatches::open_library(app, p),
|
||||
"window.swatchLibrary.other" => crate::panels::swatches::other_library(app, s("path")),
|
||||
"ui.saveSwatchLibrary" => {
|
||||
|
||||
@@ -286,7 +286,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||
"ui.newBrush" => app.session.active().is_some(),
|
||||
"ui.brushOptions" => crate::dialogs::brush_options::available(app),
|
||||
"ui.hyphenation" | "ui.justification" | "ui.tateChuYokoSettings" => crate::panels::character::text_style(app).is_some(),
|
||||
"ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"ui.colorBalanceDialog" | "ui.saturateDialog" | "ui.indexedColorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"ui.saveSwatchLibrary" => app.session.active().is_some(),
|
||||
id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(),
|
||||
"ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
|
||||
@@ -121,6 +121,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
c("Blend Vertically", "edit.colors.blendVertically"),
|
||||
c("Convert to CMYK", "edit.colors.toCMYK"),
|
||||
c("Convert to Grayscale", "edit.colors.toGrayscale"),
|
||||
c("Convert to Indexed Color…", "ui.indexedColorDialog"),
|
||||
c("Convert to RGB", "edit.colors.toRGB"),
|
||||
c("Invert Colors", "edit.colors.invert"),
|
||||
cp(
|
||||
|
||||
@@ -946,6 +946,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step",
|
||||
),
|
||||
(
|
||||
"ui.indexedColorDialog",
|
||||
"Convert to Indexed Color…",
|
||||
"",
|
||||
"{} open Convert to Indexed Color for the selection (dialog `indexedColor`, fields `reduction`, `colors` 2..256, `dither`, `ditherAmount` 0..100, `preview`): previews live, OK runs edit.colors.toIndexed as one undo step",
|
||||
),
|
||||
(
|
||||
"window.swatchLibrary",
|
||||
"Swatch Library",
|
||||
|
||||
@@ -33,6 +33,9 @@ pub type PlaceInbox = Arc<Mutex<Vec<PlaceArrival>>>;
|
||||
#[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<String>, Vec<u8>);
|
||||
/// 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 {
|
||||
|
||||
@@ -111,6 +111,9 @@ struct Chain {
|
||||
left: Option<Vec<vectorcraft_doc::NodeId>>,
|
||||
/// 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<vectorcraft_doc::NodeId>,
|
||||
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<u64>,
|
||||
read: impl FnOnce() -> Result<Read, String> + Send + 'static,
|
||||
) -> Result<Option<Value>, 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<Read, String>) {
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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<String> = (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]
|
||||
|
||||
@@ -545,6 +545,30 @@ fn ui_inspect_checks_a_folder_preference_as_written() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect` names the file selected in JavaScript Files by its file name alone when it is
|
||||
/// outside the read root, and keeps one inside it; the person sees the whole path.
|
||||
#[test]
|
||||
fn ui_inspect_hides_a_selected_javascript_file_outside_the_read_root() {
|
||||
let (mut app, tx, base) = confined("inspect-javascript-files");
|
||||
let (inside, outside) = (s(base.join("in/menu.js")), s(base.join("outside/menu.js")));
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
for f in [&inside, &outside] {
|
||||
app.run("svgInteractivity.addFile", json!({ "file": f })).unwrap();
|
||||
}
|
||||
let kind = crate::dialogs::javascript_files::KIND;
|
||||
app.ui.dialog = Some(Dialog::new(kind, json!({"file": "", "selected": outside})));
|
||||
let selected = |v: &Value| v["ui"]["dialog"]["fields"]["selected"].clone();
|
||||
assert_eq!(selected(&crate::control::inspect(&app, &egui::Context::default())), json!(outside), "the person's");
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(selected(&r["result"]), "menu.js", "{r}");
|
||||
assert!(!r.to_string().contains(&json!(outside).to_string().trim_matches('"').to_string()), "{r}");
|
||||
app.ui.dialog = Some(Dialog::new(kind, json!({"file": "", "selected": inside})));
|
||||
let r = request(&mut app, &tx, "ui.inspect", json!({}));
|
||||
assert_eq!(selected(&r["result"]), json!(inside), "{r}");
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("selected")), Some(inside), "the dialog is not changed");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.inspect` checks a folder preference as the app uses it, trimmed, as well: with the working
|
||||
/// folder as the read root, a path outside it with a space before it reads as a name in the working
|
||||
/// folder, and is still left out, because the app trims it and uses the path outside.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`.
|
||||
## Staircases in smooth areas
|
||||
Where a band border in a gradient or a shaded area runs at a slant, its pixels form a staircase whose
|
||||
steps are two or more pixels long. One piece of outline can run along a sharp edge in the image and then
|
||||
|
||||
+11
-1
@@ -32,6 +32,14 @@ every clause such as "all" or "what follows it". Type as a text object is टा
|
||||
in `crates/ui-egui/src/i18n/tests.rs` checks the save, swatch, crop, resolution and path terms
|
||||
and the point unit in every row.
|
||||
|
||||
A variable is वेरिएबल (plural वेरिएबल्स) in every sense: a data-merge variable and the
|
||||
variable library, a variable font, a variable stroke width and an environment variable. It is
|
||||
never चर, परिवर्तनीय or वैरिएबल. Joining paths, anchor points, point type or the lines of a
|
||||
paragraph is जोड़ें (जोड़ने के लिए in a purpose clause); शामिल हों is kept for joining the
|
||||
community. The stroke corner names (Bevel Join, Miter Join, Round Join) and the Join tool keep
|
||||
जॉइन. `hindi_rows_use_one_term_for_a_variable_and_for_joining_paths` checks both terms in every
|
||||
row.
|
||||
|
||||
## Terminology
|
||||
|
||||
| English | Hindi |
|
||||
@@ -52,6 +60,8 @@ and the point unit in every row.
|
||||
| Type (text object) / Crop / Resolution | टाइप / क्रॉप / रिज़ॉल्यूशन |
|
||||
| Appearance / Graphic style | अपीयरेंस / ग्राफ़िक स्टाइल |
|
||||
| Symbol / Envelope | सिंबल / एन्वेलप |
|
||||
| Variable / Variables / Variable library | वेरिएबल / वेरिएबल्स / वेरिएबल लाइब्रेरी |
|
||||
| Join (paths, points, type, lines) | जोड़ें |
|
||||
| Clipping mask / Opacity mask | क्लिपिंग मास्क / ओपेसिटी मास्क |
|
||||
| Image Trace / Live Paint | इमेज ट्रेस / लाइव पेंट |
|
||||
| Shape Builder / Pathfinder | शेप बिल्डर / पाथफ़ाइंडर |
|
||||
@@ -69,7 +79,7 @@ The draft has 7,877 unique rows, including 2,537 `@msg`, 45 `@msgplural` and 38
|
||||
Its source set combines the sibling catalogs with the current test scanner's UI/undo and error
|
||||
message dumps, so it includes runtime labels outside the literal scanner. Row count is not a
|
||||
percentage of the application's entire interface.
|
||||
On 2026-11-13 the catalog has 8,732 rows, including 3,003 `@msg`, 47 `@msgplural` and 41
|
||||
On 2026-11-13 the catalog has 8,782 rows, including 3,014 `@msg`, 47 `@msgplural` and 41
|
||||
`@plural` rows. A wording pass on that date corrected 193 rows outside the plug-in
|
||||
messages for meaning, dropped clauses, English left in place and the save, swatch, crop,
|
||||
resolution, path and type terms. It is not a native proofread.
|
||||
|
||||
@@ -29,7 +29,7 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker
|
||||
| English | `en` | source, 100% | all | Latin | — | full | — |
|
||||
| Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) |
|
||||
| Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) |
|
||||
| Hindi | `hi` | 8,732 rows (2026-11-13): 3,003 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font |
|
||||
| Hindi | `hi` | 8,782 rows (2026-11-13): 3,014 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font |
|
||||
| Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 |
|
||||
| French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) |
|
||||
| Portuguese | `pt-br` | 6,106 catalog entries (counted 2026-10-31 on `main` at `d26dd933a`): menus, `tl!` literals and messages complete when counted; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) |
|
||||
|
||||
+43
-7
@@ -230,6 +230,10 @@ the suite) keep the agent to the folders you give it:
|
||||
value and a linked JavaScript file in `svg_scripts` outside them by its file name alone (a JavaScript file given
|
||||
as a URL is kept), and leaves out the `folder` of `export_settings` (the settings Export
|
||||
for Screens last used) when that folder is outside them. The document itself is not changed.
|
||||
- `svgInteractivity.info`, `svgInteractivity.addFile` and `svgInteractivity.removeFile` name a linked JavaScript
|
||||
file outside the read roots by its file name alone in `files`, as `document.json` does, and `removeFile` takes that
|
||||
name for it (see SVG interactivity). In a frame a request drives, the JavaScript Files dialog lists such a file by
|
||||
its file name, and `ui_inspect` gives the file selected in the dialog the same way.
|
||||
- `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths
|
||||
outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in
|
||||
folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source`
|
||||
@@ -3860,11 +3864,32 @@ with one gray channel, so a PDF writes it in gray.
|
||||
`edit.colors.saturate` needs `intensity`, a number from -100 to 100 (a number outside the range counts as its nearest
|
||||
end). Without one, or with `null`, it answers `` missing number `intensity` ``; with a value that is not a number, such as
|
||||
`"40"`, it answers `` `intensity` must be a number, not "40" ``. Either way it changes nothing.
|
||||
The `edit.colors.*` commands, `edit.colors.overprintBlack` and `addon.color.filter` answer a `fill`, `stroke`,
|
||||
`includeImages`, `includePatterns`, `convert`, `remove`, `includeCmyBlacks` or `includeSpotBlacks` that is not true or
|
||||
false with `` `fill` must be true or false ``, naming the key, a `percentage` that is not a number with
|
||||
`` `percentage` must be a number ``, and a `mode` that is not a string with `` `mode` must be a string ``. They change
|
||||
nothing and record no undo step. A null counts as a key left out.
|
||||
The `edit.colors.*` commands other than `edit.colors.toIndexed`, `edit.colors.overprintBlack` and
|
||||
`addon.color.filter` answer a `fill`, `stroke`, `includeImages`, `includePatterns`, `convert`, `remove`,
|
||||
`includeCmyBlacks` or `includeSpotBlacks` that is not true or false with `` `fill` must be true or false ``, naming the
|
||||
key, a `percentage` that is not a number with `` `percentage` must be a number ``, and a `mode` that is not a string
|
||||
with `` `mode` must be a string ``. They change nothing and record no undo step. A null counts as a key left out.
|
||||
|
||||
`edit.colors.toIndexed` (Edit › Edit Colors › Convert to Indexed Color…) stores each embedded image in the selection (or
|
||||
`ids`), in groups too, as an indexed-color PNG: a palette of at most 256 RGB colors, with each pixel the index of one.
|
||||
`reduction` chooses the palette: `selective` (the default), `perceptual`, `adaptive`, `web`, `gray` and `blackWhite`
|
||||
choose it as a PNG-8 export does; `uniform` takes evenly spaced levels of red, green and blue (2 to 6 levels, the most
|
||||
whose cube fits in `colors` with one entry left for transparency); `document` takes the colors of the document's
|
||||
swatches (at most 255). `colors` (2 to 256, default 256) is the most palette entries, the transparent one included;
|
||||
`uniform` needs at least 9, and `blackWhite` and `document` refuse it. `dither` is `diffusion` (the default), `pattern`,
|
||||
`noise` or `none`, and `ditherAmount` is 0 to 100 (default 100). Reduction and dither ids are read as a PNG-8 export
|
||||
reads them, ignoring case. Pixels under half opacity become one transparent entry and the others become opaque in their
|
||||
own color. Linked images, colorized image fills and the images of color glyphs in outlined type keep their pixels. Each
|
||||
image that changes becomes a new image, in one undo step for all of them. An image that is already an indexed PNG and
|
||||
would show the same pixels is left as it is, and no undo step is recorded when no image changes. `notChanged` counts the
|
||||
locked and hidden objects Select All left out that hold an image that is not an indexed PNG yet. An image of more than
|
||||
134,217,728 pixels (33,554,432 in the web app) is refused with an error, read from the file's header before any pixel is
|
||||
decoded, and nothing changes. A value out of range or of the wrong type, such as `"colors": "8"`, is refused with an
|
||||
error and changes nothing.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"edit.colors.toIndexed","params":{"reduction":"adaptive","colors":32,"dither":"pattern"}}}
|
||||
```
|
||||
|
||||
The reply of `recolor.apply` and of every command above except the Blend commands has `notChanged: {locked, hidden}`.
|
||||
When the selection is exactly what `select.all` takes (in isolation mode, what it takes inside the isolated group), it
|
||||
@@ -4883,7 +4908,12 @@ files) and `eventNames` (the 19 events the panel lists, from `onfocusin` to `ons
|
||||
event's script where they have one (an empty `script` removes it); `svgInteractivity.removeEvent {event, ids?}` and
|
||||
`svgInteractivity.clearEvents {ids?}` remove one event or all of them. Each is one undo step.
|
||||
`svgInteractivity.addFile {file}` and `svgInteractivity.removeFile {file}` link and unlink a JavaScript file (a path
|
||||
or a URL, kept as given without surrounding spaces); a document links at most 64 files. `ui.javascriptFilesDialog {}`
|
||||
or a URL, kept as given without surrounding spaces); a document links at most 64 files. `addFile` takes a file as
|
||||
already linked only when the same text is linked. With automation read roots in force, `info`, `addFile` and
|
||||
`removeFile` name a linked file outside them by its file name alone in `files` (a relative path is taken from the
|
||||
document's folder, and a URL is kept), and `removeFile` also unlinks such a file by that name. When the name fits
|
||||
more than one linked file (two such files, or such a file and a file linked as that name), `removeFile` returns an
|
||||
error and unlinks nothing. `ui.javascriptFilesDialog {}`
|
||||
opens the JavaScript Files dialog, whose Choose… puts a picked file in as a `file:///` URL. SVG export writes each event
|
||||
as an event attribute on the object's element (`onclick="…"`; with the SVG Tiny 1.2 profile it leaves them out with a
|
||||
warning) and each file as `<script xlink:href>`. SVG import reads the event attributes the panel lists and the
|
||||
@@ -5795,7 +5825,9 @@ From the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Pers
|
||||
drawing tool chosen last in the window, or the Pencil tool when none has been chosen yet; from any other tool it
|
||||
chooses the Selection tool, and the tool left becomes the drawing tool unless it is the Hand, Rotate View, Zoom or Print
|
||||
Tiling tool. It returns `{tool}` and adds no undo step. Pencil as the first drawing tool and the list of selection tools
|
||||
are VectorCraft's own rules. A tap in Presentation Mode runs nothing.
|
||||
are VectorCraft's own rules. A tap in Presentation Mode runs nothing. Under VectorCraft's own rule, a tap on the canvas
|
||||
while a modal dialog is open runs nothing, and fingers already down when it opens make no tap when they lift, during
|
||||
the dialog or after it closes.
|
||||
|
||||
`gridlineEvery` (72 pt by default) and `gridSubdivisions` (8 by default) set the document grid that View › Show Grid
|
||||
draws, and View › Snap to Grid snaps to its subdivisions. Changing either sets the grid of every open document.
|
||||
@@ -7148,6 +7180,10 @@ color, even one of the same color, and Registration are not spot colors; the spo
|
||||
Color Objects and `objects.spots` counts them; spot swatches no art uses are not listed; with `selectionOnly`, those
|
||||
in the selection), `spotColorNames` and `spotColors` (the spot colors those objects use) and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left
|
||||
out, which objects lists as Path Length in the document's units, say for a cut line or an LED strip along the letters);
|
||||
`brushNames`, `brushes`, `spotObjects`, `patternObjects` and `gradientObjects` leave out hidden objects, objects at an
|
||||
opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0, their characters' paints and
|
||||
colorized images included, and so does `selectionOnly` for an object selected inside such a group or layer, while
|
||||
`objects` still counts them as paths, groups and the other kinds;
|
||||
`format: "text"` returns the plain-text report Document Info › Save… (`docInfo.save {path?, selectionOnly?, category?}`:
|
||||
only `category` when given, else every category) and the package report write.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user