Place: files are read and decoded on a background thread

Squashed from #3740 onto main.
This commit is contained in:
dotCooCoo
2026-10-23 09:07:55 +07:00
committed by bflatastic
parent c28ad99678
commit 5fe4f3ead1
46 changed files with 1370 additions and 38 deletions
+3 -2
View File
@@ -392,11 +392,12 @@ pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec
// ---------- reading files into the document ----------
/// Keep `full` (a file's pixels) as image `key` of `d`, unless pixels are there already; a
/// preview there is kept, and a linked image gets one.
/// preview there is kept, and a linked image gets one (the one `full` carries, when it has one).
pub(crate) fn store_image(d: &mut Document, key: &str, full: ImageBlob, linked: bool) {
let made = full.proxy.clone().filter(|_| linked);
let blob = match d.images.get(key) {
Some(b) if !b.is_proxy() => b.clone(),
old => ImageBlob { proxy: old.and_then(|b| b.proxy.clone()), ..full },
old => ImageBlob { proxy: old.and_then(|b| b.proxy.clone()).or(made), ..full },
};
d.images.insert(key.to_string(), if linked { blob.with_proxy() } else { blob });
}
+1 -1
View File
@@ -59,7 +59,7 @@ mod pathops;
mod patterncmds;
pub mod pdfcmds;
pub mod perspgrid;
pub(crate) mod place;
pub mod place;
pub mod plugin;
pub mod prefscmds;
pub mod print;
+176 -9
View File
@@ -454,7 +454,89 @@ fn fit(natural: Rect, r: Rect) -> Affine {
Affine::translate(r.center().to_vec2()) * Affine::scale(k) * Affine::translate(-natural.center().to_vec2())
}
/// What `file.place` takes from the session before it reads the file: the params, the object
/// Replace swaps and the artboard under `at`, which a DXF drawing is laid out on
/// ([`Session::prepare_place`]). [`PlaceJob::read`] reads and decodes the file without the session,
/// on any thread.
pub struct PlaceJob {
params: Value,
old: Option<NodeId>,
/// The object Replace swaps as it was when Place started.
replaced: Option<Node>,
target: Option<Rect>,
}
/// A file [`PlaceJob::read`] read, which [`Session::place_read`] places.
pub struct PlaceRead {
job: PlaceJob,
loaded: Loaded,
}
impl PlaceJob {
/// The name of the file it places ("photo.tif").
pub fn name(&self) -> String {
str_param(&self.params, "path").or_else(|| str_param(&self.params, "name")).map(fileio::file_name).unwrap_or_else(|| "Untitled".into())
}
/// Read and decode the file, as `file.place` does, without the session. A linked image's
/// preview is made here too.
pub fn read(self) -> Result<PlaceRead> {
let mut loaded = load(&self.params, PLACE, self.target, true)?;
if loaded.link.is_some()
&& bool_or(&self.params, "link", true)
&& let Art::Image(img) = &mut loaded.art
{
img.blob = img.blob.clone().with_proxy();
}
Ok(PlaceRead { job: self, loaded })
}
}
impl Session {
/// `file.place`'s checks (with no document open, the error `file.place` gives) of `params`
/// against the active document as it is now → what reads the file ([`PlaceJob::read`]; on the
/// desktop File › Place does this off the UI thread). Replace swaps the object selected now;
/// the art is placed against the artboard under `at` as it is when [`Session::place_read`]
/// places it.
pub fn prepare_place(&self, params: &Value) -> Result<PlaceJob> {
has_doc(self).map_err(|why| EngineError::Disabled(PLACE.into(), why))?;
prepare(self, params)
}
/// Run `file.place` with the params of `read` as a command (checked and journaled as any
/// other) that places the file `read` holds instead of reading it again → `file.place`'s
/// reply. The object Replace swaps must still be in the document as it was when Place started;
/// when the selection changed meanwhile, `select.set` selects that object again first, so a
/// replay of the journal replaces the same object.
pub fn place_read(&mut self, read: PlaceRead) -> Result<Value> {
let params = read.job.params.clone();
if let Some(old) = read.job.old {
let st = self.doc()?;
// Deleted, changed, or another object with its id: an undo that removes the object frees
// its id for the next object made.
if st.doc.node(old) != read.job.replaced.as_ref() {
return Err(bad(PLACE, "replace: the object to replace was changed or deleted"));
}
if st.selection.objects != [old] {
self.execute("select.set", &json!({ "ids": [old.0] }))?;
}
}
self.place_hand_off = Some(read);
let r = self.execute(PLACE, &params);
self.place_hand_off = None;
r
}
}
fn place(s: &mut Session, p: &Value) -> Result<Value> {
let read = match s.place_hand_off.take() {
Some(read) => read,
None => prepare(s, p)?.read()?,
};
apply(s, read)
}
fn prepare(s: &Session, p: &Value) -> Result<PlaceJob> {
let replace = bool_or(p, "replace", false);
let template = bool_or(p, "template", false);
let at = at_param(p)?;
@@ -472,16 +554,35 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
(true, [id]) => Some(*id),
(true, _) => return Err(bad(PLACE, "replace: select the one object to replace")),
};
let replaced = old.and_then(|id| st.doc.node(id)).cloned();
Ok(PlaceJob { params: p.clone(), old, replaced, target: board_at(&st.doc, at) })
}
/// Place the file `read` holds in the active document.
fn apply(s: &mut Session, read: PlaceRead) -> Result<Value> {
let PlaceRead { job: PlaceJob { params, old, target: read_for, .. }, mut loaded } = read;
let p = &params;
let template = bool_or(p, "template", false);
let at = at_param(p)?;
let rect = rect_param(p)?;
let st = s.doc()?;
let old_node = match old {
Some(id) => Some(st.doc.node(id).ok_or_else(|| bad(PLACE, "replace: the object to replace was changed or deleted"))?),
None => None,
};
// The artboard under `at` as it is now, as a replay of the journal finds it: a DXF drawing laid
// out on the artboard it was read for is read again when that artboard changed meanwhile.
let target = board_at(&st.doc, at);
let mut loaded = load(p, PLACE, target, true)?;
let old_node = old.and_then(|id| st.doc.node(id));
if loaded.board.is_some() && target != read_for {
loaded = load(p, PLACE, target, true)?;
}
// Type reflows rather than scales: its frame takes the size it is placed at.
let is_text = matches!(loaded.art, Art::Text(_));
if is_text {
let size = match (old_node.and_then(|o| o.geometric_bounds_with(&st.doc.symbol_boxes())), rect) {
(Some(b), _) => b.size(),
(None, Some(r)) => r.size(),
(None, None) => text_frame(&st.doc, at).size(),
(None, None) => text_frame(target).size(),
};
loaded.natural = Rect::from_origin_size(Point::ORIGIN, size);
}
@@ -532,10 +633,10 @@ fn board_at(d: &Document, at: Option<Point>) -> Option<Rect> {
at.and_then(|c| d.artboard_holding(c, false)).and_then(|i| d.artboards.get(i)).or(d.artboards.first()).map(|a| a.rect)
}
/// Placed type's frame without `rect` or Replace: the artboard under `at` (else the first) less a
/// 36 pt margin.
fn text_frame(d: &Document, at: Option<Point>) -> Rect {
let board = board_at(d, at).unwrap_or(TEXT_FRAME);
/// Placed type's frame without `rect` or Replace: the artboard `board` (the one under `at`, else the
/// first, [`board_at`]) less a 36 pt margin.
fn text_frame(board: Option<Rect>) -> Rect {
let board = board.unwrap_or(TEXT_FRAME);
Rect::from_center_size(board.center(), ((board.width() - 72.0).max(72.0), (board.height() - 72.0).max(72.0)))
}
@@ -577,10 +678,41 @@ fn queued_files(p: &Value) -> Result<Vec<Value>> {
}
}
fn queue(s: &mut Session, p: &Value) -> Result<Value> {
/// The files `file.place.queue` loads the place cursor with, before they are read
/// ([`Session::prepare_place_queue`]). [`QueueJob::read`] reads them without the session, on any
/// thread.
pub struct QueueJob {
params: Value,
files: Vec<Value>,
}
/// The files [`QueueJob::read`] read, which [`Session::place_queue_read`] loads the place cursor
/// with.
pub struct QueueRead {
params: Value,
entries: Vec<Value>,
files: Vec<Value>,
skipped: Vec<Value>,
}
impl QueueJob {
/// The names of the files it loads ("a.tif", "b.psd").
pub fn names(&self) -> Vec<String> {
self.files.iter().filter_map(|q| str_param(q, "path").or(str_param(q, "name"))).map(fileio::file_name).collect()
}
/// Read every file for its size and thumbnail, as `file.place.queue` does, without the session.
pub fn read(self) -> Result<QueueRead> {
read_queue(self.params, self.files)
}
}
/// Read each of `queued`, the files of `file.place.queue` with params `p`, for its size and
/// thumbnail.
fn read_queue(p: Value, queued: Vec<Value>) -> Result<QueueRead> {
let thumbnail = p.get("thumbnail").and_then(Value::as_f64);
let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]);
for mut q in queued_files(p)? {
for mut q in queued {
for k in ["link", "template", "page", "crop", "text", "dxf"] {
if let Some(v) = p.get(k) {
q[k] = v.clone();
@@ -603,6 +735,35 @@ fn queue(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(QueueRead { params: p, entries, files, skipped })
}
impl Session {
/// `file.place.queue`'s checks (with no document open, the error `file.place.queue` gives) of
/// `params` → what reads the files ([`QueueJob::read`]; on the
/// desktop the Place dialog's OK with several files does this off the UI thread).
pub fn prepare_place_queue(&self, params: &Value) -> Result<QueueJob> {
has_doc(self).map_err(|why| EngineError::Disabled(QUEUE.into(), why))?;
Ok(QueueJob { params: params.clone(), files: queued_files(params)? })
}
/// Run `file.place.queue` with the params of `read` as a command that loads the place cursor
/// with the files `read` holds instead of reading them again → `file.place.queue`'s reply.
pub fn place_queue_read(&mut self, read: QueueRead) -> Result<Value> {
let params = read.params.clone();
self.queue_hand_off = Some(read);
let r = self.execute(QUEUE, &params);
self.queue_hand_off = None;
r
}
}
fn queue(s: &mut Session, p: &Value) -> Result<Value> {
let read = match s.queue_hand_off.take() {
Some(read) => read,
None => s.prepare_place_queue(p)?.read()?,
};
let QueueRead { entries, files, skipped, .. } = read;
if entries.is_empty() {
let why = skipped.first().and_then(|e| e["error"].as_str()).unwrap_or("nothing to place");
return Err(bad(QUEUE, why));
@@ -645,6 +806,12 @@ fn thumbnail_png(l: Loaded, px: f64) -> Result<String> {
}
fn info(_: &mut Session, p: &Value) -> Result<Value> {
read_info(p)
}
/// `file.place.info` with `p`, without the session (on the desktop the Place dialog reads its
/// files' lines this way off the UI thread).
pub fn read_info(p: &Value) -> Result<Value> {
// A PDF/.ai page linked or not has the same size and art.
let mut l = load(p, "file.place.info", None, false)?;
let natural = l.natural;
+13
View File
@@ -1392,6 +1392,16 @@ pub struct Session {
/// While `command.batch` runs: the documents its steps closed or replaced (Revert), which an
/// error brings back; `None` otherwise.
pub(crate) batch_stash: Option<Vec<DocState>>,
/// While [`Session::place_read`] runs `file.place`: the file read for it, which the command
/// places instead of reading the file; `None` otherwise.
pub(crate) place_hand_off: Option<cmd::place::PlaceRead>,
/// While [`Session::place_queue_read`] runs `file.place.queue`: the files read for it, which
/// the command loads the place cursor with instead of reading them; `None` otherwise.
pub(crate) queue_hand_off: Option<cmd::place::QueueRead>,
/// Whether a click or drag of the place cursor asks the frontend to place its file
/// ([`UiRequest::Place`]) instead of running `file.place`: the desktop app sets it to read the
/// file off the UI thread. False: the file is placed at once.
pub place_cursor_requests: bool,
/// While [`Session::prepare_package`] runs `file.package`: the package the command prepared,
/// which it leaves here instead of writing it; `None` otherwise.
pub(crate) package_hand_off: Option<Option<cmd::package::Package>>,
@@ -1494,6 +1504,9 @@ impl Session {
journal_note: Default::default(),
note_depth: 1,
batch_stash: None,
place_hand_off: None,
queue_hand_off: None,
place_cursor_requests: false,
package_hand_off: None,
recovery: Default::default(),
in_tool_actions: false,
+243
View File
@@ -221,6 +221,194 @@ fn replace_needs_exactly_one_selected_object() {
assert!(r.unwrap_err().to_string().contains("select the one object"));
}
/// `p` with the members of `extra` added.
fn with(mut p: Value, extra: Value) -> Value {
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
o.extend(e);
}
p
}
/// A file read apart from the session ([`Session::prepare_place`], then [`cmd::place::PlaceJob::read`]
/// on another thread and [`Session::place_read`]), as File › Place reads one off the UI thread, is
/// placed as `file.place` places it: the same reply, document, selection and undo step, and the
/// same journal entry.
#[test]
fn a_file_read_apart_from_the_session_places_as_file_place_does() {
let mut src = Session::new();
src.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
src.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
let (pdf, native, eps) = (serialize(&mut src, "pdf"), serialize(&mut src, "vectorcraft"), eps_of(&mut src));
// SVG is serialized as text.
let svg = src.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().as_bytes().to_vec();
let dxf = serialize(&mut src, "dxf");
let dir = std::env::temp_dir().join(format!("vc-place-apart-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let saved = |name: &str, bytes: &[u8]| {
let p = dir.join(name);
std::fs::write(&p, bytes).unwrap();
p.to_string_lossy().into_owned()
};
let (pdf_path, native_path) = (saved("page.pdf", &pdf), saved("art.vectorcraft", &native));
let words = super::tests_textimport::docx(r#"<w:p><w:r><w:t>Placed words</w:t></w:r></w:p>"#, super::tests_textimport::STYLES);
let cases = [
with(file("red.png", &png(30, 20, [255, 0, 0, 255], Some(144.0))), json!({"at": [150, 100]})),
with(file("notes.txt", b"Two lines\nof text"), json!({"text": {}})),
file("note.rtf", br"{\rtf1\ansi {\b Bold} words\par}"),
file("words.docx", &words),
with(file("art.svg", &svg), json!({"rect": [10, 10, 80, 40]})),
file("art.eps", &eps),
file("plan.dxf", &dxf),
json!({"path": pdf_path}),
json!({"path": pdf_path, "link": false, "crop": "bounding"}),
json!({"path": native_path}),
with(file("page.pdf", &pdf), json!({"template": true})),
with(file("blue.png", &png(8, 8, [0, 0, 255, 255], None)), json!({"replace": true})),
];
for p in cases {
let name = p["name"].as_str().or(p["path"].as_str()).map(cmd::fileio::file_name).unwrap_or_default();
let (mut a, mut b) = (session(), session());
for s in [&mut a, &mut b] {
let id = s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 30})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
}
let at_once = a.execute("file.place", &p).map_err(|e| e.to_string());
assert!(at_once.is_ok(), "{name}: {at_once:?}");
let job = b.prepare_place(&p).unwrap();
assert_eq!(job.name(), name);
let read = std::thread::spawn(move || job.read()).join().unwrap().unwrap();
assert_eq!(b.place_read(read).map_err(|e| e.to_string()), at_once, "{name}: the same reply");
let (sa, sb) = (a.doc().unwrap(), b.doc().unwrap());
// `file.new` stamps each document with the second it was made in, which can differ by one.
let undated = |d: &vectorcraft_doc::Document| {
let mut d = d.clone();
d.metadata.created = None;
d
};
assert!(undated(&sa.doc) == undated(&sb.doc), "{name}: the same document");
assert_eq!(sa.selection.objects, sb.selection.objects, "{name}");
assert_eq!(sa.history.undo.len(), sb.history.undo.len(), "{name}: one undo step");
assert_eq!(a.journal.last(), b.journal.last(), "{name}");
assert_eq!(b.journal.last().map(|(id, _)| id.as_str()), Some("file.place"), "{name}: journaled as file.place");
}
let _ = std::fs::remove_dir_all(&dir);
// With no document open, the error the commands give.
let mut none = Session::new();
let p = file("red.png", &png(2, 2, [0; 4], None));
let err = |r: Result<Value>| r.err().map(|e| e.to_string());
assert_eq!(none.prepare_place(&p).err().map(|e| e.to_string()), err(none.execute("file.place", &p)));
let q = json!({"files": [p]});
assert_eq!(none.prepare_place_queue(&q).err().map(|e| e.to_string()), err(none.execute("file.place.queue", &q)));
}
/// Replace swaps the object selected when Place started, even when another one is selected by the
/// time the file has been read, and the journal replays the same; when that object was deleted or
/// changed meanwhile, nothing is placed.
#[test]
fn a_file_read_apart_from_the_session_replaces_what_was_selected_when_place_started() {
let two = || {
let mut s = session();
let first = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
let other = s.execute("shape.rectangle", &json!({"x": 200, "y": 100, "width": 20, "height": 20})).unwrap()["id"].clone();
(s, first, other)
};
let (mut s, first, other) = two();
let p = with(file("blue.png", &png(8, 8, [0, 0, 255, 255], None)), json!({"replace": true}));
s.execute("select.set", &json!({"ids": [first]})).unwrap();
let read = s.prepare_place(&p).unwrap().read().unwrap();
s.execute("select.set", &json!({"ids": [other]})).unwrap();
let r = s.place_read(read).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.node(NodeId(first.as_u64().unwrap())).is_none(), "the object selected when Place started is replaced");
assert!(st.doc.node(NodeId(other.as_u64().unwrap())).is_some());
assert_eq!(st.selection.objects, [NodeId(r["ids"][0].as_u64().unwrap())]);
// The journal's last two entries, run where the other object is selected, replace the same one.
let last: Vec<_> = s.journal.iter().rev().take(2).rev().cloned().collect();
assert_eq!(last.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(), ["select.set", "file.place"]);
let (mut replay, _, _) = two();
replay.execute("select.set", &json!({"ids": [other]})).unwrap();
for (id, p) in &last {
replay.execute(id, p).unwrap();
}
let undated = |s: &Session| {
let mut d = (*s.doc().unwrap().doc).clone();
d.metadata.created = None;
d
};
assert!(undated(&replay) == undated(&s), "the replay replaces the same object");
// The object to replace is deleted before the file has been read.
let read = s.prepare_place(&p).unwrap().read().unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let (doc, undo, journal) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len(), s.journal.len());
let err = s.place_read(read).unwrap_err();
assert!(err.to_string().contains("the object to replace was changed or deleted"), "{err}");
let st = s.doc().unwrap();
assert!(*st.doc == *doc && st.history.undo.len() == undo && s.journal.len() == journal, "nothing placed or journaled");
// The object to replace is moved, or an undo removes it and the next object made takes its id,
// before the file has been read.
for undone in [false, true] {
let mut s = session();
let id = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
let read = s.prepare_place(&p).unwrap().read().unwrap();
if undone {
s.execute("edit.undo", &json!({})).unwrap();
let next = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap()["id"].clone();
assert_eq!(next, id, "the next object takes the id");
} else {
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
}
let (doc, undo, journal) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len(), s.journal.len());
let err = s.place_read(read).unwrap_err();
assert!(err.to_string().contains("the object to replace was changed or deleted"), "undone {undone}: {err}");
let st = s.doc().unwrap();
assert!(*st.doc == *doc && st.history.undo.len() == undo && s.journal.len() == journal, "undone {undone}: nothing placed or journaled");
}
}
/// A linked image keeps the preview its file was read with ([`cmd::place::PlaceJob::read`] makes
/// it), so placing the read file does not decode the image again; an embedded image has none.
#[test]
fn a_linked_image_keeps_the_preview_made_when_its_file_was_read() {
let blob = vectorcraft_doc::ImageBlob::new("image/png", png(600, 300, [9, 9, 9, 255], None)).with_proxy();
let made = blob.proxy.clone().unwrap();
let mut d = vectorcraft_doc::Document::new(100.0, 100.0);
cmd::links::store_image(&mut d, "linked", blob.clone(), true);
assert!(d.images["linked"].proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &made)), "the preview it was read with");
cmd::links::store_image(&mut d, "embedded", blob, false);
assert!(d.images["embedded"].proxy.is_none());
}
/// Type and a DXF drawing fitted to the artboard, read apart from the session while the artboard
/// under `at` changes size, are placed against the artboard as it is when they are placed: as
/// `file.place` places them after the change, which is what a replay of the journal does.
#[test]
fn a_file_read_apart_from_the_session_is_placed_against_the_artboard_as_it_is_then() {
let mut src = Session::new();
src.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
src.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
let dxf = serialize(&mut src, "dxf");
let text = with(file("notes.txt", b"Words to place"), json!({"text": {}, "at": [100, 150]}));
for p in [text, with(file("plan.dxf", &dxf), json!({"dxf": {"fit": true}, "at": [100, 150]}))] {
let (mut a, mut b) = (session(), session());
let read = b.prepare_place(&p).unwrap().read().unwrap();
for s in [&mut a, &mut b] {
s.execute("artboard.setProps", &json!({"index": 0, "width": 200})).unwrap();
}
let at_once = a.execute("file.place", &p).unwrap();
assert_eq!(b.place_read(read).unwrap(), at_once, "{}", at_once["name"]);
let undated = |s: &Session| {
let mut d = (*s.doc().unwrap().doc).clone();
d.metadata.created = None;
d
};
assert!(undated(&a) == undated(&b), "{}", at_once["name"]);
if at_once["format"] == "txt" {
// The 200 × 300 pt artboard less a 36 pt margin on each side.
assert!(close(at_once["width"].as_f64().unwrap(), 128.0) && close(at_once["height"].as_f64().unwrap(), 228.0), "{at_once}");
}
}
}
#[test]
fn template_puts_the_art_on_a_locked_template_layer_below_the_current_one() {
let mut s = session();
@@ -481,6 +669,61 @@ fn a_queue_of_3_with_click_drag_and_esc_places_2() {
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "one undo step per placement");
}
/// With [`Session::place_cursor_requests`] set, a click of the place cursor asks the frontend to
/// place its file ([`UiRequest::Place`]) and places nothing itself; the params it asks with place
/// the file as the click places it without the flag.
#[test]
fn a_click_of_the_place_cursor_asks_the_frontend_to_place_the_file_when_asked_to() {
let view = ViewInfo::default();
let files: Vec<Value> = ["a.png", "b.png"].iter().map(|n| file(n, &png(40, 20, [9, 9, 9, 255], None))).collect();
let (mut at_once, mut s) = (session(), session());
s.place_cursor_requests = true;
for s in [&mut at_once, &mut s] {
s.execute("file.place.queue", &json!({"files": files})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), view).unwrap();
}
at_once.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 10.0), view).unwrap();
let reqs = s.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 10.0), view).unwrap();
let [UiRequest::Place(p)] = &reqs[..] else { panic!("{reqs:?}") };
assert_eq!(p["rect"], json!([10.0, 10.0, 40.0, 20.0]));
assert!(top_children(&s).is_empty(), "the frontend places it");
assert_eq!(s.tool_options()["names"], json!(["b.png"]), "the cursor goes on to the next file");
s.execute("file.place", p).unwrap();
// Left out: the second each document was made in.
let doc = |s: &Session| {
let mut d = (*s.doc().unwrap().doc).clone();
d.metadata.created = None;
d
};
assert!(doc(&s) == doc(&at_once));
assert_eq!(s.journal.last(), at_once.journal.last());
}
/// Files read for the place cursor apart from the session ([`Session::prepare_place_queue`], then
/// [`cmd::place::QueueJob::read`] on another thread and [`Session::place_queue_read`]) load it as
/// `file.place.queue` loads it: the same reply, with the files that can't be read skipped, and the
/// same tool and queue.
#[test]
fn files_read_for_the_place_cursor_apart_from_the_session_load_it_as_file_place_queue_does() {
let mut files: Vec<Value> = ["a.png", "b.png"].iter().map(|n| file(n, &png(40, 20, [9, 9, 9, 255], None))).collect();
files.push(file("notes.xyz", b"hello"));
let p = json!({"files": files, "thumbnail": 8, "link": false});
let (mut a, mut b) = (session(), session());
let at_once = a.execute("file.place.queue", &p).unwrap();
assert_eq!((at_once["count"].as_u64(), at_once["skipped"][0]["name"].as_str()), (Some(2), Some("notes.xyz")));
let job = b.prepare_place_queue(&p).unwrap();
assert_eq!(job.names(), ["a.png", "b.png", "notes.xyz"]);
let read = std::thread::spawn(move || job.read()).join().unwrap().unwrap();
assert_eq!(b.place_queue_read(read).unwrap(), at_once);
assert_eq!((b.tool_id(), b.tool_options()), (a.tool_id(), a.tool_options()));
// None of the files can be read: an error, and the tool stays.
let mut c = session();
let none = json!({"files": [file("notes.xyz", b"hello")]});
let read = c.prepare_place_queue(&none).unwrap().read().unwrap();
assert!(c.place_queue_read(read).unwrap_err().to_string().contains("not a format"));
assert_eq!(c.tool_id(), "selection");
}
#[test]
fn bad_place_params_are_errors() {
let mut s = session();
+2 -2
View File
@@ -209,7 +209,7 @@ const W_NS: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main
/// A Word document's package: its main part `body` (the inside of `w:body`), with a styles part
/// and a theme part named through the main part's relationships.
fn docx(body: &str, styles: &str) -> Vec<u8> {
pub(super) fn docx(body: &str, styles: &str) -> Vec<u8> {
let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
let rels = "http://schemas.openxmlformats.org/package/2006/relationships";
let types = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/></Types>"#;
@@ -237,7 +237,7 @@ fn docx(body: &str, styles: &str) -> Vec<u8> {
/// Document defaults in the theme's minor font at 11 pt with 8 pt after each paragraph, a default
/// Normal style, a centered, bold Heading 1 in the theme's major font at 16 pt and in a color, a
/// bold character style and a default character style in all caps.
const STYLES: &str = concat!(
pub(super) const STYLES: &str = concat!(
r#"<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi"/><w:sz w:val="22"/></w:rPr></w:rPrDefault>"#,
r#"<w:pPrDefault><w:pPr><w:spacing w:after="160"/></w:pPr></w:pPrDefault></w:docDefaults>"#,
r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>"#,
+5
View File
@@ -68,6 +68,9 @@ pub enum UiRequest {
/// A message for the status bar: the `warning` of a command the tool ran (Liquify skipping
/// type under the brush).
Status(String),
/// Place a file with these `file.place` params: a click or drag of the place cursor, when
/// [`Session::place_cursor_requests`] is set.
Place(Value),
}
impl Session {
@@ -619,6 +622,8 @@ impl Session {
self.liquify_stroke = None;
self.cancel_interaction()?
}
// The place cursor's file, placed by the frontend that asked for it.
Action::Exec(cmd, p) if cmd == "file.place" && self.place_cursor_requests => ui.push(UiRequest::Place(p)),
Action::Exec(cmd, p) => {
// A command's warning (Crop Image's when the image couldn't be decoded) goes to the
// status bar too.
+4
View File
@@ -168,6 +168,10 @@ impl Headless {
}
UiRequest::Dialog(k, p) => out.push(json!({"dialog": k, "params": p})),
UiRequest::Status(msg) => out.push(json!({"status": msg})),
// The place cursor's file, placed at once.
UiRequest::Place(p) => {
self.session.execute("file.place", &p).map_err(|e| e.to_string())?;
}
}
}
Ok(())
+1
View File
@@ -1649,6 +1649,7 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Ve
vectorcraft_engine::UiRequest::Dialog(kind, p) => crate::dialogs::open_tool_dialog(app, &kind, p),
vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t),
vectorcraft_engine::UiRequest::Status(msg) => app.status(msg),
vectorcraft_engine::UiRequest::Place(p) => crate::place::place_from_cursor(app, &p),
}
}
}
+1
View File
@@ -868,6 +868,7 @@ fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) {
.filter(|j| !j.label.is_empty() && !j.cancelled())
.map(|j| j.label.as_str())
.chain(app.opening.pending.iter().map(|p| p.label.as_str()))
.chain(app.placing.pending.iter().map(|p| p.label.as_str()))
.collect();
// Reserve the spinner's space while idle too: starting a background job changes the text,
// not its origin. Messages take priority over optional readouts in a narrow window.
+3
View File
@@ -162,6 +162,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"background": app.background.jobs.iter().filter(|j| !j.label.is_empty()).map(|j| j.label.as_str()).collect::<Vec<_>>(),
// Documents still being read off the UI thread (File › Open, Open Recent): their paths.
"opening": app.opening.pending.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(),
// Files still being read off the UI thread for Place (the Place dialog's OK, drops): their
// names, in the order they are placed.
"placing": app.placing.pending.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
// The screen color sampler is shown, waiting for the person at the computer
// (`ui.sampleScreenColor`).
"screenSampler": app.screen_sampler.is_some(),
+1 -1
View File
@@ -109,7 +109,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let r = if d.str("mode") == "place" {
let mut p = with_file(d.fields.get("__place"), d, &file);
p["dxf"] = dxf;
crate::place::run(app, &p).map(|_| ())
crate::place::run_for_user(app, &p).map(|_| ())
} else {
let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string);
io::open_document(app, &d.str("name"), &file.bytes, path, &json!({ "dxf": dxf })).map(|_| ())
+1 -1
View File
@@ -202,7 +202,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if let Some(pw) = password(&d) {
p["password"] = json!(pw);
}
crate::place::run(app, &p).map(|_| ())
crate::place::run_for_user(app, &p).map(|_| ())
} else {
let mut p = json!({"cropTo": crop(&d).id(), "password": password(&d)});
let range = d.str("range");
+6 -2
View File
@@ -5,7 +5,8 @@
//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one file
//! and one selected object: Replace applies), `__documents` and `__others` (VectorCraft documents
//! from files are picked, other files are: what Link's tooltip explains), `__info` (a line per
//! file).
//! file; on the desktop the lines are read off the UI thread and are empty until they are read,
//! [`crate::place::fill_lines`]).
use serde_json::{Value, json};
@@ -34,7 +35,10 @@ const OPTIONS: [(&str, &str, &str); 3] = [
("replace", "Replace", "Swap the selected object for the file, keeping its place and transform"),
];
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if crate::place::fill_lines(app, d) {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
}
let t = Tokens::get(ui.ctx());
let files: Vec<Value> = d.fields.get("files").and_then(Value::as_array).cloned().unwrap_or_default();
let info = d.fields.get("__info").cloned().unwrap_or_default();
+1 -1
View File
@@ -92,7 +92,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut params = d.fields.get("__params").cloned().unwrap_or_else(|| json!({}));
params["text"] = options(d);
app.ui.dialog = None;
crate::place::run(app, &params)
crate::place::run_for_user(app, &params)
}
#[cfg(test)]
+3
View File
@@ -3704,6 +3704,7 @@ axis Both И двете
@msg Couldn't copy to the system clipboard: {e} Неуспешно копиране в системния клипборд: {e}
@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 read {name}: {e} Неуспешно четене на {name}: {e}
@msg Couldn't save recovery data: {e} Неуспешно запазване на данните за възстановяване: {e}
@msg Create Outlines: select text objects Създаване на контури: изберете текстови обекти
@@ -4063,6 +4064,7 @@ axis Both И двете
@msg path is already closed пътят вече е затворен
@msg paths contain invalid coordinates пътищата съдържат невалидни координати
@msg paths: give at least one file пътища: посочете поне един файл
@msg replace: the object to replace was changed or deleted замяна: обектът за замяна е променен или изтрит
@msg pattern missing липсва мостра
@msg patterns nested too deeply are filled with mid-grey мострите, вложени прекалено дълбоко, се запълват със средно сиво
@msg percent must be above 0 and at most 6400, not {p} процентът трябва да е над 0 и най-много 6400, а не {p}
@@ -4257,6 +4259,7 @@ axis Both И двете
@msg Saving {_1} Записване на {_1}
@msg Exporting {_1} Експортиране на {_1}
@msg Packaging {_1} Пакетиране на {_1}
@msg Placing {_1} Вмъкване на {_1}
@msg Saving recovery data for {_1} Записване на данни за възстановяване на {_1}
@msg Discarded {_1} recovered document(s) Изхвърлени са {_1} възстановени документ(и)
@msg Restored {_1} recovered document(s): save them to keep them Възстановени са {_1} документ(и): запазете ги, за да ги съхраните
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -4092,6 +4092,7 @@ imageTrace Outline Obrys
@msg Couldn't copy to the system clipboard: {e} Nelze kopírovat do systémové schránky: {e}
@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 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
@@ -4457,6 +4458,7 @@ imageTrace Outline Obrys
@msg path is already closed cesta už je uzavřená
@msg paths contain invalid coordinates cesty obsahují neplatné souřadnice
@msg paths: give at least one file paths: zadejte alespoň jeden soubor
@msg replace: the object to replace was changed or deleted replace: objekt k nahrazení byl změněn nebo odstraněn
@msg pattern missing chybí vzorek
@msg patterns nested too deeply are filled with mid-grey příliš hluboko vnořené vzorky se vyplní středně šedou
@msg percent must be above 0 and at most 6400, not {p} procento musí být větší než 0 a nejvýše 6400, ne {p}
@@ -4657,6 +4659,7 @@ imageTrace Outline Obrys
@msg Saving {_1} Ukládání {_1}
@msg Exporting {_1} Exportování {_1}
@msg Packaging {_1} Balení {_1}
@msg Placing {_1} Umísťování {_1}
@msg Saving recovery data for {_1} Ukládání dat pro obnovení pro {_1}
@msg Discarded {_1} recovered document(s) Zahozeno obnovených dokumentů: {_1}
@msg Restored {_1} recovered document(s): save them to keep them Obnoveno dokumentů: {_1}; pokud je chcete zachovat, uložte je
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3825,6 +3825,7 @@ imageTrace Outline Kontur
@msg Couldn't copy to the system clipboard: {e} Kunne ikke kopiere til systemets udklipsholder: {e}
@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 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
@@ -4168,6 +4169,7 @@ imageTrace Outline Kontur
@msg path is already closed stien er allerede lukket
@msg paths contain invalid coordinates stierne indeholder ugyldige koordinater
@msg paths: give at least one file stier: angiv mindst én fil
@msg replace: the object to replace was changed or deleted erstat: objektet, der skulle erstattes, er ændret eller slettet
@msg pattern missing mønster mangler
@msg patterns nested too deeply are filled with mid-grey mønstre, der er indlejret for dybt, udfyldes med mellemgrå
@msg percent must be above 0 and at most 6400, not {p} procent skal være over 0 og højst 6400, ikke {p}
@@ -4353,6 +4355,7 @@ imageTrace Outline Kontur
@msg Saving {_1} Gemmer {_1}
@msg Exporting {_1} Eksporterer {_1}
@msg Packaging {_1} Pakker {_1}
@msg Placing {_1} Placerer {_1}
@msg Saving recovery data for {_1} Gemmer gendannelsesdata for {_1}
@msg Discarded {_1} recovered document(s) Forkastede {_1} gendannet(e) dokument(er)
@msg Restored {_1} recovered document(s): save them to keep them Genvandt {_1} gendannet(e) dokument(er): gem dem for at beholde dem
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3712,6 +3712,7 @@ theme Appearance Erscheinungsbild
@msg Couldn't copy to the system clipboard: {e} In die Systemzwischenablage konnte nicht kopiert werden: {e}
@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 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
@@ -4080,6 +4081,7 @@ theme Appearance Erscheinungsbild
@msg path is already closed der Pfad ist bereits geschlossen
@msg paths contain invalid coordinates die Pfade enthalten ungültige Koordinaten
@msg paths: give at least one file paths: Geben Sie mindestens eine Datei an
@msg replace: the object to replace was changed or deleted replace: Das zu ersetzende Objekt wurde geändert oder gelöscht
@msg pattern missing das Muster fehlt
@msg patterns nested too deeply are filled with mid-grey zu tief verschachtelte Muster werden mit mittlerem Grau gefüllt
@msg percent must be above 0 and at most 6400, not {p} percent muss über 0 und höchstens 6400 sein, nicht {p}
@@ -4280,6 +4282,7 @@ theme Appearance Erscheinungsbild
@msg Saving {_1} {_1} wird gespeichert
@msg Exporting {_1} {_1} wird exportiert
@msg Packaging {_1} {_1} wird verpackt
@msg Placing {_1} {_1} wird platziert
@msg Saving recovery data for {_1} Wiederherstellungsdaten für {_1} werden gespeichert
@msg Discarded {_1} recovered document(s) {_1} wiederhergestellte(s) Dokument(e) verworfen
@msg Restored {_1} recovered document(s): save them to keep them {_1} wiederhergestellte(s) Dokument(e) geöffnet: Speichern Sie sie, um sie zu behalten
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2054,6 +2054,7 @@ axis Both Και τα δύο
@msg Couldn't copy to the system clipboard: {e} Δεν ήταν δυνατή η αντιγραφή στο πρόχειρο συστήματος: {e}
@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 read {name}: {e} Δεν ήταν δυνατή η ανάγνωση του {name}: {e}
@msg Couldn't save recovery data: {e} Δεν ήταν δυνατή η αποθήκευση δεδομένων ανάκτησης: {e}
@msg Create Outlines: select text objects Δημιουργία περιγραμμάτων: επιλέξτε αντικείμενα κειμένου
@@ -4134,6 +4135,7 @@ theme Appearance Εμφάνιση
@msg path is already closed η διαδρομή είναι ήδη κλειστή
@msg paths contain invalid coordinates οι διαδρομές περιέχουν μη έγκυρες συντεταγμένες
@msg paths: give at least one file διαδρομές: δώστε τουλάχιστον ένα αρχείο
@msg replace: the object to replace was changed or deleted αντικατάσταση: το αντικείμενο προς αντικατάσταση άλλαξε ή διαγράφηκε
@msg pattern missing λείπει το μοτίβο
@msg patterns nested too deeply are filled with mid-grey τα πολύ βαθιά ένθετα μοτίβα γεμίζονται με μέτριο γκρι
@msg percent must be above 0 and at most 6400, not {p} το ποσοστό πρέπει να είναι μεγαλύτερο από 0 και έως 6400, όχι {p}
@@ -4329,6 +4331,7 @@ theme Appearance Εμφάνιση
@msg Saving {_1} Αποθήκευση {_1}
@msg Exporting {_1} Εξαγωγή {_1}
@msg Packaging {_1} Συσκευασία {_1}
@msg Placing {_1} Τοποθέτηση {_1}
@msg Saving recovery data for {_1} Αποθήκευση δεδομένων ανάκτησης για {_1}
@msg Discarded {_1} recovered document(s) Απορρίφθηκαν {_1} ανακτημένα έγγραφα
@msg Restored {_1} recovered document(s): save them to keep them Επαναφέρθηκαν {_1} ανακτημένα έγγραφα: αποθηκεύστε τα για να διατηρηθούν
Can't render this file because it contains an unexpected character in line 2045 and column 19.
+3
View File
@@ -3788,6 +3788,7 @@ axis Both Ambos
@msg Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e}
@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 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
@@ -4155,6 +4156,7 @@ axis Both Ambos
@msg path is already closed el trazado ya está cerrado
@msg paths contain invalid coordinates los trazados contienen coordenadas no válidas
@msg paths: give at least one file paths: indica al menos un archivo
@msg replace: the object to replace was changed or deleted replace: se modificó o eliminó el objeto que se iba a reemplazar
@msg pattern missing falta el motivo
@msg patterns nested too deeply are filled with mid-grey los motivos anidados a demasiada profundidad se rellenan de gris medio
@msg percent must be above 0 and at most 6400, not {p} el porcentaje debe ser mayor que 0 y como máximo 6400, no {p}
@@ -4356,6 +4358,7 @@ axis Both Ambos
@msg Saving {_1} Guardando {_1}
@msg Exporting {_1} Exportando {_1}
@msg Packaging {_1} Empaquetando {_1}
@msg Placing {_1} Colocando {_1}
@msg Saving recovery data for {_1} Guardando datos de recuperación de {_1}
@msg Discarded {_1} recovered document(s) Se descartaron {_1} documento(s) recuperado(s)
@msg Restored {_1} recovered document(s): save them to keep them Se restauraron {_1} documento(s) recuperado(s): guárdalos para conservarlos
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -4010,6 +4010,7 @@ imageTrace Outline Ääriviiva
@msg Couldn't copy to the system clipboard: {e} Järjestelmän leikepöydälle ei voitu kopioida: {e}
@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 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
@@ -4370,6 +4371,7 @@ imageTrace Outline Ääriviiva
@msg path is already closed polku on jo suljettu
@msg paths contain invalid coordinates polut sisältävät virheellisiä koordinaatteja
@msg paths: give at least one file paths: anna vähintään yksi tiedosto
@msg replace: the object to replace was changed or deleted replace: korvattavaa objektia on muutettu tai se on poistettu
@msg pattern missing kuvio puuttuu
@msg patterns nested too deeply are filled with mid-grey liian syvälle sisäkkäiset kuviot täytetään keskiharmaalla
@msg percent must be above 0 and at most 6400, not {p} prosenttiarvon on oltava yli 0 ja enintään 6400, ei {p}
@@ -4564,6 +4566,7 @@ imageTrace Outline Ääriviiva
@msg Saving {_1} Tallennetaan {_1}
@msg Exporting {_1} Viedään {_1}
@msg Packaging {_1} Paketoidaan {_1}
@msg Placing {_1} Sijoitetaan {_1}
@msg Saving recovery data for {_1} Tallennetaan palautustietoja: {_1}
@msg Discarded {_1} recovered document(s) Hylättiin {_1} palautettu(a) asiakirja(a)
@msg Restored {_1} recovered document(s): save them to keep them Palautettiin {_1} asiakirja(a): tallenna ne säilyttääksesi ne
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3785,6 +3785,7 @@ axis Both Les deux
@msg Couldn't copy to the system clipboard: {e} Impossible de copier dans le presse-papiers du système : {e}
@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 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
@@ -4152,6 +4153,7 @@ axis Both Les deux
@msg path is already closed le tracé est déjà fermé
@msg paths contain invalid coordinates les tracés contiennent des coordonnées non valides
@msg paths: give at least one file paths : indiquez au moins un fichier
@msg replace: the object to replace was changed or deleted replace : l’objet à remplacer a été modifié ou supprimé
@msg pattern missing motif manquant
@msg patterns nested too deeply are filled with mid-grey les motifs imbriqués trop profondément sont remplis de gris moyen
@msg percent must be above 0 and at most 6400, not {p} le pourcentage doit être supérieur à 0 et au plus égal à 6400, pas {p}
@@ -4353,6 +4355,7 @@ axis Both Les deux
@msg Saving {_1} Enregistrement de {_1}
@msg Exporting {_1} Exportation de {_1}
@msg Packaging {_1} Assemblage de {_1}
@msg Placing {_1} Importation de {_1}
@msg Saving recovery data for {_1} Enregistrement des données de récupération de {_1}
@msg Discarded {_1} recovered document(s) {_1} document(s) récupéré(s) supprimé(s)
@msg Restored {_1} recovered document(s): save them to keep them {_1} document(s) récupéré(s) restauré(s) : enregistrez-les pour les conserver
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -4555,6 +4555,7 @@ imageTrace Outline Körvonal
@msg Couldn't open {name}: {e} Nem sikerült megnyitni: {name}: {e}
@msg Couldn't paste from the clipboard: {e} Nem sikerült beilleszteni a vágólapról: {e}
@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}: {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}
@@ -4594,6 +4595,7 @@ imageTrace Outline Körvonal
@msg Exported {what} with {_1} note(s): {first} Exportálva: {what}; megjegyzések: {_1}, az első: {first}
@msg Exporting {_1} Exportálás: {_1}
@msg Packaging {_1} Csomagolás: {_1}
@msg Placing {_1} Elhelyezés: {_1}
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) a fájl verziója újabb, mint amit ez a VectorCraft támogat: {0} (támogatott: {VERSION})
@msg Flatten Transparency: select objects to flatten Átlátszóság lapítása: jelöld ki a lapítandó objektumokat
@msg Flatten Transparency: {e} Átlátszóság lapítása: {e}
@@ -4840,6 +4842,7 @@ imageTrace Outline Körvonal
@msg path is already closed az útvonal már zárt
@msg paths contain invalid coordinates az útvonalakban érvénytelen koordináták vannak
@msg paths: give at least one file paths: legalább egy fájlt adj meg
@msg replace: the object to replace was changed or deleted replace: a cserélendő objektumot módosították vagy törölték
@msg pattern missing hiányzik a mintázat
@msg patterns nested too deeply are filled with mid-grey a túl mélyen egymásba ágyazott mintázatok helyén középszürke kitöltés áll
@msg PDF settings are not an object a PDF-beállítások nem objektumot alkotnak
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3825,6 +3825,7 @@ imageTrace Outline Kontur
@msg Couldn't copy to the system clipboard: {e} Tidak dapat menyalin ke papan klip sistem: {e}
@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 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
@@ -4168,6 +4169,7 @@ imageTrace Outline Kontur
@msg path is already closed jalur sudah tertutup
@msg paths contain invalid coordinates jalur berisi koordinat yang tidak valid
@msg paths: give at least one file jalur: berikan setidaknya satu berkas
@msg replace: the object to replace was changed or deleted ganti: objek yang akan diganti telah diubah atau dihapus
@msg pattern missing pola hilang
@msg patterns nested too deeply are filled with mid-grey pola yang bersarang terlalu dalam diisi dengan abu-abu tengah
@msg percent must be above 0 and at most 6400, not {p} persen harus di atas 0 dan paling banyak 6400, bukan {p}
@@ -4353,6 +4355,7 @@ imageTrace Outline Kontur
@msg Saving {_1} Menyimpan {_1}
@msg Exporting {_1} Mengekspor {_1}
@msg Packaging {_1} Mengemas {_1}
@msg Placing {_1} Menempatkan {_1}
@msg Saving recovery data for {_1} Menyimpan data pemulihan untuk {_1}
@msg Discarded {_1} recovered document(s) {_1} dokumen hasil pemulihan dibuang
@msg Restored {_1} recovered document(s): save them to keep them {_1} dokumen hasil pemulihan dipulihkan: simpan agar tidak hilang
Can't render this file because it contains an unexpected character in line 3816 and column 19.
+3
View File
@@ -3828,6 +3828,7 @@ axis Both Entrambi
@msg Couldn't copy to the system clipboard: {e} Impossibile copiare negli appunti di sistema: {e}
@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 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
@@ -4195,6 +4196,7 @@ axis Both Entrambi
@msg path is already closed il tracciato è già chiuso
@msg paths contain invalid coordinates i tracciati contengono coordinate non valide
@msg paths: give at least one file paths: specifica almeno un file
@msg replace: the object to replace was changed or deleted replace: l’oggetto da sostituire è stato modificato o eliminato
@msg pattern missing motivo mancante
@msg patterns nested too deeply are filled with mid-grey i motivi annidati troppo in profondità vengono riempiti di grigio medio
@msg percent must be above 0 and at most 6400, not {p} la percentuale deve essere maggiore di 0 e al massimo 6400, non {p}
@@ -4396,6 +4398,7 @@ axis Both Entrambi
@msg Saving {_1} Salvataggio di {_1}
@msg Exporting {_1} Esportazione di {_1}
@msg Packaging {_1} Creazione del pacchetto {_1}
@msg Placing {_1} Inserimento di {_1}
@msg Saving recovery data for {_1} Salvataggio dei dati di recupero per {_1}
@msg Discarded {_1} recovered document(s) Documenti recuperati scartati: {_1}
@msg Restored {_1} recovered document(s): save them to keep them Documenti recuperati ripristinati: {_1}. Salvali per conservarli
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -4296,6 +4296,7 @@ swatch Bright Violet ビビッドバイオレット
@msg decimals must be {_1}–{_2} (got {_3}) 小数点以下の桁数は {_1} から {_2} までにしてください (指定値: {_3})
@msg Exporting {_1} {_1} を書き出し中
@msg Packaging {_1} {_1} をパッケージ中
@msg Placing {_1} {_1} を配置中
@msg Exported {what} {what} を書き出しました
@msg "{0}" is not a colour: write it as #rrggbb 「{0}」はカラーではありません: #rrggbb の形式で指定してください
@msg could not decode image: {0} 画像をデコードできませんでした: {0}
@@ -4465,9 +4466,11 @@ swatch Bright Violet ビビッドバイオレット
@msg no swatch library `{l}` (see swatch.library.list) スウォッチライブラリ `{l}` が見つかりません (swatch.library.list を参照)
@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 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 つ以上指定してください
@msg replace: the object to replace was changed or deleted replace: 置き換えるオブジェクトは変更または削除されています
@msg Couldn't place {name}: {e} {name} を配置できませんでした: {e}
@msg no print service プリントサービスがありません
@msg the print job has no data プリントジョブにデータがありません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3587,6 +3587,7 @@ effect Regular ჩვეულებრივი
@msg Couldn't copy to the system clipboard: {e} სისტემის გაცვლის ბუფერში კოპირება ვერ მოხერხდა: {e}
@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 read {name}: {e} {name} ვერ წაიკითხა: {e}
@msg Couldn't save recovery data: {e} აღდგენის მონაცემები ვერ შეინახა: {e}
@msg Create Outlines: select text objects კონტურების შექმნა: მონიშნეთ ტექსტური ობიექტები
@@ -3929,6 +3930,7 @@ effect Regular ჩვეულებრივი
@msg path is already closed ტრაექტორია უკვე შეკრულია
@msg paths contain invalid coordinates ტრაექტორიებში არასწორი კოორდინატებია
@msg paths: give at least one file paths: მიუთითეთ ერთი ფაილი მაინც
@msg replace: the object to replace was changed or deleted replace: ჩასანაცვლებელი ობიექტი შეიცვალა ან წაიშალა
@msg pattern missing პატერნი აკლია
@msg patterns nested too deeply are filled with mid-grey ზედმეტად ღრმად ჩადგმული პატერნები საშუალო ნაცრისფრით ივსება
@msg percent must be above 0 and at most 6400, not {p} პროცენტი უნდა იყოს 0-ზე მეტი და არაუმეტეს 6400-ისა და არა {p}
@@ -4112,6 +4114,7 @@ effect Regular ჩვეულებრივი
@msg Saving {_1} {_1}-ის შენახვა
@msg Exporting {_1} ექსპორტირდება: {_1}
@msg Packaging {_1} იფუთება: {_1}
@msg Placing {_1} თავსდება: {_1}
@msg Saving recovery data for {_1} {_1}-ის აღდგენის მონაცემების შენახვა
@msg Discarded {_1} recovered document(s) უარყოფილია აღდგენილი დოკუმენტები: {_1}
@msg Restored {_1} recovered document(s): save them to keep them აღდგენილია დოკუმენტები: {_1}: შესანარჩუნებლად შეინახეთ ისინი
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -4228,6 +4228,7 @@ theme Appearance 외관
@msg decimals must be {_1}–{_2} (got {_3}) 소수 자릿수는 {_1}~{_2}이어야 합니다({_3} 입력됨)
@msg Exporting {_1} {_1} 내보내는 중
@msg Packaging {_1} {_1} 패키지 만드는 중
@msg Placing {_1} {_1} 가져오는 중
@msg Exported {what} {what}을(를) 내보냈습니다
@msg "{0}" is not a colour: write it as #rrggbb "{0}"은(는) 색상이 아닙니다: #rrggbb 형식으로 입력하세요
@msg could not decode image: {0} 이미지를 디코딩할 수 없습니다: {0}
@@ -4397,9 +4398,11 @@ theme Appearance 외관
@msg no swatch library `{l}` (see swatch.library.list) 견본 라이브러리 `{l}`이(가) 없습니다(swatch.library.list 참조)
@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 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: 파일을 하나 이상 지정하세요
@msg replace: the object to replace was changed or deleted replace: 바꿀 개체가 변경되었거나 삭제되었습니다
@msg Couldn't place {name}: {e} {name}을(를) 가져올 수 없습니다: {e}
@msg no print service 인쇄 서비스가 없습니다
@msg the print job has no data 인쇄 작업에 데이터가 없습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3839,6 +3839,7 @@ imageTrace Outline Contour
@msg Couldn't copy to the system clipboard: {e} Kon niet naar het systeemklembord kopiëren: {e}
@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 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
@@ -4180,6 +4181,7 @@ imageTrace Outline Contour
@msg path is already closed pad is al gesloten
@msg paths contain invalid coordinates paden bevatten ongeldige coördinaten
@msg paths: give at least one file paden: geef minstens één bestand
@msg replace: the object to replace was changed or deleted vervangen: het te vervangen object is gewijzigd of verwijderd
@msg pattern missing patroon ontbreekt
@msg patterns nested too deeply are filled with mid-grey patronen die te diep genest zijn, worden gevuld met middelgrijs
@msg percent must be above 0 and at most 6400, not {p} procent moet boven 0 en maximaal 6400 zijn, niet {p}
@@ -4365,6 +4367,7 @@ imageTrace Outline Contour
@msg Saving {_1} Bezig met opslaan van {_1}
@msg Exporting {_1} Bezig met exporteren van {_1}
@msg Packaging {_1} Bezig met verpakken van {_1}
@msg Placing {_1} Bezig met plaatsen van {_1}
@msg Saving recovery data for {_1} Bezig met opslaan van herstelgegevens voor {_1}
@msg Discarded {_1} recovered document(s) {_1} hersteld(e) document(en) verworpen
@msg Restored {_1} recovered document(s): save them to keep them {_1} hersteld(e) document(en) teruggezet: sla ze op om ze te behouden
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3914,6 +3914,7 @@ imageTrace Outline Kontur
@msg Couldn't copy to the system clipboard: {e} Nie udało się skopiować do schowka systemowego: {e}
@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 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
@@ -4281,6 +4282,7 @@ imageTrace Outline Kontur
@msg path is already closed ścieżka jest już zamknięta
@msg paths contain invalid coordinates ścieżki zawierają nieprawidłowe współrzędne
@msg paths: give at least one file paths: podaj co najmniej jeden plik
@msg replace: the object to replace was changed or deleted replace: obiekt do zastąpienia został zmieniony lub usunięty
@msg pattern missing brak wzorka
@msg patterns nested too deeply are filled with mid-grey zbyt głęboko zagnieżdżone wzorki są wypełniane średnią szarością
@msg percent must be above 0 and at most 6400, not {p} procent musi być większy od 0 i nie większy niż 6400, nie {p}
@@ -4482,6 +4484,7 @@ imageTrace Outline Kontur
@msg Saving {_1} Zapisywanie {_1}
@msg Exporting {_1} Eksportowanie {_1}
@msg Packaging {_1} Pakowanie {_1}
@msg Placing {_1} Umieszczanie {_1}
@msg Saving recovery data for {_1} Zapisywanie danych odzyskiwania dla {_1}
@msg Discarded {_1} recovered document(s) Odrzucone odzyskane dokumenty: {_1}
@msg Restored {_1} recovered document(s): save them to keep them Przywrócone odzyskane dokumenty: {_1} — zapisz je, aby je zachować
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3371,6 +3371,7 @@ axis Both Ambos
@msg Couldn't copy to the system clipboard: {e} Não foi possível copiar para a área de transferência do sistema: {e}
@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 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
@@ -3728,6 +3729,7 @@ axis Both Ambos
@msg path is already closed o traçado já está fechado
@msg paths contain invalid coordinates os traçados contêm coordenadas inválidas
@msg paths: give at least one file paths: informe pelo menos um arquivo
@msg replace: the object to replace was changed or deleted replace: o objeto a ser substituído foi alterado ou excluído
@msg pattern missing padrão ausente
@msg patterns nested too deeply are filled with mid-grey padrões aninhados com profundidade excessiva são preenchidos com cinza médio
@msg percent must be above 0 and at most 6400, not {p} a porcentagem deve ser maior que 0 e no máximo 6400, não {p}
@@ -3927,6 +3929,7 @@ axis Both Ambos
@msg Saving {_1} Salvando {_1}
@msg Exporting {_1} Exportando {_1}
@msg Packaging {_1} Empacotando {_1}
@msg Placing {_1} Inserindo {_1}
@msg Saving recovery data for {_1} Salvando dados de recuperação de {_1}
@msg Discarded {_1} recovered document(s) {_1} documento(s) recuperado(s) descartado(s)
@msg Restored {_1} recovered document(s): save them to keep them {_1} documento(s) recuperado(s) restaurado(s): salve-os para mantê-los
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -3824,6 +3824,7 @@ axis Both Оба
@msg Couldn't copy to the system clipboard: {e} Не удалось скопировать в системный буфер обмена: {e}
@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 read {name}: {e} Не удалось прочитать {name}: {e}
@msg Couldn't save recovery data: {e} Не удалось сохранить данные восстановления: {e}
@msg Create Outlines: select text objects Создание контуров: выделите текстовые объекты
@@ -4191,6 +4192,7 @@ axis Both Оба
@msg path is already closed контур уже замкнут
@msg paths contain invalid coordinates контуры содержат недопустимые координаты
@msg paths: give at least one file paths: укажите как минимум один файл
@msg replace: the object to replace was changed or deleted replace: заменяемый объект был изменён или удалён
@msg pattern missing узор отсутствует
@msg patterns nested too deeply are filled with mid-grey слишком глубоко вложенные узоры заполняются серым
@msg percent must be above 0 and at most 6400, not {p} процент должен быть больше 0 и не более 6400, а не {p}
@@ -4392,6 +4394,7 @@ axis Both Оба
@msg Saving {_1} Сохранение {_1}
@msg Exporting {_1} Экспорт {_1}
@msg Packaging {_1} Упаковка {_1}
@msg Placing {_1} Размещение {_1}
@msg Saving recovery data for {_1} Сохранение данных восстановления для {_1}
@msg Discarded {_1} recovered document(s) Отменено восстановленных документов: {_1}
@msg Restored {_1} recovered document(s): save them to keep them Восстановлено документов: {_1}: сохраните их, чтобы оставить
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -3708,6 +3708,7 @@ axis Both Båda
@msg Couldn't copy to the system clipboard: {e} Kunde inte kopiera till systemets urklipp: {e}
@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 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
@@ -4070,6 +4071,7 @@ axis Both Båda
@msg path is already closed banan är redan sluten
@msg paths contain invalid coordinates banorna innehåller ogiltiga koordinater
@msg paths: give at least one file paths: ange minst en fil
@msg replace: the object to replace was changed or deleted replace: objektet som skulle ersättas har ändrats eller tagits bort
@msg pattern missing mönster saknas
@msg patterns nested too deeply are filled with mid-grey mönster som är kapslade för djupt fylls med mellangrått
@msg percent must be above 0 and at most 6400, not {p} procent måste vara över 0 och högst 6400, inte {p}
@@ -4265,6 +4267,7 @@ axis Both Båda
@msg Saving {_1} Sparar {_1}
@msg Exporting {_1} Exporterar {_1}
@msg Packaging {_1} Paketerar {_1}
@msg Placing {_1} Placerar {_1}
@msg Saving recovery data for {_1} Sparar återställningsdata för {_1}
@msg Discarded {_1} recovered document(s) Kastade {_1} återställda dokument
@msg Restored {_1} recovered document(s): save them to keep them Återställde {_1} återskapade dokument: spara dem för att behålla dem
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3
View File
@@ -3596,6 +3596,7 @@ axis Both Her İkisi
@msg Couldn't copy to the system clipboard: {e} Sistem panosuna kopyalanamadı: {e}
@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 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
@@ -3924,6 +3925,7 @@ axis Both Her İkisi
@msg path is already closed yol zaten kapalı
@msg paths contain invalid coordinates yollar geçersiz koordinatlar içeriyor
@msg paths: give at least one file yollar: en az bir dosya verin
@msg replace: the object to replace was changed or deleted değiştir: değiştirilecek nesne değiştirildi veya silindi
@msg pattern missing desen eksik
@msg patterns nested too deeply are filled with mid-grey çok derin iç içe geçmiş desenler orta gri renkle dolu
@msg percent must be above 0 and at most 6400, not {p} yüzde {p} değil, 0'ın üzerinde ve en fazla 6400 olmalıdır
@@ -4110,6 +4112,7 @@ axis Both Her İkisi
@msg Saving {_1} {_1} kaydediliyor
@msg Exporting {_1} {_1}'yi dışa aktarma
@msg Packaging {_1} {_1} paketleniyor
@msg Placing {_1} {_1} yerleştiriliyor
@msg Saving recovery data for {_1} {_1} için kurtarma verileri kaydediliyor
@msg Discarded {_1} recovered document(s) Atılan {_1} kurtarılan belge(ler)
@msg Restored {_1} recovered document(s): save them to keep them Geri yüklenen {_1} kurtarılan belge(ler): saklamak için bunları kaydedin
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3
View File
@@ -3812,6 +3812,7 @@ axis Both Обидві
@msg Couldn't copy to the system clipboard: {e} Не вдалося скопіювати до системного буфера обміну: {e}
@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 read {name}: {e} Неможливо прочитати {name}: {e}
@msg Couldn't save recovery data: {e} Не вдалося зберегти дані відновлення: {e}
@msg Create Outlines: select text objects Створення контурів: виділіть текстові об’єкти
@@ -4179,6 +4180,7 @@ axis Both Обидві
@msg path is already closed контур вже замкнутий
@msg paths contain invalid coordinates контури містять неприпустимі координати
@msg paths: give at least one file paths: вкажіть щонайменше один файл
@msg replace: the object to replace was changed or deleted replace: об’єкт для заміни було змінено або видалено
@msg pattern missing візерунок відсутній
@msg patterns nested too deeply are filled with mid-grey надто глибоко вкладені візерунки заповнюються сірим
@msg percent must be above 0 and at most 6400, not {p} відсоток має бути більше 0 і не більше 6400, а не {p}
@@ -4380,6 +4382,7 @@ axis Both Обидві
@msg Saving {_1} Збереження {_1}
@msg Exporting {_1} Експорт {_1}
@msg Packaging {_1} Пакування {_1}
@msg Placing {_1} Розміщення {_1}
@msg Saving recovery data for {_1} Збереження даних відновлення для {_1}
@msg Discarded {_1} recovered document(s) Скасовано відновлених документів: {_1}
@msg Restored {_1} recovered document(s): save them to keep them Відновлено документів: {_1}: збережіть їх, щоб залишити
Can't render this file because it contains an unexpected character in line 733 and column 40.
+3
View File
@@ -4204,6 +4204,7 @@ theme Appearance Giao diện
@msg Couldn't open {name}: {e} Không thể mở {name}: {e}
@msg Couldn't paste from the clipboard: {e} Không thể dán từ khay nhớ tạm: {e}
@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}: {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}
@@ -4241,6 +4242,7 @@ theme Appearance Giao diện
@msg Exported {what} with {_1} note(s): {first} Đã xuất {what} với {_1} ghi chú: {first}
@msg Exporting {_1} Đang xuất {_1}
@msg Packaging {_1} Đang đóng gói {_1}
@msg Placing {_1} Đang đặt {_1}
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) phiên bản tệp {0} mới hơn VectorCraft hỗ trợ ({VERSION})
@msg Flatten Transparency: select objects to flatten Dẹp phẳng độ trong suốt: hãy chọn đối tượng để dẹp phẳng
@msg Flatten Transparency: {e} Dẹp phẳng độ trong suốt: {e}
@@ -4490,6 +4492,7 @@ theme Appearance Giao diện
@msg path is already closed đường dẫn đã được đóng
@msg paths contain invalid coordinates đường dẫn chứa tọa độ không hợp lệ
@msg paths: give at least one file đường dẫn: hãy cung cấp ít nhất một tệp
@msg replace: the object to replace was changed or deleted thay thế: đối tượng cần thay thế đã bị thay đổi hoặc xóa
@msg pattern missing thiếu họa tiết
@msg patterns nested too deeply are filled with mid-grey họa tiết lồng nhau quá sâu được tô bằng màu xám trung tính
@msg PDF settings are not an object Cài đặt PDF không phải là đối tượng
Can't render this file because it contains an unexpected character in line 4030 and column 10.
+3
View File
@@ -4027,6 +4027,7 @@ imageTrace Outline 轮廓
@msg decimals must be {_1}–{_2} (got {_3}) 小数位数必须为 {_1}–{_2} (实际为 {_3})
@msg Exporting {_1} 正在导出 {_1}
@msg Packaging {_1} 正在打包 {_1}
@msg Placing {_1} 正在置入 {_1}
@msg Exported {what} 已导出 {what}
@msg "{0}" is not a colour: write it as #rrggbb "{0}" 不是颜色:请写为 #rrggbb
@msg could not decode image: {0} 无法解码图像:{0}
@@ -4189,9 +4190,11 @@ imageTrace Outline 轮廓
@msg no swatch library `{l}` (see swatch.library.list) 没有色板库 `{l}` (请参阅 swatch.library.list)
@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 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:请至少给出一个文件
@msg replace: the object to replace was changed or deleted replace:要替换的对象已被更改或删除
@msg Couldn't place {name}: {e} 无法置入 {name}:{e}
@msg no print service 没有打印服务
@msg the print job has no data 打印作业没有数据
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -2013,6 +2013,7 @@ file-filter Templates 範本
@msg decimals must be {_1}–{_2} (got {_3}) 小數位數必須為 {_1}–{_2} (收到 {_3})
@msg Exporting {_1} 正在匯出 {_1}
@msg Packaging {_1} 正在封裝 {_1}
@msg Placing {_1} 正在置入 {_1}
@msg Exported {what} 已匯出 {what}
@msg "{0}" is not a colour: write it as #rrggbb "{0}" 不是顏色:請寫成 #rrggbb
@msg could not decode image: {0} 無法解碼影像:{0}
@@ -2175,9 +2176,11 @@ file-filter Templates 範本
@msg no swatch library `{l}` (see swatch.library.list) 沒有色票資料庫 `{l}` (請參閱 swatch.library.list)
@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 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:請提供至少一個檔案
@msg replace: the object to replace was changed or deleted replace:要取代的物件已被變更或刪除
@msg Couldn't place {name}: {e} 無法置入 {name}:{e}
@msg no print service 沒有列印服務
@msg the print job has no data 列印工作沒有資料
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -2
View File
@@ -1046,9 +1046,10 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) ->
Ok(r)
}
/// Place a file's bytes (no path, so embedded) centred in the view: `file.place`.
/// Place a file's bytes (no path, so embedded) centered in the view: `file.place`, read off the UI
/// thread on the desktop ([`crate::place::run_for_user`]).
pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> {
crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ())
crate::place::run_for_user(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ())
}
#[cfg(test)]
+13 -1
View File
@@ -44,6 +44,7 @@ mod panel_docking;
pub mod panels;
pub mod picks;
pub mod place;
pub mod placing;
pub mod plugin_runs;
pub mod prefs_dialog;
pub mod print;
@@ -519,6 +520,8 @@ pub struct VectorcraftApp {
pub opening: opening::Opening,
/// Heavy plug-in filters running off the UI thread ([`plugin_runs`]).
pub plugin_runs: plugin_runs::PluginRuns,
/// Files being read off the UI thread for Place ([`placing`]).
pub placing: placing::Placing,
/// Data Recovery's timer and startup question ([`recovery`]).
pub recovery: recovery::Timer,
/// Modifiers the keyboard holds (from the host's input), given back after synthetic input.
@@ -576,6 +579,9 @@ impl VectorcraftApp {
if let Some(store) = &services.recovery_store {
session.recovery.set_store(store.clone());
}
// The place cursor's clicks and drags place their files through the app
// ([`place::place_from_cursor`]).
session.place_cursor_requests = true;
let views = session.documents().iter().map(View::of).collect();
Self {
session,
@@ -644,6 +650,7 @@ impl VectorcraftApp {
background: Default::default(),
opening: Default::default(),
plugin_runs: Default::default(),
placing: Default::default(),
recovery: Default::default(),
host_modifiers: Default::default(),
synthetic_modifiers: false,
@@ -1340,9 +1347,14 @@ impl VectorcraftApp {
screen_color::poll(self, ctx);
opening::poll(self);
plugin_runs::poll(self);
placing::poll(self);
#[cfg(not(target_arch = "wasm32"))]
self.tick_link_updates(ctx, now);
if !self.background.jobs.is_empty() || !self.opening.pending.is_empty() || !self.plugin_runs.running.is_empty() {
if !self.background.jobs.is_empty()
|| !self.opening.pending.is_empty()
|| !self.plugin_runs.running.is_empty()
|| !self.placing.pending.is_empty()
{
// Keep the status bar's progress moving and pick the result up when it arrives.
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
+70 -3
View File
@@ -29,11 +29,12 @@ pub struct Opening {
}
/// Is a long operation running that should show the busy cursor and status spinner (background
/// save/export with a label, a file opening off the UI thread, or a heavy plug-in filter)?
/// save/export with a label, a file opening or being read for Place off the UI thread, or a heavy plug-in filter)?
pub fn long_operation_busy(app: &VectorcraftApp) -> bool {
app.background.jobs.iter().any(|j| !j.label.is_empty() && !j.cancelled())
|| !app.opening.pending.is_empty()
|| !app.plugin_runs.running.is_empty()
|| !app.placing.pending.is_empty()
}
/// Does opening happen off the UI thread here? Not on wasm (no threads), in unit tests (they look
@@ -80,9 +81,11 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec<u8>) -> Result<Val
/// Open the files that are ready (each frame). Not while a dialog is open: a document that opens
/// becomes the active one, and the dialog's OK acts on the active document (#1694: Save As
/// options confirmed after another file had finished opening saved that file's art under the
/// first one's name). The files wait, their status line too, until the dialog is closed.
/// first one's name). The files wait, their status line too, until the dialog is closed. Nor while
/// a drag (a tool's interaction, or a tool busy with a drag of its own) is under way: a document
/// that opens cancels it.
pub fn poll(app: &mut VectorcraftApp) {
if app.ui.dialog.is_some() {
if app.ui.dialog.is_some() || app.session.in_interaction() || app.session.tool_busy() {
return;
}
let mut i = 0;
@@ -186,6 +189,70 @@ mod tests {
assert_eq!(app.session.doc().unwrap().path.as_deref(), Some("/art/Second.vectorcraft"));
}
/// A file that finishes opening while an object is being dragged waits for the drag to end, so
/// the drag is not canceled.
#[test]
fn a_file_ready_during_a_drag_waits_for_it_to_end() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let square = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.select_tool("selection");
let view = vectorcraft_engine::ViewInfo::default();
for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 40.0), (PointerKind::Drag, 60.0)] {
app.session.pointer(&PointerEvent::new(kind, x, 20.0), view).unwrap();
}
start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap();
let t0 = std::time::Instant::now();
while t0.elapsed().as_millis() < 400 {
poll(&mut app);
std::thread::yield_now();
}
assert!(app.session.documents().len() == 1 && app.session.in_interaction(), "the file waits for the drag");
app.session.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 20.0), view).unwrap();
let t0 = std::time::Instant::now();
while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 {
poll(&mut app);
std::thread::yield_now();
}
assert_eq!(app.session.documents().len(), 2);
let moved = app.session.documents()[0].doc.node(vectorcraft_doc::NodeId(square)).and_then(|n| n.geometric_bounds());
assert_eq!(moved.map(|b| (b.x0, b.y0)), Some((50.0, 10.0)), "the drag was not canceled");
}
/// A file that finishes opening while the place cursor is being dragged, a drag the tool keeps
/// to itself, waits for the drag to end: the drag still places its file.
#[test]
fn a_file_ready_during_a_place_cursor_drag_waits_for_it_to_end() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let mut png = vec![];
image::RgbImage::new(8, 8).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
app.run("file.place.queue", json!({"files": [{"name": "a.png", "dataBase64": vectorcraft_format::base64_encode(&png)}]})).unwrap();
let view = vectorcraft_engine::ViewInfo::default();
for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 60.0)] {
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, x, 20.0), view);
}
start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap();
let t0 = std::time::Instant::now();
while t0.elapsed().as_millis() < 400 {
poll(&mut app);
std::thread::yield_now();
}
assert!(app.session.documents().len() == 1 && app.session.tool_busy(), "the file waits for the drag");
crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Up, 60.0, 20.0), view);
let t0 = std::time::Instant::now();
while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 {
poll(&mut app);
std::thread::yield_now();
}
assert_eq!(app.session.documents().len(), 2);
let first = &app.session.documents()[0].doc;
let names: Vec<_> = first.layers.iter().flat_map(|l| l.children().into_iter().flatten()).filter_map(|n| n.name.clone()).collect();
assert_eq!(names, ["a.png"], "the drag placed its file");
}
#[test]
fn a_file_read_off_the_ui_thread_opens_as_one_read_at_once() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
+140 -12
View File
@@ -63,6 +63,9 @@ pub struct PlaceState {
thumbs: Vec<(String, Thumb)>,
/// The Control bar's `image.info`, for (document uid, revision, image id).
info: Option<((u64, u64, u64), Value)>,
/// The Place dialog's lines about its files while they are read off the UI thread: the
/// dialog's files, and the lines once they are read.
reading_lines: Option<(Value, std::sync::mpsc::Receiver<Vec<String>>)>,
}
/// A place cursor thumbnail: decoded when the cursor is loaded, uploaded when first drawn (and
@@ -79,6 +82,12 @@ impl PlaceState {
th.tex = None;
}
}
/// The names of the files the loaded cursor has thumbnails of.
#[cfg(test)]
pub(crate) fn thumbnails(&self) -> Vec<&str> {
self.thumbs.iter().map(|(n, _)| n.as_str()).collect()
}
}
/// Run engine command `id` itself (past the UI's handling of the same id); an error shows in the
@@ -97,8 +106,27 @@ fn engine(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result<Value, String
/// File → Place… (`file.place`): with a file (`path` or `name` + `dataBase64`), place it, centred
/// in the view unless `at`, `rect` or `replace` say otherwise (a text file without `text` options
/// asks for them first); with `paths`, the Place dialog for them; with neither, pick the files
/// first.
/// first. The file is read and placed at once (MCP, scripts).
pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
place_file(app, p, Read::AtOnce)
}
/// [`run`] for a file the user places (the Place dialog's OK, [`io::place_bytes`], the OK of the
/// import options Place asks for): on the desktop the file is read off the UI thread
/// ([`crate::placing::start`]), and the result is then `{background: true, status}`.
pub fn run_for_user(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
place_file(app, p, Read::ForUser { dropped: false })
}
/// How [`place_file`] and [`queue_files`] read files: at once, or for the user, off the UI thread
/// where they can ([`crate::placing::available`]); `dropped` when the file was dropped on the canvas.
#[derive(Clone, Copy)]
enum Read {
AtOnce,
ForUser { dropped: bool },
}
fn place_file(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result<Value, String> {
if p.get("text").is_none() && crate::dialogs::text_import::is_text(p) {
return Ok(crate::dialogs::text_import::open(app, p));
}
@@ -127,7 +155,16 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
{
p["at"] = json!([v.center.x, v.center.y]);
}
engine(app, "file.place", &p)
match read {
Read::ForUser { dropped } if crate::placing::available(app) => {
let r = crate::placing::start(app, &p, dropped);
if let Err(e) = &r {
app.status(e.clone());
}
r
}
_ => engine(app, "file.place", &p),
}
}
/// Pick files to place: the Place dialog opens with them (on the web once they arrive).
@@ -153,7 +190,7 @@ fn pick(app: &mut VectorcraftApp) -> Result<(), String> {
/// The Place dialog for `files` (`{path}`, or `{name}` with the bytes in [`PlaceState`]).
fn open_dialog(app: &mut VectorcraftApp, files: Vec<Value>) {
let units = app.session.active().map(|d| d.doc.units).unwrap_or_default();
let info: Vec<String> = files.iter().map(|f| summary(app, f, units)).collect();
let info = lines(app, &files, units);
let one_object = app.session.active().is_some_and(|d| d.selection.objects.len() == 1);
let linked: Vec<bool> = files.iter().map(|f| is_vectorcraft_file(app, f)).collect();
app.ui.dialog = Some(crate::state::Dialog::new(
@@ -194,13 +231,73 @@ fn source(app: &VectorcraftApp, file: &Value) -> Option<Value> {
Some(json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) }))
}
/// One line about `file` for the Place dialog ("300 × 150 px, 300 ppi, RGB (1 in × 0.5 in)"), or
/// why it can't be placed.
/// The Place dialog's line about each of `files`, read at once, or where the files the user places
/// are read off the UI thread ([`crate::placing::available`]) on a thread of its own: the lines are
/// then empty until [`fill_lines`] shows them.
fn lines(app: &mut VectorcraftApp, files: &[Value], units: vectorcraft_doc::Unit) -> Vec<String> {
if crate::placing::available(app) {
let sources: Vec<Option<Value>> = files.iter().map(|f| source(app, f)).collect();
let (tx, read) = std::sync::mpsc::channel();
// The automation roots in force here are in force there.
let scope = vectorcraft_engine::file_access::ScopeHandle::capture();
let spawned = std::thread::Builder::new().name("vectorcraft-place-info".into()).spawn(move || {
let lines: Vec<String> = scope.run(|| {
sources
.iter()
.map(|p| match p {
None => "not found".to_string(),
Some(p) => line(
vectorcraft_engine::guard::catch_panic(|| vectorcraft_engine::cmd::place::read_info(p).map_err(|e| e.to_string()))
.unwrap_or_else(|msg| Err(format!("internal error: {msg} (please report this bug)"))),
units,
),
})
.collect()
});
// The app may have quit meanwhile: nothing to hand them to.
let _ = tx.send(lines);
});
if spawned.is_ok() {
app.place.reading_lines = Some((json!(files), read));
return vec![String::new(); files.len()];
}
}
files.iter().map(|f| summary(app, f, units)).collect()
}
/// The open Place dialog `d`'s lines about its files once they have been read off the UI thread
/// ([`lines`]) → whether they are still being read.
pub(crate) fn fill_lines(app: &mut VectorcraftApp, d: &mut crate::state::Dialog) -> bool {
let Some((files, read)) = &app.place.reading_lines else { return false };
if d.fields.get("files") != Some(files) {
return false;
}
match read.try_recv() {
Ok(lines) => {
d.fields.insert("__info".into(), json!(lines));
app.place.reading_lines = None;
false
}
Err(std::sync::mpsc::TryRecvError::Empty) => true,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
app.place.reading_lines = None;
false
}
}
}
/// One line about `file` for the Place dialog, read at once ([`line`]).
fn summary(app: &mut VectorcraftApp, file: &Value, units: vectorcraft_doc::Unit) -> String {
let Some(p) = source(app, file) else { return "not found".into() };
let i = match app.session.execute("file.place.info", &p) {
line(app.session.execute("file.place.info", &p).map_err(|e| e.to_string()), units)
}
/// One line for the Place dialog from `file.place.info`'s reply `i` ("300 × 150 px, 300 ppi, RGB
/// (1 in × 0.5 in)"), or why the file can't be placed.
fn line(i: Result<Value, String>, units: vectorcraft_doc::Unit) -> String {
let i = match i {
Ok(i) => i,
Err(e) => return e.to_string(),
Err(e) => return e,
};
let size = format!("{} × {}", units.format(i["width"].as_f64().unwrap_or(0.0)), units.format(i["height"].as_f64().unwrap_or(0.0)));
match (i["pixelWidth"].as_u64(), i["pixelHeight"].as_u64()) {
@@ -229,6 +326,7 @@ pub fn confirm(app: &mut VectorcraftApp, d: &crate::state::Dialog) -> Result<Val
let (link, template) = (d.bool("link"), d.bool("template"));
app.ui.place_link = link;
app.ui.dialog = None;
app.place.reading_lines = None;
let r = match &sources[..] {
[one] => {
let mut p = one.clone();
@@ -237,12 +335,12 @@ pub fn confirm(app: &mut VectorcraftApp, d: &crate::state::Dialog) -> Result<Val
if d.bool("replace") && d.bool("__replace") {
p["replace"] = json!(true);
}
run(app, &p)
run_for_user(app, &p)
}
many => {
let paths: Vec<&Value> = many.iter().filter_map(|s| s.get("path")).collect();
let files: Vec<&Value> = many.iter().filter(|s| s.get("path").is_none()).collect();
queue(app, &json!({ "paths": paths, "files": files, "link": link, "template": template }))
queue_files(app, &json!({ "paths": paths, "files": files, "link": link, "template": template }), Read::ForUser { dropped: false })
}
};
app.place.picked.clear();
@@ -251,13 +349,44 @@ pub fn confirm(app: &mut VectorcraftApp, d: &crate::state::Dialog) -> Result<Val
// ---------- the place cursor ----------
/// `file.place.queue` with thumbnails for the cursor (unless `thumbnail` is given).
/// `file.place.queue` with thumbnails for the cursor (unless `thumbnail` is given), the files read
/// at once (MCP, scripts).
pub fn queue(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
queue_files(app, p, Read::AtOnce)
}
fn queue_files(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result<Value, String> {
let mut p = p.clone();
if p.get("thumbnail").is_none() {
p["thumbnail"] = json!(THUMB);
}
if matches!(read, Read::ForUser { .. }) && crate::placing::available(app) {
let r = crate::placing::start_queue(app, &p);
if let Err(e) = &r {
app.status(e.clone());
}
return r;
}
let r = engine(app, "file.place.queue", &p)?;
show_thumbnails(app, &r);
Ok(r)
}
/// The file a click or drag of the place cursor places (`file.place` with `p`, its `rect` set):
/// read off the UI thread where the files the user places are ([`crate::placing::start`]), else at
/// once. An error shows in the status bar.
pub(crate) fn place_from_cursor(app: &mut VectorcraftApp, p: &Value) {
if crate::placing::available(app) {
if let Err(e) = crate::placing::start(app, p, false) {
app.status(e);
}
} else {
let _ = engine(app, "file.place", p);
}
}
/// The cursor's thumbnails from `file.place.queue`'s reply `r`.
pub(crate) fn show_thumbnails(app: &mut VectorcraftApp, r: &Value) {
app.place.thumbs = r["files"]
.as_array()
.into_iter()
@@ -269,7 +398,6 @@ pub fn queue(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
Some((f["name"].as_str()?.to_string(), Thumb { image: std::sync::Arc::new(color), tex: None }))
})
.collect();
Ok(r)
}
/// The loaded place cursor at `pos`: the current file's thumbnail with the number of files loaded.
@@ -359,7 +487,7 @@ pub fn drop_files(app: &mut VectorcraftApp, mut files: Vec<(DropTarget, DropFile
};
p["at"] = json!([at.x, at.y]);
p["link"] = json!(!embed);
run(app, &p).map(|_| ())
place_file(app, &p, Read::ForUser { dropped: true }).map(|_| ())
}
DropTarget::Open => match path.as_deref() {
Some(path) if app.services.read.is_some() => io::open_path(app, path).map(|_| ()),
+609
View File
@@ -0,0 +1,609 @@
//! Placing files off the UI thread (desktop): a file the user places (the Place dialog's OK, a file
//! dropped on the canvas, the OK of the PDF, DXF and text import options Place asks for) is read and
//! decoded on a thread that reads one file at a time while the status bar shows it, and is placed in
//! the document it was placed in once it is ready, as it would be at once: the same object, undo
//! step, selection and errors. The files the Place dialog loads the place cursor with are read, and
//! their thumbnails made, the same way, and so is the file each click or drag of the place cursor
//! places. Files are read and placed in the order they were started. `file.place` and
//! `file.place.queue` from MCP and scripts run at once, and so does every place on wasm (no
//! threads), with `VECTORCRAFT_SYNC_OPEN` set, and in unit tests that do not set
//! `off_the_ui_thread` ([`available`]).
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use serde_json::{Value, json};
use vectorcraft_engine::cmd::place::{PlaceRead, QueueRead};
use vectorcraft_engine::file_access::ScopeHandle;
use crate::VectorcraftApp;
/// What was read: one file to place, or the files to load the place cursor with.
enum Read {
Place(Box<PlaceRead>),
Queue(QueueRead),
}
/// Files being read for Place.
pub struct Pending {
/// The file's name ("photo.tif"; the names joined with ", " for the place cursor's files).
pub(crate) name: String,
/// What the status bar shows ("Placing photo.tif").
pub label: String,
/// The document they are placed in ([`vectorcraft_engine::DocState::uid`]).
doc: u64,
/// Whether the file was dropped on the canvas (its messages say so, as
/// [`crate::place::drop_files`]'s do).
dropped: bool,
done: Receiver<Result<Read, String>>,
}
/// A read waiting for the thread that reads the files.
type Job = Box<dyn FnOnce() + Send>;
/// The files being read for Place, in the order they were started.
#[derive(Default)]
pub struct Placing {
pub pending: Vec<Pending>,
/// Set by unit tests whose app reads the files the user places off the UI thread.
pub(crate) off_the_ui_thread: bool,
/// The thread that reads the files, one at a time in the order they were started: started with
/// the first read, it ends when the app does.
reader: Option<Sender<Job>>,
/// Unit tests: how many reads run now, and the most that ran at once.
#[cfg(test)]
pub(crate) reading: std::sync::Arc<[std::sync::atomic::AtomicUsize; 2]>,
}
/// Does `app` read a file the user places off the UI thread? Where documents open off it
/// ([`crate::opening::available`]), and in the unit tests that set `off_the_ui_thread`.
pub fn available(app: &VectorcraftApp) -> bool {
app.placing.off_the_ui_thread || crate::opening::available()
}
/// `file.place` with `p` for the active document, its file read and decoded off the UI thread
/// ([`vectorcraft_engine::Session::prepare_place`], [`vectorcraft_engine::cmd::place::PlaceJob::read`])
/// → `{background: true, status}`; `dropped` when it was dropped on the canvas. The params are
/// checked here first, so a bad one is an error at once. When no thread could start, the file is
/// placed at once → `file.place`'s reply.
pub fn start(app: &mut VectorcraftApp, p: &Value, dropped: bool) -> Result<Value, String> {
let job = app.session.prepare_place(p).map_err(|e| e.to_string())?;
let name = job.name();
let started = spawn(app, name.clone(), name, dropped, move || job.read().map(|r| Read::Place(Box::new(r))).map_err(|e| e.to_string()))?;
started.map_or_else(
|| {
let r = app.session.execute("file.place", p).map_err(|e| e.to_string());
app.sync_views();
r
},
Ok,
)
}
/// `file.place.queue` with `p` for the active document, its files read and their thumbnails made off
/// the UI thread → `{background: true, status}`; the place cursor is loaded once they are ready. When no thread could start, the cursor is loaded at once → `file.place.queue`'s reply.
pub fn start_queue(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let job = app.session.prepare_place_queue(p).map_err(|e| e.to_string())?;
let names = job.names().join(", ");
let started = spawn(app, names.clone(), names, false, move || job.read().map(Read::Queue).map_err(|e| e.to_string()))?;
match started {
Some(r) => Ok(r),
None => {
let r = app.session.execute("file.place.queue", p).map_err(|e| e.to_string());
app.sync_views();
let r = r?;
crate::place::show_thumbnails(app, &r);
Ok(r)
}
}
}
/// Run `read` for the active document on the thread that reads the files, after the reads started
/// before it (so a batch of large pictures is decoded one at a time, as at once) →
/// `{background: true, status}`, or `None` when that thread could not start.
fn spawn(
app: &mut VectorcraftApp,
name: String,
shown: String,
dropped: bool,
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 (tx, done) = channel();
// The automation roots in force here are in force there.
let scope = ScopeHandle::capture();
#[cfg(test)]
let reading = app.placing.reading.clone();
let job: Job = Box::new(move || {
#[cfg(test)]
{
let now = reading[0].fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
reading[1].fetch_max(now, std::sync::atomic::Ordering::SeqCst);
}
let r = scope
.run(|| vectorcraft_engine::guard::catch_panic(read))
.unwrap_or_else(|msg| Err(format!("internal error: {msg} (please report this bug)")));
#[cfg(test)]
reading[0].fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
// The app may have quit meanwhile: nothing to hand it to.
let _ = tx.send(r);
});
// A reader that has ended gives the job back.
let job = match &app.placing.reader {
Some(reader) => reader.send(job).err().map(|e| e.0),
None => Some(job),
};
if let Some(job) = job {
let (reader, jobs) = channel::<Job>();
let started = std::thread::Builder::new().name("vectorcraft-place".into()).spawn(move || {
for job in jobs {
job();
}
});
if started.is_err() {
return Ok(None);
}
let _ = reader.send(job);
app.placing.reader = Some(reader);
}
let label = format!("Placing {shown}");
app.placing.pending.push(Pending { name, label: label.clone(), doc, dropped, done });
let status = format!("{label}…");
app.status(status.clone());
Ok(Some(json!({ "background": true, "status": status })))
}
/// Place the files that are ready (each frame), in the order they were started: a file read
/// sooner than one started before it waits for that one. Not while a dialog is open or a drag (a
/// tool's interaction, or a tool busy with a drag of its own, such as the place cursor's) is under
/// way, as [`crate::opening::poll`]: a placed file's document becomes the active one and the
/// dialog's OK acts on the active document; a drag in another document is canceled when the
/// active document changes, and a drag in the same one previews from the document as it was
/// before the drag, without the placed art.
pub fn poll(app: &mut VectorcraftApp) {
while app.ui.dialog.is_none()
&& !app.session.in_interaction()
&& !app.session.tool_busy()
&& let Some(p) = app.placing.pending.first()
{
let r = match p.done.try_recv() {
Ok(r) => r,
Err(TryRecvError::Empty) => return,
Err(TryRecvError::Disconnected) => Err("the file stopped being read (please report this bug)".to_string()),
};
let p = app.placing.pending.remove(0);
// The status bar no longer says it is placing.
if app.ui.status == format!("{}…", p.label) {
app.ui.status.clear();
}
finish(app, &p, r);
}
}
/// Place files read off the UI thread in their document, which becomes the active one again, as
/// [`crate::place::run`] and [`crate::place::queue`] place files read at once; an error shows in
/// the status bar as it does then.
fn finish(app: &mut VectorcraftApp, p: &Pending, r: Result<Read, String>) {
let Some(i) = app.session.documents().iter().position(|st| st.uid == p.doc) else {
let name = &p.name;
if p.dropped {
app.status(format!("Couldn't place {name}: the document it was dropped on was closed"));
} else {
app.status(format!("Couldn't place {name}: the document it was placed in was closed"));
}
return;
};
app.session.set_active(i);
let r = match r {
Ok(Read::Place(read)) => app.session.place_read(*read).map(|_| ()).map_err(|e| e.to_string()),
Ok(Read::Queue(read)) => app.session.place_queue_read(read).map(|r| crate::place::show_thumbnails(app, &r)).map_err(|e| e.to_string()),
Err(e) => Err(e),
};
app.sync_views();
let name = &p.name;
match r {
Err(e) if p.dropped => app.status(format!("Couldn't place {name}: {e}")),
Err(e) => app.status(e),
Ok(()) => {}
}
}
#[cfg(test)]
mod tests {
use image::ImageFormat::{Bmp, Png, Tiff};
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_engine::Session;
use super::*;
use crate::Services;
use crate::place::{DropAt, DropTarget};
/// An app reading files from disk, with a 400 × 300 pt document, that reads the files the user
/// places off the UI thread when `background`.
fn app(background: bool) -> VectorcraftApp {
let services = Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() };
let mut app = VectorcraftApp::new(Session::new(), services);
app.placing.off_the_ui_thread = background;
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app
}
/// `bytes` written to a fresh file named `name` in a folder of `test`'s own → its path.
fn saved(test: &str, name: &str, bytes: &[u8]) -> String {
let dir = std::env::temp_dir().join(format!("vc-placing-{test}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
}
/// A `w` × `h` picture of one color in `format`.
fn picture(format: image::ImageFormat, w: u32, h: u32) -> Vec<u8> {
let mut out = vec![];
image::RgbImage::from_pixel(w, h, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut out), format).unwrap();
out
}
/// A flat `w` × `h` Photoshop document of one color (raw RGB, no layers).
fn psd(w: u32, h: u32) -> Vec<u8> {
let mut f = b"8BPS\0\x01\0\0\0\0\0\0\0\x03".to_vec();
f.extend(h.to_be_bytes());
f.extend(w.to_be_bytes());
f.extend([0, 8, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
for v in [40u8, 40, 200] {
f.extend(std::iter::repeat_n(v, (w * h) as usize));
}
f
}
/// A one-page PDF with a rectangle on it.
fn pdf() -> Vec<u8> {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
let v = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap();
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
/// Poll each frame until every file read for Place has been placed (at most 60 s).
fn placed(app: &mut VectorcraftApp) {
let t0 = std::time::Instant::now();
while !app.placing.pending.is_empty() && t0.elapsed().as_secs() < 60 {
poll(app);
std::thread::yield_now();
}
assert!(app.placing.pending.is_empty(), "read within 60 s");
}
/// What a place changes: the active document (without the second it was made in), its
/// selection, its undo steps and the last journal entry.
fn state(app: &VectorcraftApp) -> (Document, Vec<NodeId>, usize, Option<(String, Value)>) {
let st = app.session.doc().unwrap();
let mut doc = (*st.doc).clone();
doc.metadata.created = None;
(doc, st.selection.objects.clone(), st.history.undo.len(), app.session.journal.last().cloned())
}
/// The names of the active document's top-level objects, bottom first.
fn names(app: &VectorcraftApp) -> Vec<String> {
let st = app.session.doc().unwrap();
st.doc.layers.iter().flat_map(|l| l.children().into_iter().flatten()).filter_map(|n| n.name.clone()).collect()
}
/// A TIFF, a Photoshop document, a BMP, a PDF page and a PDF-compatible `.ai` page (both placed
/// linked) read off the UI thread are placed as when they are read at once: the same object
/// with its bounds and link, the same selection, one undo step and the same journal entry. Until
/// then the document is unchanged, and the status bar and the busy cursor show the file is being
/// placed.
#[test]
fn a_file_read_off_the_ui_thread_places_as_one_read_at_once() {
let page = pdf();
let files = [
("photo.tif", picture(Tiff, 300, 200)),
("photo.psd", psd(300, 200)),
("photo.bmp", picture(Bmp, 300, 200)),
("page.pdf", page.clone()),
("page.ai", page),
];
for (name, bytes) in files {
let p = json!({"path": saved("same", name, &bytes), "at": [200, 150]});
let mut at_once = app(false);
at_once.run("file.place", p.clone()).unwrap();
let mut app = app(true);
let before = state(&app);
let r = start(&mut app, &p, false).unwrap();
assert_eq!(r, json!({"background": true, "status": format!("Placing {name}…")}));
assert_eq!(app.ui.status, format!("Placing {name}…"));
assert!(crate::opening::long_operation_busy(&app), "{name}");
assert!(state(&app) == before, "{name}: nothing is placed until the file is read");
placed(&mut app);
assert!(state(&app) == state(&at_once), "{name}: placed as at once");
assert!(app.ui.status.is_empty(), "{name}: {}", app.ui.status);
assert!(!crate::opening::long_operation_busy(&app), "{name}");
}
}
/// The Place dialog's OK with one file, with several (the place cursor and its thumbnails), a
/// drop on the canvas and [`crate::io::place_bytes`] read their files off the UI thread;
/// `file.place` and `file.place.queue` from MCP and scripts read them at once, with the replies
/// they have always had.
#[test]
fn what_the_user_places_is_read_off_the_ui_thread_and_commands_read_at_once() {
let mut app = app(true);
let (a, b) = (saved("routes", "a.tif", &picture(Tiff, 60, 40)), saved("routes", "b.bmp", &picture(Bmp, 60, 40)));
let r = app.run("file.place", json!({"path": a})).unwrap();
assert!(r["ids"].is_array() && app.placing.pending.is_empty(), "{r}");
let r = app.run("file.place.queue", json!({"paths": [b]})).unwrap();
assert!(r["count"] == 1 && app.placing.pending.is_empty(), "{r}");
assert_eq!(app.place.thumbnails(), ["b.bmp"]);
app.select_tool("selection");
// The Place dialog's OK with one file.
app.run("file.place", json!({"paths": [a]})).unwrap();
assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["background"], true);
assert_eq!(app.placing.pending.len(), 1);
placed(&mut app);
assert_eq!(names(&app), ["a.tif", "a.tif"]);
// With several: the place cursor is loaded once they are read.
app.run("file.place", json!({"paths": [a, b]})).unwrap();
assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["background"], true);
assert_eq!(app.ui.status, "Placing a.tif, b.bmp…");
assert_eq!(app.session.tool_id(), "selection");
let (inspect, _) = crate::control::ControlRequest::new("ui.inspect", json!({}));
let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &inspect) else { panic!("not done") };
assert_eq!(r["result"]["placing"], json!(["a.tif, b.bmp"]), "ui.inspect lists the files being read");
placed(&mut app);
assert_eq!((app.session.tool_id(), app.session.tool_options()["count"].as_u64()), ("place", Some(2)));
assert_eq!(app.place.thumbnails(), ["a.tif", "b.bmp"]);
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 });
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);
placed(&mut app);
assert_eq!(names(&app), ["a.tif", "a.tif", "b.bmp", "c.png"]);
}
/// A click and a drag of the place cursor read their files off the UI thread, and place them as
/// a click and a drag place them when they are read at once.
#[test]
fn the_place_cursors_clicks_read_their_files_off_the_ui_thread() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let paths = [saved("cursor", "a.tif", &picture(Tiff, 60, 40)), saved("cursor", "b.bmp", &picture(Bmp, 60, 40))];
let view = vectorcraft_engine::ViewInfo::default();
let press = |app: &mut VectorcraftApp, from: (f64, f64), to: (f64, f64)| {
for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
crate::canvas::dispatch(app, &PointerEvent::new(kind, x, y), view);
}
};
let (mut at_once, mut app) = (app(false), app(true));
for app in [&mut at_once, &mut app] {
app.run("file.place.queue", json!({"paths": paths})).unwrap();
press(app, (10.0, 10.0), (10.0, 10.0));
}
assert_eq!(names(&at_once), ["a.tif"]);
assert!(names(&app).is_empty() && app.placing.pending.len() == 1, "nothing is placed until the file is read");
assert_eq!(app.ui.status, "Placing a.tif…");
placed(&mut app);
assert!(state(&app) == state(&at_once), "the click places as at once");
// A drag places the last file at the dragged size, and the previous tool comes back.
for app in [&mut at_once, &mut app] {
press(app, (100.0, 100.0), (180.0, 110.0));
}
placed(&mut app);
assert!(state(&app) == state(&at_once), "the drag places as at once");
assert_eq!(names(&app), ["a.tif", "b.bmp"]);
assert_eq!(app.session.tool_id(), at_once.session.tool_id());
}
/// The Place dialog's line about each file is read off the UI thread: the lines are empty when
/// the dialog opens, and then show what the lines read at once show.
#[test]
fn the_place_dialogs_lines_are_read_off_the_ui_thread() {
let p = json!({"paths": [saved("lines", "a.tif", &picture(Tiff, 60, 40)), saved("lines", "notes.xyz", b"hello")]});
let mut at_once = app(false);
at_once.run("file.place", p.clone()).unwrap();
let want = at_once.ui.dialog.as_ref().unwrap().fields["__info"].clone();
assert!(want[0].as_str().is_some_and(|l| l.starts_with("60 × 40 px")), "{want}");
let mut app = app(true);
app.run("file.place", p).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__info"], json!(["", ""]));
let t0 = std::time::Instant::now();
loop {
let Some(mut d) = app.ui.dialog.take() else { panic!("the dialog stays open") };
let reading = crate::place::fill_lines(&mut app, &mut d);
app.ui.dialog = Some(d);
if !reading || t0.elapsed().as_secs() > 60 {
break;
}
std::thread::yield_now();
}
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__info"], want);
}
/// Several files dropped together are placed in the order they were dropped, a large one first
/// included, as when they are read at once.
#[test]
fn files_dropped_together_are_placed_in_the_order_dropped() {
let large = saved("order", "large.tif", &picture(Tiff, 1500, 1500));
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 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);
placed(&mut app);
assert_eq!(names(&app), ["large.tif", "small1.png", "small2.png"]);
}
/// Files dropped together are read one at a time, as when they are read at once, so a batch of
/// large pictures is not decoded all at once.
#[test]
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 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);
assert_eq!(names(&app).len(), 4);
assert_eq!(app.placing.reading[1].load(std::sync::atomic::Ordering::SeqCst), 1, "the most reads at once");
}
/// A file that can't be read shows the message it shows when read at once, dropped or placed
/// from the dialog, and nothing is placed.
#[test]
fn a_file_that_cant_be_read_off_the_ui_thread_reports_as_at_once() {
let gone = saved("unread", "gone.tif", b"");
std::fs::remove_file(&gone).unwrap();
let unknown = saved("unread", "notes.xyz", b"hello");
for path in [gone, unknown] {
let p = json!({"path": path});
let mut at_once = app(false);
crate::place::run_for_user(&mut at_once, &p).unwrap_err();
let mut app = app(true);
let before = state(&app);
crate::place::run_for_user(&mut app, &p).unwrap();
placed(&mut app);
assert_eq!(app.ui.status, at_once.ui.status);
assert!(state(&app) == before, "{path}: nothing placed");
// Dropped on the canvas.
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 });
crate::place::drop_files(app, vec![(drop, (name.clone(), Some(path.clone()), vec![]))]);
placed(app);
}
assert_eq!(app.ui.status, at_once.ui.status);
assert!(app.ui.status.starts_with(&format!("Couldn't place {name}: ")), "{}", app.ui.status);
}
}
/// A file whose document closed before it was read is not placed, and the status bar says why.
#[test]
fn a_file_whose_document_closed_meanwhile_is_not_placed() {
let path = saved("closed", "photo.tif", &picture(Tiff, 60, 40));
for dropped in [false, true] {
let mut app = app(true);
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
start(&mut app, &json!({"path": path}), dropped).unwrap();
app.session.execute("file.close", &json!({})).unwrap();
let left = state(&app);
placed(&mut app);
let why = if dropped { "dropped on" } else { "placed in" };
assert_eq!(app.ui.status, format!("Couldn't place photo.tif: the document it was {why} was closed"));
assert!(state(&app) == left, "the other document is unchanged");
}
}
/// 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]
fn a_replace_whose_object_was_deleted_meanwhile_places_nothing() {
let path = saved("replace", "photo.tif", &picture(Tiff, 60, 40));
let mut app = app(true);
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
crate::place::run_for_user(&mut app, &json!({"path": path, "replace": true})).unwrap();
app.run("edit.clear", json!({})).unwrap();
let cleared = state(&app);
placed(&mut app);
assert!(app.ui.status.contains("the object to replace was changed or deleted"), "{}", app.ui.status);
assert!(state(&app) == cleared, "nothing placed");
}
/// A file read while a dialog is open waits for the dialog to close, as a document that opens
/// does (#1694): the dialog's OK acts on the active document.
#[test]
fn a_file_read_while_a_dialog_is_open_waits_for_it_to_close() {
let path = saved("dialog", "photo.tif", &picture(Tiff, 60, 40));
let mut app = app(true);
start(&mut app, &json!({"path": path}), false).unwrap();
app.ui.dialog = Some(crate::state::Dialog::new(crate::dialogs::save_options::KIND, json!({"path": "/art/First.vectorcraft"})));
let t0 = std::time::Instant::now();
while t0.elapsed().as_millis() < 400 {
poll(&mut app);
std::thread::yield_now();
}
assert!(names(&app).is_empty() && app.placing.pending.len() == 1, "the file waits for the dialog");
app.ui.dialog = None;
placed(&mut app);
assert_eq!(names(&app), ["photo.tif"]);
}
/// A file read while an object is being dragged, in its document or in another one, waits for
/// the drag to end: the drag is not canceled, and the placed art stays.
#[test]
fn a_file_read_during_a_drag_waits_for_it_to_end() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let path = saved("drag", "photo.tif", &picture(Tiff, 60, 40));
for other_document in [false, true] {
let mut app = app(true);
start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap();
if other_document {
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
}
let square = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
let dragged = app.session.active_index();
app.select_tool("selection");
let view = vectorcraft_engine::ViewInfo::default();
for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 40.0), (PointerKind::Drag, 60.0)] {
app.session.pointer(&PointerEvent::new(kind, x, 20.0), view).unwrap();
}
assert!(app.session.in_interaction());
let t0 = std::time::Instant::now();
while t0.elapsed().as_millis() < 400 {
poll(&mut app);
std::thread::yield_now();
}
assert!(app.placing.pending.len() == 1 && app.session.in_interaction(), "other document: {other_document}");
// The drag moves on before it ends.
for kind in [PointerKind::Drag, PointerKind::Up] {
app.session.pointer(&PointerEvent::new(kind, 60.0, 20.0), view).unwrap();
}
placed(&mut app);
let moved = app.session.documents()[dragged.unwrap()].doc.node(NodeId(square)).and_then(|n| n.geometric_bounds());
assert_eq!(moved.map(|b| (b.x0, b.y0)), Some((50.0, 10.0)), "other document: {other_document}");
assert_eq!(names(&app), ["photo.tif"], "other document: {other_document}");
}
}
/// A file read for one document while the place cursor is dragged in another waits for the drag
/// to end, which the place cursor keeps to itself: the drag still places its file.
#[test]
fn a_file_read_during_a_place_cursor_drag_waits_for_it_to_end() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let path = saved("cursor-drag", "photo.tif", &picture(Tiff, 60, 40));
let cursor = [saved("cursor-drag", "a.png", &picture(Png, 8, 8)), saved("cursor-drag", "b.png", &picture(Png, 8, 8))];
let mut app = app(true);
start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("file.place.queue", json!({"paths": cursor})).unwrap();
let view = vectorcraft_engine::ViewInfo::default();
for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 60.0)] {
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, x, 20.0), view);
}
assert!(app.session.tool_busy() && !app.session.in_interaction());
let t0 = std::time::Instant::now();
while t0.elapsed().as_millis() < 400 {
poll(&mut app);
std::thread::yield_now();
}
assert!(app.placing.pending.len() == 1 && app.session.tool_busy(), "the file waits for the drag");
crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Up, 60.0, 20.0), view);
placed(&mut app);
let placed_in = |i: usize| -> Vec<(String, Option<vectorcraft_geom::Rect>)> {
let st = &app.session.documents()[i];
st.doc
.layers
.iter()
.flat_map(|l| l.children().into_iter().flatten())
.map(|n| (n.name.clone().unwrap_or_default(), n.geometric_bounds()))
.collect()
};
assert_eq!(placed_in(0).iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["photo.tif"]);
assert_eq!(placed_in(1), [("a.png".to_string(), Some(vectorcraft_geom::Rect::new(20.0, 20.0, 60.0, 60.0)))], "the drag placed its file");
}
}
+1 -1
View File
@@ -111,7 +111,7 @@ the two can still win that race.
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab |
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centered in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). In the desktop app the dialog reads its files off the UI thread: `__info` is empty until they are read, `ui.dialog.confirm` returns `{background: true, status}`, `ui.inspect` lists the files still being read in `placing`, and the file is placed (or the cursor loaded) once it is read. With a file, it runs `file.place` centered in the view, at once. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files (in the desktop app each file is read off the UI thread and placed once it is read, and `ui.inspect` lists it in `placing` until then) and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
| `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `<stem>.html` (client-side `<map>`) or `<stem>.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) |
| `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields |
+1
View File
@@ -905,6 +905,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- File › Place reads RTF files as area type with their formatting: each run's font, size, bold and italic, underline, strikethrough, caps, superscript or subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction. Table cells are separated by tabs; hidden text, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests_textimport.rs`).
- File › Place reads Word `.docx` documents as area type with the same formatting, taken from the document defaults, the paragraph and character styles (their toggle properties reversing each other) and direct formatting, the theme's fonts included. Table rows are paragraphs with their cells separated by tabs; hidden and deleted text, field instructions, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests_textimport.rs`).
- A Word paragraph justified with a low, medium or high kashida length places justified with Insert Kashidas set to Short, Medium or Long (`file.place`, `crates/engine/src/tests_textimport.rs`).
- On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before.
- On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them.
- On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before.
- Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected.