diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index eb61b5126..b24386645 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -420,8 +420,22 @@ pub(crate) fn text_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result Option { p.get(key).and_then(Value::as_u64).map(NodeId) } -pub(crate) fn ids_param(p: &Value, key: &str) -> Option> { - p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()) +/// The object ids `p` gives for `key`, or `None` when `p` leaves it out or gives null. A value that +/// is not an array, or an entry that is not a non-negative integer, is an error with the message +/// [`checked_ids_param`] gives, so no entry is dropped. Whether each id names an object is left to +/// the caller. +pub(crate) fn ids_param(p: &Value, key: &str, cmd: &str) -> Result>> { + let values = match p.get(key) { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(a)) => a, + Some(_) => return Err(bad(cmd, format!("{key} must be an array of non-negative integer object ids"))), + }; + values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, cmd)).collect::>>().map(Some) +} +/// Entry `index` (from 0) of the `key` list as an object id: anything but a non-negative integer is +/// an error naming the key, the entry and its value. +fn id_entry(value: &Value, key: &str, index: usize, cmd: &str) -> Result { + value.as_u64().map(NodeId).ok_or_else(|| bad(cmd, format!("`{key}` entry {index} is {value}, not a non-negative integer object id"))) } /// The objects `p` gives `command`: its `ids`, else its `id` as `ids` of one (as [`targets`] reads /// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error, @@ -447,7 +461,7 @@ pub(crate) fn index(v: u64) -> usize { pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { let values = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?; - values.iter().map(|value| checked_id(s, value, command)).collect() + values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_id(s, value, command))).collect() } /// The objects a command acts on: `ids` when given (every one checked, and those inside another diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index 574003b93..cd30d29d1 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -174,7 +174,7 @@ fn filter(s: &mut Session, p: &Value) -> Result { let label = effects::effect_info(id).map_or("Apply as Filter", |e| e.label.trim_end_matches('…')); let kinds = effects::addons::duotone::swatch_kinds(&s.doc()?.doc); let swatches = |name: &str| kinds.get(name).copied(); - recolor_links(s, &given, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches)) + recolor_links(s, &given, C, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches)) } /// Apply Fill to Stroke (`to_stroke`) or Apply Stroke to Fill: each target copies one of its own diff --git a/crates/engine/src/cmd/addons/color/nearest.rs b/crates/engine/src/cmd/addons/color/nearest.rs index 5a9d0e7c7..0236698be 100644 --- a/crates/engine/src/cmd/addons/color/nearest.rs +++ b/crates/engine/src/cmd/addons/color/nearest.rs @@ -189,7 +189,7 @@ pub(super) fn apply(s: &mut Session, p: &Value) -> Result { let given = json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "fill": fill, "stroke": stroke, "includeImages": false, "includePatterns": false, }); - let r = recolor_links(s, &given, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?; + let r = recolor_links(s, &given, APPLY, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?; squash(s, undo, "Apply Nearest Swatch"); Ok(json!({ "changed": r["changed"], "swatches": names, "added": added })) } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 7f7b34352..02990a662 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -1230,7 +1230,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let selected = if defaults_only { vec![] } else { - match ids_param(p, "ids") { + match ids_param(p, "ids", C)? { Some(v) => outermost(&s.doc()?.doc, v), None => selected_roots(s)?, } diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index d70a81700..221521efb 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { None, "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}", has_selection, - |s, p| recolor(s, p, "Invert Colors", &invert) + |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), cmd!( "edit.colors.toCMYK", @@ -41,7 +41,7 @@ pub fn specs() -> Vec { None, "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", has_selection, - |s, p| recolor(s, p, "Convert to CMYK", &to_cmyk) + |s, p| recolor(s, p, "edit.colors.toCMYK", "Convert to CMYK", &to_cmyk) ), cmd!( "edit.colors.toGrayscale", @@ -50,7 +50,7 @@ pub fn specs() -> Vec { None, "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", has_selection, - |s, p| recolor(s, p, "Convert to Grayscale", &to_gray) + |s, p| recolor(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray) ), cmd!( "edit.colors.toRGB", @@ -60,7 +60,7 @@ pub fn specs() -> Vec { "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", has_selection, |s, p| { - recolor(s, p, "Convert to RGB", &|c| { + recolor(s, p, "edit.colors.toRGB", "Convert to RGB", &|c| { let [r, g, b] = c.to_rgb(); Color::rgb(r, g, b) }) @@ -331,26 +331,27 @@ impl ColorVisitor for Visit<'_, '_> { } } -pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { - run_pass(s, p, label, Recolor::new(Scope::of(p), f)) +pub(crate) fn recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { + run_pass(s, p, cmd, label, Recolor::new(Scope::of(p), f)) } /// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch /// link and tint. -pub(crate) fn recolor_links(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result { - run_pass(s, p, label, Recolor::with_links(Scope::of(p), f, links)) +pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result { + run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p), f, links)) } -/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step. -fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result { - let ids = match ids_param(p, "ids") { +/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`; +/// errors name the command `cmd`. +fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result { + let ids = match ids_param(p, "ids", cmd)? { Some(v) => v, None => selected_roots(s)?, }; let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let n = s.edit(label, |d, _| { let n = pass.run(d, &ids); - super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; Ok(n) })?; Ok(json!({ "changed": n })) @@ -358,7 +359,7 @@ fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result Result { let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32; - recolor(s, p, "Saturate", &move |c| { + recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| { let [h, sat, v] = c.to_hsb(); let to = (sat * (1.0 + i)).clamp(0.0, 1.0); // A color whose saturation stays the same is returned as it is, so it keeps its swatch link. @@ -416,12 +417,12 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result { (*tint, *c) = (t, base.tinted(t)); true }; - return run_pass(s, p, "Adjust Colors", Recolor::links(Scope::of(p), &shift)); + return run_pass(s, p, C, "Adjust Colors", Recolor::links(Scope::of(p), &shift)); } other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))), }; let convert = bool_or(p, "convert", false); - recolor(s, p, "Adjust Colors", &move |c| { + recolor(s, p, C, "Adjust Colors", &move |c| { let out = adjust(c); if convert { out } else { keep_model(c, out) } }) diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 26e6bbc3d..8c1722a60 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -720,7 +720,7 @@ fn a_file_whose_pdf_part_cant_be_read_outlines_its_type_when_asked() { assert!(paths > 0, "the outlines are there: {r}"); } -/// An image too large to read from the editing data of a file whose PDF part can't be read is +/// An image whose data is cut short in the editing data of a file whose PDF part can't be read is /// left out, and both notes say so. #[test] fn a_file_whose_pdf_part_cant_be_read_notes_an_image_left_out() { @@ -729,7 +729,7 @@ fn a_file_whose_pdf_part_cant_be_read_notes_an_image_left_out() { let mut s = Session::new(); let e = open(&mut s, name, &file, json!({"editingData": false})).unwrap_err().to_string(); let r = open(&mut s, name, &file, json!({})).unwrap(); - assert_eq!(r["warnings"], json!([unread_pdf_part(&e), "an image too large to read from the editing data was left out"]), "{r}"); + assert_eq!(r["warnings"], json!([unread_pdf_part(&e), "an image whose data is cut short was left out"]), "{r}"); } /// A copied file whose PDF part can't be read pastes the objects of its editing data when asked diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 6fe0746ac..cf7c9b6b2 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -601,7 +601,7 @@ fn set_current(s: &mut Session, p: &Value) -> Result { fn highlight(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.highlight"; - let mut ids = ids_param(p, "ids").ok_or_else(|| bad(C, "missing ids"))?; + let mut ids = ids_param(p, "ids", C)?.ok_or_else(|| bad(C, "missing ids"))?; ids.truncate(MAX_ROWS); let mode = str_param(p, "mode").unwrap_or("set"); let st = s.doc_mut()?; @@ -740,7 +740,7 @@ enum Place { /// Drag rows in the Layers panel. fn move_rows(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.move"; - let mut ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?; + let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?; ids.truncate(MAX_ROWS); let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?; let place = match str_param(p, "place").unwrap_or("inside") { diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index b4f570582..48e2d1793 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -966,7 +966,7 @@ enum Item { /// The selected objects (or `ids`) as a library graphic (its id and name set by the caller). fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result { let st = s.doc()?; - let ids = match ids_param(p, "ids") { + let ids = match ids_param(p, "ids", cmd)? { Some(ids) => ids, None => st.selection.in_paint_order(&st.doc), }; @@ -1006,7 +1006,7 @@ fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result let given = NewColor { name: color.to_hex(), color, spot: None, tint: 1.0 }; return Ok(spot_color(s, given, swatch, tint)); } - match selected_paint(s, p, stroke)? { + match selected_paint(s, p, stroke, cmd)? { Paint::Solid { color, swatch, tint } => { let swatch = swatch.filter(|n| !n.is_empty()); let name = swatch.clone().unwrap_or_else(|| color.to_hex()); @@ -1029,9 +1029,9 @@ fn spot_color(s: &Session, c: NewColor, swatch: Option, tint: f32) -> Ne /// The first selected object's (or `ids`') fill (`stroke`: stroke) paint (type's: its first /// character's, as the proxy shows it), else the default one. -fn selected_paint(s: &Session, p: &Value, stroke: bool) -> Result { +fn selected_paint(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result { let st = s.doc()?; - let ids = match ids_param(p, "ids") { + let ids = match ids_param(p, "ids", cmd)? { Some(ids) => ids, None => st.selection.objects.clone(), }; @@ -1071,7 +1071,7 @@ fn gradient_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result (None, selected_paint(s, p, stroke)?), + None => (None, selected_paint(s, p, stroke, cmd)?), }; let Paint::Gradient(mut g) = paint else { return Err(bad(cmd, if stroke { "the selection has no gradient stroke" } else { "the selection has no gradient fill" })); @@ -1094,7 +1094,7 @@ fn pattern_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result name.to_string(), _ => return Err(bad(cmd, format!("no pattern swatch `{name}`"))), }, - None => match selected_paint(s, p, stroke)? { + None => match selected_paint(s, p, stroke, cmd)? { Paint::Pattern { pattern, .. } => pattern, _ => return Err(bad(cmd, if stroke { "the selection has no pattern stroke" } else { "the selection has no pattern fill" })), }, diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 28d8be2ee..ff3cb8c87 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1079,7 +1079,7 @@ pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) { fn check(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let ids = ids_param(p, "ids"); + let ids = ids_param(p, "ids", "links.check")?; let (mut missing, mut modified) = (0, 0); let mut links = vec![]; for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) { @@ -1100,7 +1100,7 @@ fn check(s: &mut Session, p: &Value) -> Result { } fn update(s: &mut Session, p: &Value) -> Result { - update_where(s, ids_param(p, "ids"), None) + update_where(s, ids_param(p, "ids", "links.update")?, None) } /// Update Links in the active document of `s` for objects `ids` (default: the linked files that @@ -1225,7 +1225,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { _ => None, }) }; - let mut ids: Vec = match (&each, ids_param(p, "ids")) { + let mut ids: Vec = match (&each, ids_param(p, "ids", C)?) { (Some(each), _) => each.iter().flat_map(|(ids, _)| ids.iter().copied()).collect(), (None, Some(ids)) => ids, (None, None) => { @@ -1603,7 +1603,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { Some(NodeKind::PlacedDocument(_)) => true, _ => false, }; - let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect()); + let ids = ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect()); if let Some(id) = ids.iter().find(|id| !matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_)))) { return Err(bad(C, format!("object {} is not an image or a placed document", id.0))); } @@ -1792,7 +1792,8 @@ fn info(s: &mut Session, p: &Value) -> Result { fn placement_options(s: &mut Session, p: &Value) -> Result { const C: &str = "links.placementOptions"; let st = s.doc()?; - let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect()); + let ids = + ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect()); if let Some(id) = ids.iter().find(|id| placement_of(&st.doc, **id).is_none()) { return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); } diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 7940fc879..ae45f4d97 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -114,7 +114,7 @@ fn apply_transform_moving_guides( let c = xf.as_coeffs(); let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9; let delta = guides.unwrap_or(Vec2::new(c[4], c[5])); - let with_guides = objects && ids_param(p, "ids").is_none() && is_translation; + let with_guides = objects && p.get("ids").is_none_or(Value::is_null) && is_translation; let has_guides = with_guides && !s.doc()?.selection.guides.is_empty(); // A transform of ids that name no object, or an identity transform without a copy, moves // nothing: the document and its undo history stay as they are, and Transform Again repeats it. @@ -254,7 +254,7 @@ pub(super) fn transform(s: &mut Session, p: &Value) -> Result { if p.get("ids").is_none() && crate::cmd::brushsym::selected_part(s)?.is_some() { return Ok(json!({ "warning": crate::cmd::brushsym::part_only().to_string() })); } - let (ids, anchors) = match ids_param(p, "ids") { + let (ids, anchors) = match ids_param(p, "ids", "object.transform")? { Some(v) => (outermost(&s.doc()?.doc, v), false), None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), }; diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index b8b0138c5..9d81576b0 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -210,7 +210,7 @@ fn make(s: &mut Session, p: &Value) -> Result { Some(id) => Err(bad(C, format!("object {} is hidden or locked, or on a locked artboard", id.0))), None => Ok(()), }; - let mut ids = match ids_param(p, "ids") { + let mut ids = match ids_param(p, "ids", C)? { Some(ids) => { if let Some(id) = ids.iter().find(|id| d.node(**id).is_none()) { return Err(EngineError::NoNode(*id)); diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 7cab59481..7906a68e8 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -229,7 +229,7 @@ pub(crate) fn overprint_black(s: &mut Session, p: &Value) -> Result { rich: bool_or(p, "includeCmyBlacks", false), spot: bool_or(p, "includeSpotBlacks", false), }; - let ids = match ids_param(p, "ids") { + let ids = match ids_param(p, "ids", "edit.colors.overprintBlack")? { Some(v) => v, None => selected_roots(s)?, }; diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 19cdcfbbd..a2cd9a14a 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -950,7 +950,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( // The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles. ItemTarget::Top => { let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); - (super::colorcmds::recolor(s, &q, label, f)?, None) + (super::colorcmds::recolor(s, &q, cmd, label, f)?, None) } }; let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? }; diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index fb81e1e49..4cb6880f0 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -597,7 +597,7 @@ pub(super) fn join(s: &mut Session, p: &Value) -> Result { }, }; - if let Some(ids) = ids_param(p, "ids") { + if let Some(ids) = ids_param(p, "ids", C)? { if ends.is_some() && ids.len() > 2 { return Err(bad(C, "`ends` applies to joining exactly two paths")); } @@ -1101,7 +1101,7 @@ fn average_join_ends(s: &Session, p: &Value, c: &str) -> Result let doc = &st.doc; let subpath = |id: NodeId, si: usize| doc.node(id)?.path_data()?.subpaths.get(si).filter(|sp| !sp.closed && !sp.anchors.is_empty()); let last = |sp: &SubPath| sp.anchors.len().saturating_sub(1); - let ids = ids_param(p, "ids"); + let ids = ids_param(p, "ids", c)?; let ends = if ids.is_none() && !st.selection.anchors.is_empty() { let picked: Vec = st.selection.anchors.iter().flat_map(|(&id, set)| set.iter().map(move |&(si, ai)| (id, si, ai))).collect(); match picked.as_slice() { diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 1e7f88d2b..55c4390c2 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -219,8 +219,8 @@ fn ids_json(ids: &[NodeId]) -> Value { json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) } -fn roots(s: &Session, p: &Value) -> Result> { - match ids_param(p, "ids") { +fn roots(s: &Session, p: &Value, cmd: &str) -> Result> { + match ids_param(p, "ids", cmd)? { Some(ids) => { let st = s.doc()?; let mut sel = Selection::default(); @@ -306,7 +306,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result { if editing(s).is_some() { return Err(bad(C, "already in pattern editing mode")); } - let roots = roots(s, p)?; + let roots = roots(s, p, C)?; if roots.is_empty() { return Err(bad(C, "select the art for the pattern tile")); } @@ -665,9 +665,9 @@ fn pattern_transform(s: &mut Session, p: &Value) -> Result { // ---------- Repeat ---------- /// Make a repeat from the selection, or switch the selected repeat's kind. -fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec>) -> RepeatSpec) -> Result { +fn make_repeat(s: &mut Session, p: &Value, cmd: &str, label: &str, make: impl Fn(Vec>) -> RepeatSpec) -> Result { let existing = selected_repeats(s); - if ids_param(p, "ids").is_none() + if ids_param(p, "ids", cmd)?.is_none() && let [r] = existing.as_slice() { let r = *r; @@ -680,7 +680,7 @@ fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec Result { return Err(bad("object.repeat.radial", "instances must be 1..1000")); } let radius = p.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r >= 0.0); - make_repeat(s, p, "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius)) + make_repeat(s, p, "object.repeat.radial", "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius)) } fn repeat_grid(s: &mut Session, p: &Value) -> Result { let (hs, vs) = (p.get("hSpacing").and_then(Value::as_f64), p.get("vSpacing").and_then(Value::as_f64)); let rows = p.get("rows").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32); let cols = p.get("cols").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32); - make_repeat(s, p, "Grid Repeat", |src| { + make_repeat(s, p, "object.repeat.grid", "Grid Repeat", |src| { let b = vectorcraft_doc::live::nodes_bounds(&src).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0)); let mut r = RepeatSpec::grid(src, hs.unwrap_or(b.width() / 4.0), vs.unwrap_or(b.height() / 4.0)); if let RepeatKind::Grid { rows: rr, cols: cc, .. } = &mut r.kind { @@ -720,7 +720,7 @@ fn repeat_grid(s: &mut Session, p: &Value) -> Result { fn repeat_mirror(s: &mut Session, p: &Value) -> Result { let angle = f64_or(p, "angle", 90.0); let offset = f64_or(p, "offset", 10.0); - make_repeat(s, p, "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset)) + make_repeat(s, p, "object.repeat.mirror", "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset)) } fn repeat_release(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 838327cf5..d7dba8bb0 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -25,7 +25,10 @@ //! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a //! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its //! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log -//! under its id. +//! under its id. One that lists `settings` or `cache` may keep settings and a cache of its own +//! (`vc_settings_*`, `vc_cache_*`), in the plug-in store under its id ([`crate::store`]): +//! `plugin.clearCache` empties the cache, `plugin.list` and `plugin.info` give its size +//! (`cacheBytes`), replacing the plug-in keeps both and `plugin.remove` deletes them. use std::sync::{Arc, Mutex}; @@ -148,7 +151,7 @@ impl FilterRun { let st = s.doc()?; // While an object of a dynamic symbol instance's art is selected, the selection is refused, // as `selected_roots` refuses it. - let selected = match ids_param(p, "ids") { + let selected = match ids_param(p, "ids", C)? { Some(ids) => ids, None => { super::edit::refuse_symbol_part(s)?; @@ -352,13 +355,27 @@ fn grant_param(c: &str, p: &Value) -> Result>> { Ok(Some(out)) } +/// Would installing `plugin` take another module's place: one installed with its id and other +/// bytes, or, with none installed, the settings and cache the plug-in store keeps for its id? +fn takes_over(s: &Session, plugin: &Plugin) -> bool { + match registry::get(plugin.id()) { + Some(installed) => installed.hash() != plugin.hash(), + None => s.has_plugin_storage(plugin.id()), + } +} + /// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless -/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id -/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant -/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. +/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted its id +/// unless `grant` lists them all, one that would take another module's place ([`takes_over`]) +/// unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records the grant with +/// the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "plugin.install"; - let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?); + let replace = match p.get("replace") { + None | Some(Value::Null) => None, + Some(_) => Some(switch_or(p, "replace", false, C)?), + }; + let allow_heavy = switch_or(p, "allowHeavy", false, C)?; let grant = grant_param(C, p)?; let (plugin, bytes) = load_given(C, p)?; // `hash` pins the module the user was shown: one with other bytes is refused. @@ -367,9 +384,21 @@ fn install(s: &mut Session, p: &Value) -> Result { { return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed")); } - if !replace && registry::get(plugin.id()).is_some() { + if replace == Some(false) && registry::get(plugin.id()).is_some() { return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); } + // Another module under the id gets the settings and cache kept for it: whoever installs it asks + // the user first. + if replace != Some(true) && takes_over(s, &plugin) { + let name = permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME); + return Err(bad( + C, + format!( + "“{name}” is another module than the plug-in with id {:?} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees", + plugin.id() + ), + )); + } // A heavy plug-in is installed once the user agrees: whoever installs it asks them first. if plugin.manifest().budget == Budget::Heavy && !allow_heavy { let (minutes, times) = vectorcraft_plugins::heavy_terms(); @@ -382,7 +411,8 @@ fn install(s: &mut Session, p: &Value) -> Result { )); } // A plug-in that lists permissions installs once the user grants them: whoever installs it - // asks them first, unless they already granted its id all of them. + // asks them first, unless they already granted all of them (with a plug-in store, to this + // module). let m = plugin.manifest(); let wanted = m.permissions(); if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) { @@ -395,8 +425,9 @@ fn install(s: &mut Session, p: &Value) -> Result { registry::grant(g.clone()); s.store_grant(&m.id, Some(g)); } - // Installed plug-ins stay installed after a restart (#2541). + // Installed plug-ins stay installed after a restart (#2541), with their settings and cache. let stored = s.store_plugin(plugin.id(), &bytes); + s.attach_plugin_storage(plugin.id()); // Installing a disabled plug-in again (an updated version) turns it on for the next launch too, // also when it is disabled by its stored file alone (it stopped a launch while loading). When // the new version could not replace the stored file, the old one stays disabled. @@ -411,15 +442,17 @@ fn install(s: &mut Session, p: &Value) -> Result { if changed { s.store_disabled_plugins(); } - Ok(describe(®istry::install(plugin))) + Ok(describe_installed(®istry::install(plugin))) } -/// `plugin.inspect`: what installing the module would install, without installing it, and whether -/// that needs the user to grant its permissions first (`needsApproval`). +/// `plugin.inspect`: what installing the module would install, without installing it, whether that +/// needs the user to grant its permissions first (`needsApproval`) and whether it would take +/// another module's place (`needsReplace`). fn inspect(s: &mut Session, p: &Value) -> Result { load_given("plugin.inspect", p).map(|(plugin, _)| { let mut v = describe(&plugin); v["needsApproval"] = json!(!s.plugin_approved(&plugin)); + v["needsReplace"] = json!(takes_over(s, &plugin)); v }) } @@ -490,6 +523,9 @@ fn load_folder(s: &Session, folder: &str) -> Value { }; match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) { Ok(r) => { + for id in &r.loaded { + s.attach_plugin_storage(id); + } if r.disabled_changed { s.store_disabled_plugins(); } @@ -521,11 +557,18 @@ fn reload(s: &mut Session, p: &Value) -> Result { Ok(report) } +/// [`describe`] for an installed plug-in, with the bytes its cache holds (`cacheBytes`). +fn describe_installed(p: &Plugin) -> Value { + let mut v = describe(p); + v["cacheBytes"] = json!(registry::storage(p.id()).map_or(0, |st| st.cache_bytes())); + v +} + fn list(_: &mut Session, _: &Value) -> Result { let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); let disabled: Vec = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect(); Ok( - json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), + json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), ) } @@ -580,7 +623,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { fn info(_: &mut Session, p: &Value) -> Result { let plugin = plugin_by_id("plugin.info", p)?; - let mut v = describe(&plugin); + let mut v = describe_installed(&plugin); v["manifest"] = serde_json::to_value(plugin.manifest()).unwrap_or(Value::Null); v["defaults"] = Value::Object(plugin.manifest().defaults()); v["lastError"] = json!(effect::last_error(plugin.id())); @@ -589,15 +632,48 @@ fn info(_: &mut Session, p: &Value) -> Result { fn remove(s: &mut Session, p: &Value) -> Result { let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?; + let installed = registry::get(id).is_some(); + // Settings and cache the store keeps for an id that isn't installed are deleted too. + let stored = !installed && vectorcraft_plugins::manifest::valid_id(id) && s.has_plugin_storage(id); + // Its settings and cache go first: when they can't be moved aside, the plug-in stays installed, + // as it was, and a second `plugin.remove` tries again. + if (installed || stored) + && let Err(e) = s.remove_plugin_storage(id) + { + if installed { + s.attach_plugin_storage(id); + } + return Err(EngineError::Other(e.to_string())); + } // Installed again, it asks again. registry::revoke(id); s.store_grant(id, None); s.unstore_plugin(id); - if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) } + if !stored && (!installed || !registry::remove(id)) { + return Err(err(vectorcraft_plugins::Error::NotFound(id.into()))); + } + Ok(json!({"removed": id})) +} + +/// `plugin.clearCache`: empties the cache of plug-in `id`, else of every installed plug-in → +/// `{cleared: [id], bytesFreed}`. Settings stay. +fn clear_cache(_: &mut Session, p: &Value) -> Result { + let plugins = match p.get("id").filter(|v| !v.is_null()) { + Some(_) => vec![plugin_by_id("plugin.clearCache", p)?], + None => registry::list(), + }; + let (mut cleared, mut freed) = (Vec::new(), 0_u64); + for plugin in plugins { + if let Some(st) = registry::storage(plugin.id()) { + freed += st.clear_cache().map_err(|e| EngineError::Other(e.to_string()))?; + } + cleared.push(plugin.id().to_string()); + } + Ok(json!({"cleared": cleared, "bytesFreed": freed})) } /// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove`, -/// `.reload`, `.safeMode` and `.setEnabled`. +/// `.reload`, `.clearCache`, `.safeMode` and `.setEnabled`. pub fn specs() -> Vec { vec![ cmd!( @@ -605,7 +681,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -632,7 +708,7 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count, each for the module with its hash alone), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", always, install ), @@ -641,11 +717,28 @@ pub fn specs() -> Vec { "Inspect Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)", + "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)", always, inspect ), - cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove), + cmd!( + query "plugin.remove", + "Remove Plug-in", + [], + None, + "{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, the command fails and the plug-in stays installed); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}", + always, + remove + ), + cmd!( + query "plugin.clearCache", + "Clear Plug-in Cache", + [], + None, + "{id?} delete every entry in the cache of plug-in id (default: of every installed plug-in); its settings stay → {cleared: [id], bytesFreed}", + always, + clear_cache + ), cmd!( query "plugin.reload", "Reload Plug-ins", diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index f52c26a1c..5792c44fd 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -204,7 +204,7 @@ impl Session { /// The slices a command acts on: `slices` (ids that must all be slices), else the selected ones. fn slice_targets(s: &Session, p: &Value, cmd: &str) -> Result> { let st = s.doc()?; - match ids_param(p, "slices") { + match ids_param(p, "slices", cmd)? { Some(ids) => match ids.iter().find(|id| !st.doc.is_slice(**id)) { Some(id) => Err(bad(cmd, format!("{id} is not a slice"))), None => Ok(dedup(ids)), @@ -290,7 +290,7 @@ pub(crate) fn options_json(o: &SliceOptions) -> Value { // ---------- Object → Slice ---------- fn make(s: &mut Session, p: &Value) -> Result { - let ids = match ids_param(p, "ids") { + let ids = match ids_param(p, "ids", "object.slice.make")? { Some(ids) => super::edit::outermost(&s.doc()?.doc, ids), None => super::edit::selected_roots(s)?, }; @@ -734,7 +734,8 @@ fn delete(s: &mut Session, p: &Value) -> Result { } fn select(s: &mut Session, p: &Value) -> Result { - let ids = slice_targets(s, &json!({ "slices": p.get("slices").cloned().unwrap_or(json!([])) }), "object.slice.select")?; + let slices = p.get("slices").filter(|v| !v.is_null()).cloned().unwrap_or(json!([])); + let ids = slice_targets(s, &json!({ "slices": slices }), "object.slice.select")?; let toggle = bool_or(p, "toggle", false); s.select(|_, sel| { let mut now = if toggle { sel.slices.clone() } else { vec![] }; diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index de262cf2b..a3745975b 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -65,7 +65,8 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.pattern_edit.is_some() { return Err(bad(C, "finish editing the pattern first")); } - let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty())); + let ids = super::ids_param(p, "ids", C)?; + let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| ids.filter(|v| !v.is_empty())); let (name, xf, instance) = match given { Some(ids) => { let symbols = ids.iter().map(|id| instance_of(&st.doc, *id)).collect::>>(); diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 2224ddccc..9fcb7cd4c 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -121,13 +121,14 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[ /// The non-empty `ids` given to a command whose params description names `ids` as a key (the /// key callers outside the app may pass it, [`cmd::params::takes_key`]), or its `id` as `ids` of /// one when no `ids` are given and the description takes `id` so -/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them. +/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them. Entries of `ids` that are +/// not ids are left out of this list; `Session::execute` refuses them before it reads it. pub(crate) fn given_ids(description: &'static str, params: &Value) -> Option> { if !cmd::params::takes_key(description, "ids") { return None; } match params.get("ids").filter(|v| !v.is_null()) { - Some(_) => cmd::ids_param(params, "ids"), + Some(v) => v.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()), None => cmd::id_param(params, "id").filter(|_| cmd::params::id_stands_for_ids(description)).map(|id| vec![id]), } .filter(|ids| !ids.is_empty()) diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 71f90e7e4..352a267b4 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -15,11 +15,14 @@ impl Session { r } - /// Error, under the command's own id, at the first of `ids` that is malformed, names no node, or - /// names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on objects. + /// Error, under the command's own id, at the first of `ids` that is malformed, else at the first + /// that names no node or names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on + /// objects. fn check_given_ids(&self, id: &str, params: &Value) -> Result<()> { let (Some(st), Some(ids)) = (self.active(), params.get("ids")) else { return Ok(()) }; let Some(given) = ids.as_array() else { return cmd::checked_ids_param(self, params, "ids", id).map(drop) }; + // A malformed entry anywhere in the list is refused first, under the key and the entry. + cmd::ids_param(params, "ids", id)?; for value in given { let node = cmd::checked_id(self, value, id)?; if st.doc.node(node).is_some_and(|n| n.is_layer()) { @@ -65,6 +68,13 @@ impl Session { if SELECTS_GIVEN_IDS.contains(&id) { self.check_given_ids(id, params)?; } + // An `ids` list with an entry that is not an object id, or an `ids` that is not a list, is + // refused under the command's id before the menu check, which would otherwise run on the + // other entries or on the selection. This holds with no document open too, where commands + // such as `effect.list` still run. + if cmd::params::takes_key(spec.params, "ids") { + cmd::ids_param(params, "ids", id)?; + } // Clear's menu needs a selection; an agent supplying explicit targets only needs a // document. The command validates those targets before making any edit. let given = given_ids(spec.params, params); diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index c748240d1..27b9ad8ac 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -19,6 +19,11 @@ //! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins //! stored there that list permissions (id, module hash, permissions); a stored plug-in whose //! permissions it doesn't grant is skipped with a log record, and stays in the folder. +//! * **Plug-in settings and caches**: `storage/id-/` in the plug-in store, the id written as +//! [`case_safe`] writes it, holds what a plug-in +//! keeps with the `settings` and `cache` permissions ([`PluginStorage`]): `settings.json` and a +//! `cache` folder with one file per entry. The host names every file; a plug-in only names keys. +//! Replacing a plug-in keeps them, and `plugin.remove` deletes them. //! //! The files are untrusted input like any other: sizes and counts are capped, a file that does not //! parse is skipped with a log record, and every write is atomic. @@ -26,9 +31,12 @@ use std::collections::BTreeMap; use std::io::Read; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use vectorcraft_pdf::{PdfPreset, builtin_preset}; use vectorcraft_plugins::safe_mode; +use vectorcraft_plugins::storage::{self, Meter, Storage, StorageError}; use crate::Session; use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile}; @@ -55,6 +63,25 @@ pub const DISABLED_LOCK: &str = "disabled.lock"; const GRANTS_FILE: &str = "grants.json"; /// The largest grants file read. const MAX_GRANTS_FILE: u64 = 1 << 20; +/// The folder in the plug-in store that holds each plug-in's settings and cache. +const PLUGIN_DATA_DIR: &str = "storage"; +/// A plug-in's settings file, in its folder. +const SETTINGS_FILE: &str = "settings.json"; +/// Where a settings file that doesn't read is moved, replacing the one moved there before. +const BAD_SETTINGS_FILE: &str = "settings.unreadable.json"; +/// The folder of a plug-in's cache entries, in its folder. +const CACHE_DIR: &str = "cache"; +/// The extension of a cache entry's file. +const CACHE_EXT: &str = "bin"; +/// Items in a cache folder a write looks at, at most: the entries, and temporary files a write left. +/// A write to a folder that holds more is refused as full. +pub(crate) const MAX_CACHE_SCAN: usize = storage::MAX_CACHE_ENTRIES * 2; +/// Held while a plug-in's settings or cache change, so two runs can't pass a cap together or lose +/// each other's settings. +static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); +/// The closed flag of each plug-in folder's storage, shared by every storage made for the folder +/// until the plug-in is removed, which sets it and drops it. +static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name @@ -262,7 +289,8 @@ impl Session { /// Install the plug-ins kept in the plug-in folder: none in Safe Mode, and none the user /// disabled (matched by file name, which is made from the id). The launch marker names each /// file while it loads. A module that doesn't load, and one that lists permissions the grants - /// there don't grant it, is skipped with a log record. + /// there don't grant it, is skipped with a log record. A grant covers only the module it was + /// given to ([`grant_covers`]). pub fn load_stored_plugins(&mut self) { let Some(dir) = self.plugin_dir.clone() else { return }; if safe_mode::active() { @@ -294,15 +322,19 @@ impl Session { // module stored here load. Ok(plugin) if plugin.manifest().asks_permissions() => { let m = plugin.manifest(); - match grants.get(&m.id).filter(|g| g.covers(&m.permissions())) { + match grants.get(&m.id).filter(|g| grant_covers(g, &plugin)) { Some(g) => { vectorcraft_plugins::registry::grant(g.clone()); + vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id)); vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); } - None => log::warn!("skipping the stored plug-in {}: the user hasn't granted the permissions it lists", path.display()), + None => { + log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display()) + } } } Ok(plugin) => { + vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id())); vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); } Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()), @@ -345,8 +377,9 @@ impl Session { } /// Whether `plugin` installs without asking the user: it lists no permissions, or its id was - /// granted every one it lists, in the grants of the attached plug-in store when there is one, - /// else in this process's ([`vectorcraft_plugins::registry::approved`]). + /// granted every one it lists, in the grants of the attached plug-in store when there is one + /// (for this module alone, [`grant_covers`]), else in this process's + /// ([`vectorcraft_plugins::registry::approved`]). pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool { self.plugin_approver()(plugin) } @@ -358,7 +391,7 @@ impl Session { move |plugin| { let m = plugin.manifest(); match &grants { - Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.permissions())), + Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| grant_covers(g, plugin)), _ => vectorcraft_plugins::registry::approved(plugin), } } @@ -422,6 +455,379 @@ impl Session { take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id))); } } + + /// Keep the settings and cache of plug-in `id` in the attached plug-in store from now on; without + /// one, nowhere (its storage calls then fail as unavailable). + pub(crate) fn attach_plugin_storage(&self, id: &str) { + vectorcraft_plugins::registry::set_storage(id, plugin_storage(self.plugin_dir.as_deref(), id)); + } + + /// Does the attached plug-in store have a folder of settings and cache for plug-in `id`? A + /// folder behind a `storage` folder that is a link is not counted: the plug-in's storage calls + /// can't reach it and removing the plug-in leaves it. + pub(crate) fn has_plugin_storage(&self, id: &str) -> bool { + self.plugin_dir + .as_deref() + .is_some_and(|dir| !is_link(&dir.join(PLUGIN_DATA_DIR)) && std::fs::symlink_metadata(plugin_data_dir(dir, id)).is_ok()) + } + + /// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it. + /// The folder is first renamed out of the way (`.removed`), and that fails, leaving it + /// as it was, when the rename does; what the deletion then leaves stays under that name, with a + /// log record, and is deleted at the plug-in's next removal. + pub(crate) fn remove_plugin_storage(&self, id: &str) -> Result<(), StorageError> { + vectorcraft_plugins::registry::set_storage(id, None); + let Some(dir) = &self.plugin_dir else { return Ok(()) }; + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let path = plugin_data_dir(dir, id); + // Every storage handed out for the folder, including those runs still hold, is closed once + // the folder is out of the way; until then they keep working. + let close = || { + if let Some(closed) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) { + closed.store(true, Ordering::Release); + } + }; + // A `storage` folder that is a link leads out of the store: nothing there is deleted. A link + // in place of the plug-in's own folder is removed itself (`remove_dir_all` doesn't follow it). + let data = dir.join(PLUGIN_DATA_DIR); + if is_link(&data) { + log::warn!("{} is a link: the plug-in settings and cache it leads to are left", data.display()); + close(); + return Ok(()); + } + let mut aside = path.clone().into_os_string(); + aside.push(".removed"); + let aside = PathBuf::from(aside); + let _ = std::fs::remove_dir_all(&aside); + match std::fs::rename(&path, &aside) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(StorageError::Io(format!("{}: {e}", path.display()))), + Ok(()) => {} + } + close(); + if std::fs::symlink_metadata(&aside).is_ok() + && let Err(e) = std::fs::remove_dir_all(&aside) + { + log::warn!("can't delete {}: {e}", aside.display()); + } + Ok(()) + } +} + +/// `name` (a plug-in id or a cache entry name, ASCII) written for a file name that no other name +/// gets on a file system that ignores case: `_` is written `__`, each capital letter `_` and the +/// letter in lower case, and `.` `_0`. +fn case_safe(name: &str) -> String { + let mut file = String::with_capacity(name.len() * 2); + for c in name.chars() { + match c { + '_' => file.push_str("__"), + '.' => file.push_str("_0"), + c if c.is_ascii_uppercase() => { + file.push('_'); + file.push(c.to_ascii_lowercase()); + } + c => file.push(c), + } + } + file +} + +/// The folder of plug-in `id`'s settings and cache in the plug-in store `dir`. The `id-` in front +/// keeps an id such as `con` from naming a device on Windows. +pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf { + dir.join(PLUGIN_DATA_DIR).join(format!("id-{}", case_safe(id))) +} + +/// Where plug-in `id` keeps its settings and cache in the plug-in store `dir`. +fn plugin_storage(dir: Option<&Path>, id: &str) -> Option> { + let dir = dir?; + let data = plugin_data_dir(dir, id); + let closed = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone(); + Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, closed })) +} + +/// The settings and cache of one plug-in, in its folder of the plug-in store (see the module docs). +/// Keys and entry names are [`storage::valid_name`]s; an entry's file name is its name as +/// [`case_safe`] writes it, so that names differing only in case get different files. A link +/// anywhere in the folder's path below the store is refused, and so is a cache file that is not a +/// plain file. +pub(crate) struct PluginStorage { + /// The plug-in store, and the plug-in's folder in it. + root: PathBuf, + dir: PathBuf, + /// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed; shared by every storage of the + /// folder ([`PLUGIN_DATA_OPEN`]). + closed: Arc, +} + +/// The sizes of `files` added up, at most `u64::MAX`: the sizes come from the file system. +fn sum_sizes(files: &[(PathBuf, u64)]) -> u64 { + files.iter().fold(0, |sum, (_, n)| sum.saturating_add(*n)) +} + +/// Is `name` the file name of a cache entry? Entry files end in `.bin` and never start with a `.` +/// ([`case_safe`] writes no `.`), while the temporary files of `write_atomic` do. +fn is_entry_file(name: &std::ffi::OsStr) -> bool { + name.to_str().is_some_and(|n| !n.starts_with('.') && n.ends_with(&format!(".{CACHE_EXT}"))) +} + +/// What a storage call is told when the file system fails. +fn io(e: &std::io::Error) -> StorageError { + StorageError::Io(e.to_string()) +} + +/// Does grant `g` cover `plugin`: was it given to this module (the same SHA-256) for every +/// permission the module lists? A module with other bytes under the id needs the user's approval +/// again, and so doesn't get the settings and cache kept for the id without it, and so does every +/// module when the grant records no hash. +fn grant_covers(g: &vectorcraft_plugins::permissions::Grant, plugin: &vectorcraft_plugins::Plugin) -> bool { + !g.hash.is_empty() && g.hash == plugin.hash() && g.covers(&plugin.manifest().permissions()) +} + +/// Is `path` a link (a symbolic link or, on Windows, a junction)? +fn is_link(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink() || reparse_point(&m)) +} + +#[cfg(windows)] +fn reparse_point(m: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn reparse_point(_: &std::fs::Metadata) -> bool { + false +} + +impl PluginStorage { + fn settings_path(&self) -> PathBuf { + self.dir.join(SETTINGS_FILE) + } + + fn cache_dir(&self) -> PathBuf { + self.dir.join(CACHE_DIR) + } + + /// The file of the cache entry `name`. + fn entry_path(&self, name: &str) -> PathBuf { + self.cache_dir().join(format!("{}.{CACHE_EXT}", case_safe(name))) + } + + /// Checks that no folder between the store and `path`, nor `path` itself, is a link. + fn no_links(&self, path: &Path) -> Result<(), StorageError> { + let mut at = path; + while at != self.root { + if is_link(at) { + return Err(StorageError::Unavailable); + } + match at.parent() { + Some(up) => at = up, + None => break, + } + } + Ok(()) + } + + /// The settings, by key. A file that is too large or doesn't parse is moved aside + /// ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is charged the + /// file's size before it is read. + fn read_settings(&self, meter: &mut Meter) -> Result>, StorageError> { + let path = self.settings_path(); + self.no_links(&path)?; + let size = match std::fs::metadata(&path) { + Ok(m) => m.len(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(e) => return Err(io(&e)), + }; + let readable = size <= storage::MAX_SETTINGS_BYTES as u64; + if readable { + meter.take(size)?; + } + let parsed = readable + .then(|| read_capped(&path, storage::MAX_SETTINGS_BYTES as u64).ok()) + .flatten() + .and_then(|bytes| serde_json::from_slice::>(&bytes).ok()) + .and_then(|stored| { + let decode = + |(k, v): (String, String)| if storage::valid_name(&k) { vectorcraft_format::base64_decode(&v).map(|v| (k, v)) } else { None }; + let settings: Option>> = stored.into_iter().map(decode).collect(); + settings.filter(|s| s.len() <= storage::MAX_SETTINGS && s.values().all(|v| v.len() <= storage::MAX_SETTING_BYTES)) + }); + match parsed { + Some(settings) => Ok(settings), + None => { + let aside = self.dir.join(BAD_SETTINGS_FILE); + log::warn!("the plug-in settings {} don't read: moved to {} and started empty", path.display(), aside.display()); + std::fs::rename(&path, &aside).map_err(|e| io(&e))?; + Ok(BTreeMap::new()) + } + } + } + + /// Writes `settings` as the settings file, or removes it when there are none. `meter` is charged + /// the new file's size before it is written. + fn write_settings(&self, settings: &BTreeMap>, meter: &mut Meter) -> Result<(), StorageError> { + let path = self.settings_path(); + if settings.is_empty() { + return match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(io(&e)), + _ => Ok(()), + }; + } + let stored: BTreeMap<&str, String> = settings.iter().map(|(k, v)| (k.as_str(), vectorcraft_format::base64_encode(v))).collect(); + let bytes = serde_json::to_vec(&stored).map_err(|e| StorageError::Io(e.to_string()))?; + if bytes.len() > storage::MAX_SETTINGS_BYTES { + return Err(StorageError::Full); + } + meter.take(bytes.len() as u64)?; + std::fs::create_dir_all(&self.dir).map_err(|e| io(&e))?; + self.no_links(&path)?; + write_atomic(&path, &bytes).map_err(|e| io(&e)) + } + + /// Fails with [`StorageError::Unavailable`] once the storage is closed. + fn open(&self) -> Result<(), StorageError> { + if self.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) } + } + + /// The entries of the cache folder (plain files named as [`Self::entry_path`] names them, which + /// leaves out the temporary files of writes) and their sizes, and whether the folder holds more + /// than [`MAX_CACHE_SCAN`] items, in which case only the entries among the first that many are + /// listed. + fn cache_files(&self) -> Result<(Vec<(PathBuf, u64)>, bool), StorageError> { + let dir = self.cache_dir(); + self.no_links(&dir)?; + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((vec![], false)), + Err(e) => return Err(io(&e)), + }; + let mut files = Vec::new(); + for (seen, e) in entries.enumerate() { + if seen == MAX_CACHE_SCAN { + return Ok((files, true)); + } + let e = e.map_err(|e| io(&e))?; + if is_entry_file(&e.file_name()) + && let Ok(m) = std::fs::symlink_metadata(e.path()) + && m.is_file() + && !reparse_point(&m) + { + files.push((e.path(), m.len())); + } + } + Ok((files, false)) + } +} + +impl Storage for PluginStorage { + fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + Ok(self.read_settings(meter)?.remove(key)) + } + + fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let mut settings = self.read_settings(meter)?; + if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) { + return Err(StorageError::Full); + } + settings.insert(key.to_string(), value.to_vec()); + self.write_settings(&settings, meter) + } + + fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let mut settings = self.read_settings(meter)?; + if settings.remove(key).is_none() { + return Ok(false); + } + self.write_settings(&settings, meter).map(|()| true) + } + + fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let path = self.entry_path(name); + self.no_links(&path)?; + match std::fs::symlink_metadata(&path) { + Ok(m) if m.is_file() && m.len() <= storage::MAX_CACHE_ENTRY as u64 => meter.take(m.len())?, + Ok(_) => return Err(StorageError::Unavailable), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(io(&e)), + } + read_capped(&path, storage::MAX_CACHE_ENTRY as u64).map(Some).map_err(StorageError::Io) + } + + fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let path = self.entry_path(name); + let (files, overfull) = self.cache_files()?; + if overfull { + return Err(StorageError::Full); + } + let old = files.iter().find(|(p, _)| *p == path).map(|(_, n)| *n); + let total = sum_sizes(&files).saturating_sub(old.unwrap_or(0)).saturating_add(value.len() as u64); + let too_many = old.is_none() && files.len() >= storage::MAX_CACHE_ENTRIES; + if value.len() > storage::MAX_CACHE_ENTRY || total > storage::MAX_CACHE_BYTES || too_many { + return Err(StorageError::Full); + } + meter.take(value.len() as u64)?; + std::fs::create_dir_all(self.cache_dir()).map_err(|e| io(&e))?; + self.no_links(&path)?; + write_atomic(&path, value).map_err(|e| io(&e)) + } + + fn remove_cache_entry(&self, name: &str) -> Result { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let path = self.entry_path(name); + self.no_links(&path)?; + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(io(&e)), + } + } + + fn clear_cache(&self) -> Result { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let dir = self.cache_dir(); + self.no_links(&dir)?; + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(io(&e)), + }; + // Every plain file, one at a time as the folder is read, past the ones a write looks at too. + let mut freed = 0_u64; + for e in entries { + let path = e.map_err(|e| io(&e))?.path(); + if let Ok(m) = std::fs::symlink_metadata(&path) + && m.is_file() + && !reparse_point(&m) + { + std::fs::remove_file(&path).map_err(|e| io(&e))?; + freed = freed.saturating_add(m.len()); + } + } + Ok(freed) + } + + fn cache_bytes(&self) -> u64 { + if self.open().is_err() { + return 0; + } + self.cache_files().map(|(files, _)| sum_sizes(&files)).unwrap_or(0) + } } /// What the grants file of a plug-in store holds. diff --git a/crates/engine/src/tests/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs index f8449c104..8e26577f9 100644 --- a/crates/engine/src/tests/ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -135,8 +135,9 @@ fn id_does_what_ids_of_one_does_with_nothing_selected() { /// Every command that takes `id` as `ids` of one ([`cmd::params::id_stands_for_ids`]), with an /// object selected: an `id` that is malformed (a string, negative, a fraction, an object) or names -/// no object is refused under the command's id, as the same value in `ids` is, and the document -/// and the selection stay as they were. It never stands for the selection instead. (A string `id` +/// no object is refused under the command's id, as the same value in `ids` is (whose error for a +/// malformed entry also names the key and the entry), and the document and the selection stay as +/// they were. It never stands for the selection instead. (A string `id` /// given to `effect.apply` without `effect` names the effect, which "bad" isn't: refused too.) #[test] fn a_bad_id_is_refused_as_the_same_ids_are_and_changes_nothing() { @@ -158,7 +159,10 @@ fn a_bad_id_is_refused_as_the_same_ids_are_and_changes_nothing() { let st = s.doc().unwrap(); (r, st.doc == before && st.selection.objects == vec![NodeId(d)]) }; - let (with_id, with_ids) = (run("id", bad.clone()), run("ids", json!([bad.clone()]))); + let (with_id, mut with_ids) = (run("id", bad.clone()), run("ids", json!([bad.clone()]))); + // The error for an `ids` entry names the key and the entry; the one for `id` names the value. + let entry = format!("`ids` entry 0 is {bad}, not a non-negative integer object id"); + with_ids.0 = with_ids.0.map_err(|e| e.replace(&entry, &format!("invalid object id {bad}: expected a non-negative integer"))); let effect_named = spec.id == "effect.apply" && bad.is_string(); if with_id.0.is_ok() || !with_id.1 || (with_id != with_ids && !effect_named) { failures.push(format!( @@ -301,3 +305,205 @@ fn ids_of_an_object_and_a_group_that_holds_it_do_what_that_selection_does() { assert!(failures.is_empty(), "{} differ:\n{}", failures.len(), failures.join("\n")); assert!(succeeded >= 150, "only {succeeded} runs succeeded"); } + +/// What `ids` (or `slices`) left out or null gives a command: the selection, or the command's own +/// default. +enum Unset { + /// The selected objects: a result, the same as the list of the selection gives. + Selected, + /// The selected objects, which the command refuses with this error, as it refuses the list of + /// the selection. + SelectionRefused(&'static str), + /// This error, which the list of the selection does not give. + Fails(&'static str), + /// This result, which the list of the selection does not give. + Gives(serde_json::Value), +} + +/// The commands that read their `ids` (or `slices`) list themselves or pass it to a colour pass, +/// with the rectangle apart (`d`): the key, the other params, and what leaving the key out gives. +fn ids_cases(d: u64) -> Vec<(&'static str, &'static str, serde_json::Value, Unset)> { + use Unset::*; + vec![ + ("appearance.copyFrom", "ids", json!({ "source": d }), Selected), + ("edit.colors.invert", "ids", json!({}), Selected), + ("edit.colors.toCMYK", "ids", json!({}), Selected), + ("edit.colors.toGrayscale", "ids", json!({}), Selected), + ("edit.colors.toRGB", "ids", json!({}), Selected), + ("edit.colors.saturate", "ids", json!({"intensity": 50}), Selected), + ("edit.colors.adjustBalance", "ids", json!({"r": 20}), Selected), + ("paint.invert", "ids", json!({}), Selected), + ("paint.complement", "ids", json!({}), Selected), + ("addon.color.filter", "ids", json!({"effect": "addon.color.invert"}), Selected), + ("addon.color.applyNearestSwatch", "ids", json!({}), Selected), + ("edit.colors.overprintBlack", "ids", json!({}), Selected), + ("edit.findReplace", "ids", json!({"find": "A", "replace": "B"}), Gives(json!({"count": 1}))), + ("layer.highlight", "ids", json!({}), Fails("missing ids")), + ("layer.move", "ids", json!({ "target": d }), Fails("missing ids")), + ("library.add", "ids", json!({"kind": "graphic"}), Selected), + ("library.add", "ids", json!({"kind": "fillColor"}), Selected), + ("links.check", "ids", json!({}), Selected), + ("links.update", "ids", json!({}), Selected), + ("links.relink", "ids", json!({"path": "missing.png"}), Fails("select the images to relink, or pass ids")), + ("links.embed", "ids", json!({}), Fails("select the linked images or placed documents, or pass ids")), + ("links.placementOptions", "ids", json!({}), Fails("select the images, or pass ids")), + ("object.transform", "ids", json!({"matrix": [1, 0, 0, 1, 10, 0]}), Selected), + ("object.pattern.make", "ids", json!({"name": "P"}), Selected), + ("object.repeat.radial", "ids", json!({}), Selected), + ("object.repeat.grid", "ids", json!({}), Selected), + ("object.repeat.mirror", "ids", json!({}), Selected), + ("object.slice.make", "ids", json!({}), Selected), + ("object.slice.move", "slices", json!({"dx": 1, "dy": 1}), Selected), + ("object.slice.delete", "slices", json!({}), Selected), + ("object.slice.select", "slices", json!({}), Gives(json!({"selected": []}))), + ("path.join", "ids", json!({}), SelectionRefused("path is already closed")), + ("path.averageJoin", "ids", json!({"to": "corner"}), SelectionRefused("select two open path ends, or two open paths, to average and join")), + ("symbol.edit", "ids", json!({}), Fails("the document has no symbols")), + ("transparency.makeOpacityMask", "ids", json!({}), Selected), + ] +} + +/// What `cmd` does in a fresh [`doc_with_type`] with `selected` selected (made an object slice +/// first when `slice`): its result or error, the document, the selection and the undo history's +/// length. +fn outcome( + cmd: &str, + p: &serde_json::Value, + selected: &[u64], + slice: bool, +) -> (std::result::Result, vectorcraft_doc::Document, vectorcraft_doc::Selection, usize) { + let (mut s, ..) = doc_with_type(); + if slice { + s.execute("object.slice.make", &json!({ "ids": selected })).unwrap(); + } + s.execute("select.set", &json!({ "ids": selected })).unwrap(); + let r = s.execute(cmd, p).map_err(|e| e.to_string()); + let st = s.doc().unwrap(); + (r, (*st.doc).clone(), st.selection.clone(), st.history.undo.len()) +} + +/// With the rectangle apart (`d`) selected and with nothing selected, a list with an entry that is +/// not a non-negative integer (a string, true, an object) is refused with an error naming the key, +/// the entry and its value, a value that is not a list with an error naming the key, and the +/// document, the selection and the undo history stay as they were. No entry is dropped. A list of +/// the rectangle is not refused. +#[test] +fn an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing() { + let (_, [.., d], _) = doc_with_type(); + let mut failures = vec![]; + for (cmd, key, params, _) in &ids_cases(d) { + let bads = [json!(["abc"]), json!([d, true]), json!([{}]), json!("abc")]; + for (bad, selected) in bads.iter().flat_map(|b| [(b, true), (b, false)]) { + // A fresh document each time, so no mode one case enters carries into the next. + let (mut s, ..) = doc_with_type(); + if selected { + s.execute("select.set", &json!({ "ids": [d] })).unwrap(); + } + let st = s.doc().unwrap(); + let before = (st.doc.clone(), st.selection.clone(), st.history.undo.len()); + let mut p = params.clone(); + p[*key] = bad.clone(); + let r = s.execute(cmd, &p); + let want = match bad.as_array() { + Some(a) => { + let (index, value) = a.iter().enumerate().find(|(_, v)| !v.is_u64()).unwrap(); + format!("`{key}` entry {index} is {value}, not a non-negative integer object id") + } + None => format!("{key} must be an array of non-negative integer object ids"), + }; + match &r { + Err(EngineError::BadParams { cmd: c, msg }) if c == cmd && *msg == want => {} + r => failures.push(format!("{cmd} {key}: {bad} (selected: {selected}): {r:.160?}")), + } + let st = s.doc().unwrap(); + if (st.doc.clone(), st.selection.clone(), st.history.undo.len()) != before { + failures.push(format!("{cmd} {key}: {bad} (selected: {selected}): the document, the selection or the undo history changed")); + } + } + let (mut s, ..) = doc_with_type(); + s.execute("select.set", &json!({ "ids": [d] })).unwrap(); + let mut p = params.clone(); + p[*key] = json!([d]); + if let Err(EngineError::BadParams { msg, .. }) = s.execute(cmd, &p) + && (msg.starts_with(&format!("`{key}` entry ")) || msg.starts_with(&format!("{key} must be an array"))) + { + failures.push(format!("{cmd} {key}: [{d}] is refused: {msg}")); + } + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); +} + +/// With the rectangle apart (`d`) selected, the same commands with `ids` (or `slices`) left out and +/// with it null do the same: what the selection's list does for the commands that take the +/// selection, and the command's own default for the others (`object.slice.select` deselects the +/// slices). `addon.color.filter` and `addon.color.applyNearestSwatch` read `ids` through +/// [`cmd::checked_ids_or_selection`], which refuses null as a value that is not a list and changes +/// nothing. +#[test] +fn ids_left_out_or_null_take_the_selection_or_the_default() { + let (_, [.., d], _) = doc_with_type(); + let mut failures = vec![]; + for (cmd, key, params, unset) in ids_cases(d) { + // The commands on `slices` act on the selected object once it is an object slice. + let slice = key == "slices"; + let left_out = outcome(cmd, ¶ms, &[d], slice); + let mut null = params.clone(); + null[key] = serde_json::Value::Null; + let with_null = outcome(cmd, &null, &[d], slice); + if matches!(cmd, "addon.color.filter" | "addon.color.applyNearestSwatch") { + let (mut s, ..) = doc_with_type(); + s.execute("select.set", &json!({ "ids": [d] })).unwrap(); + let st = s.doc().unwrap(); + let refused = with_null.0.as_ref().is_err_and(|e| e.ends_with("ids must be an array of non-negative integer object ids")); + if !refused || (&with_null.1, &with_null.2, with_null.3) != (&*st.doc, &st.selection, st.history.undo.len()) { + failures.push(format!("{cmd} {key}: null is not refused with nothing changed ({:.160?})", with_null.0)); + } + } else if with_null != left_out { + failures.push(format!("{cmd} {key}: null does not do what leaving it out does ({:.160?})", left_out.0)); + } + let mut listed = params.clone(); + listed[key] = json!([d]); + let listed = outcome(cmd, &listed, &[d], slice); + let ok = match &unset { + Unset::Selected => left_out.0.is_ok() && listed == left_out, + Unset::SelectionRefused(want) => left_out.0.as_ref().is_err_and(|e| e.ends_with(want)) && listed == left_out, + Unset::Fails(want) => left_out.0.as_ref().is_err_and(|e| e.ends_with(want)) && listed.0 != left_out.0, + Unset::Gives(want) => left_out.0.as_ref() == Ok(want) && listed.0 != left_out.0, + }; + if !ok { + failures.push(format!("{cmd} {key}: left out: {:.200?}; listed: {:.200?}", left_out.0, listed.0)); + } + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); +} + +/// With no document open, every command that takes `ids` refuses a list with an entry that is not +/// a non-negative integer, and a value that is not a list, with the same error as with a document +/// open (`effect.list` and `links.findFiles` run without a document). +#[test] +fn an_ids_entry_that_is_not_an_id_is_refused_with_no_document() { + let (mut failures, mut checked) = (vec![], 0); + for spec in command_specs() { + if !cmd::params::takes_key(spec.params, "ids") { + continue; + } + checked += 1; + for bad in [json!(["abc"]), json!([1, true]), json!("abc")] { + let mut s = Session::new(); + let r = s.execute(spec.id, &json!({ "ids": bad })); + let want = match bad.as_array() { + Some(a) => { + let (index, value) = a.iter().enumerate().find(|(_, v)| !v.is_u64()).unwrap(); + format!("`ids` entry {index} is {value}, not a non-negative integer object id") + } + None => "ids must be an array of non-negative integer object ids".to_string(), + }; + match &r { + Err(EngineError::BadParams { cmd: c, msg }) if c == spec.id && *msg == want => {} + r => failures.push(format!("{} ids: {bad}: {r:.160?}", spec.id)), + } + } + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); + assert!(checked >= 100, "only {checked} commands take ids"); +} diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 4e6100df9..2113d5a79 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -2104,7 +2104,7 @@ fn type_menu_and_find_commands_refuse_a_param_of_the_wrong_type() { let switch = |k: &str| format!("`{k}` must be true or false"); let scope = "`scope` must be one of selection, document".to_string(); let not_a_list = "ids must be an array of non-negative integer object ids".to_string(); - let not_an_id = "invalid object id \"x\": expected a non-negative integer".to_string(); + let not_an_id = |entry: usize| format!("`ids` entry {entry} is \"x\", not a non-negative integer object id"); for (cmd, p, want) in [ ("edit.findReplace", json!({"find": "cat", "replace": 5}), string("replace")), ("edit.findReplace", json!({"find": 5, "replace": "dog"}), string("find")), @@ -2112,7 +2112,8 @@ fn type_menu_and_find_commands_refuse_a_param_of_the_wrong_type() { ("edit.findReplace", json!({"find": "cat", "replace": "dog", "wholeWord": "yes"}), switch("wholeWord")), ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": t.0}), not_a_list), ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": [t.0, 999_999]}), "no such object id 999999".to_string()), - ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": ["x"]}), not_an_id), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": ["x"]}), not_an_id(0)), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": [t.0, "x"]}), not_an_id(1)), ("edit.findNext", json!({"find": ["cat"]}), string("find")), ("edit.findNext", json!({"find": "cat", "matchCase": 1}), switch("matchCase")), ("edit.findNext", json!({"find": "cat", "wholeWord": "false"}), switch("wholeWord")), diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index fdfe60067..dad65195f 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -17,8 +17,9 @@ fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } +/// Installs `wasm`, in place of any module installed with its id (`replace: true`). fn install(s: &mut Session, wasm: &[u8]) -> Value { - run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})) + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "replace": true})) } fn install_wat(s: &mut Session, src: &str) -> Value { @@ -74,6 +75,52 @@ fn object_filter_recolours_the_selection_as_one_undo_step() { assert!(matches!(s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": "all"})), Err(EngineError::BadParams { .. }))); } +/// `plugin.run` refuses an `ids` list with an entry that is not an object id, with an error naming +/// the key, the entry and its value, and changes nothing. With `ids` left out or null the filter +/// runs on the selection, as it does on the list of the selection. +#[test] +fn object_filter_ids_entries_must_be_ids_and_ids_left_out_take_the_selection() { + // A session with the plug-in, a red and a blue rectangle, and the red one selected. The + // document has no creation date, so documents made in different seconds compare equal. + let fresh = || { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 200, "created": null})); + install(&mut s, DESATURATE); + let a = rect(&mut s, 10.0, "#ff0000"); + let b = rect(&mut s, 40.0, "#0000ff"); + run(&mut s, "select.set", json!({"ids": [a.0]})); + (s, a, b) + }; + let (_, a, b) = fresh(); + for (bad, entry) in [(json!(["abc"]), r#"entry 0 is "abc""#), (json!([a.0, true]), "entry 1 is true"), (json!([{}]), "entry 0 is {}")] { + let (mut s, ..) = fresh(); + let st = s.doc().unwrap(); + let before = (st.doc.clone(), st.selection.clone(), st.history.undo.len()); + let r = s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": 100, "ids": bad})); + let want = format!("`ids` {entry}, not a non-negative integer object id"); + assert!(matches!(&r, Err(EngineError::BadParams { cmd, msg }) if cmd == "plugin.run" && *msg == want), "{bad}: {r:?}"); + let st = s.doc().unwrap(); + assert!((st.doc.clone(), st.selection.clone(), st.history.undo.len()) == before, "{bad}: something changed"); + } + let gray = |s: &Session, id: NodeId| { + let [r, g, b] = node(s, id).appearance.fill_paint().color().unwrap().to_rgb_uncalibrated(); + (r - g).abs() < 1e-6 && (g - b).abs() < 1e-6 + }; + let mut docs = vec![]; + for ids in [None, Some(Value::Null), Some(json!([a.0]))] { + let (mut s, ..) = fresh(); + let mut p = json!({"id": DESATURATE_ID, "amount": 100}); + if let Some(ids) = &ids { + p["ids"] = ids.clone(); + } + let r = run(&mut s, "plugin.run", p); + assert_eq!(r["ids"], json!([a.0]), "{ids:?}"); + assert!(gray(&s, a) && !gray(&s, b), "{ids:?}: only the selected rectangle turns gray"); + docs.push(s.doc().unwrap().doc.clone()); + } + assert!(docs.windows(2).all(|w| w[0] == w[1]), "left out, null and the selection's list give the same document"); +} + #[test] fn object_filters_add_replace_and_generate_objects() { let mut s = session(); @@ -532,8 +579,8 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec { /// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`), /// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's -/// hash. The grant is recorded with that hash: replacing the plug-in with one that lists the same -/// permissions needs no new grant, and one that lists another needs one. Removing it forgets the +/// hash. The grant is recorded with that hash: replacing the plug-in (`replace: true`) with one +/// that lists the same permissions needs no new grant, and one that lists another needs one. Removing it forgets the /// grant. A plug-in without `permissions` installs as before, with the ones its kind implies. #[test] fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { @@ -574,15 +621,15 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { // grant now records the new module's hash. let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false); - run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other)})); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "replace": true})); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other)); // One that asks for more is refused until it is granted all of them. let more = data(r#"["doc.read","doc.write"]"#); assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": more}))["needsApproval"], true); - let e = s.execute("plugin.install", &json!({"dataBase64": more})).unwrap_err().to_string(); + let e = s.execute("plugin.install", &json!({"dataBase64": more, "replace": true})).unwrap_err().to_string(); assert!(e.contains("doc.read, doc.write") && e.contains("`grant`"), "{e}"); assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().manifest().permissions().len(), 1, "the one installed stays"); - run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"]})); + run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"], "replace": true})); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().permissions.len(), 2, "only what it lists is recorded"); // Removed, it is asked again. run(&mut s, "plugin.remove", json!({"id": id})); @@ -603,7 +650,7 @@ fn a_filter_does_only_what_its_permissions_allow() { let id = rect(&mut s, 10.0, "#ff0000"); let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]})); let grant = |s: &mut Session, wasm: &[u8], perms: Value| { - run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms})); + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms, "replace": true})); }; // Denied `doc.write`: renaming the object is refused, and the document and history stay. let reader = "test.engine.permissions.reader"; @@ -747,6 +794,59 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { run(&mut granted, "plugin.remove", json!({"id": id})); } +/// A grant covers the module it was given to: another module under the id, listing the same +/// permissions, isn't installed from the store at the next start, a folder load reports it as +/// needing approval, and `plugin.install` asks for `grant` again, even with `replace: true`. It +/// doesn't get the settings kept for the id until the user grants it. A kept grant without a hash +/// covers no module. +#[test] +fn a_grant_covers_only_the_module_it_was_given_to() { + let folder = crate::tests_links::Folder::new("plugin-store-grant-hash"); + let id = "test.engine.granthash"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + // The stored module is replaced on disk by one that reads the settings. + let other = storing(id, READS); + let stored: Vec<_> = + std::fs::read_dir(&folder.0).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|x| x == "wasm")).collect(); + assert_eq!(stored.len(), 1, "{stored:?}"); + std::fs::write(&stored[0], &other).unwrap(); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "another module isn't loaded under the grant"); + let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash"); + std::fs::write(dir.0.join("other.wasm"), &other).unwrap(); + let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()})); + assert_eq!(r["loaded"], json!([])); + let why = r["failed"][0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval"), "{why}"); + let data = vectorcraft_format::base64_encode(&other); + let e = next.execute("plugin.install", &json!({"dataBase64": data, "replace": true})).unwrap_err().to_string(); + assert!(e.contains("`grant`"), "{e}"); + // Granted, it reads the settings the first module set. + run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + + // A kept grant that records no hash, or an empty one, covers no module: the stored module isn't + // installed at the next start and `plugin.inspect` asks for approval. + for entry in [json!({"id": id, "permissions": ["settings", "cache"]}), json!({"id": id, "hash": "", "permissions": ["settings", "cache"]})] { + std::fs::write(folder.0.join("grants.json"), serde_json::to_vec(&json!([entry])).unwrap()).unwrap(); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut again = session(); + again.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "a grant without a hash doesn't load the module: {entry}"); + assert_eq!(run(&mut again, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], json!(true), "{entry}"); + next = again; + } + run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); + run(&mut next, "plugin.remove", json!({"id": id})); +} + /// A grants file entry this version can't use (a permission it doesn't know, an invalid id) is /// skipped without dropping the other grants, and recording a grant writes it back as it was, /// except an entry for the plug-in being granted or removed. A grants file that doesn't parse as a @@ -888,7 +988,7 @@ fn a_filter_run_made_elsewhere_keeps_its_document_data() { let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#); let install = |s: &mut Session, src: String| { let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap()); - run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true})); + run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true, "replace": true})); }; // It first keeps the pens it found with the document. install(&mut s, tpl::returning(&manifest, &json!({"objects": [], "documentData": {"pens": 2}}).to_string())); @@ -954,3 +1054,379 @@ fn a_cancelled_filter_run_changes_nothing() { assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); assert_eq!(s.doc().unwrap().doc, before); } + +/// A filter `id` of API level 2 that lists `settings` and `cache`, whose `vc_run` runs `first` +/// (instructions that trap unless each storage call returns what it should), with the keys "zoom" +/// at 0x2000 and "tile" at 0x2004 and the value "hello" at 0x2100. +fn storing(id: &str, first: &str) -> Vec { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["settings","cache"]}}"#); + let data = format!(r#"{} (data (i32.const 0x2000) "zoomtile") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS); + wat::parse_str(tpl::with_imports(&manifest, &data, first, r#"{"objects":[]}"#)).unwrap() +} + +/// Sets "zoom" to "hello" and writes "hel" to the cache entry "tile". +const WRITES: &str = "(if (call $vc_settings_set (i32.const 0x2000) (i32.const 4) (i32.const 0x2100) (i32.const 5)) (then unreachable)) + (if (call $vc_cache_write (i32.const 0x2004) (i32.const 4) (i32.const 0x2100) (i32.const 3)) (then unreachable))"; + +/// Traps unless "zoom" is "hello". +const READS: &str = + "(if (i32.ne (call $vc_settings_get (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 16)) (i32.const 5)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 0x3000)) (i32.load (i32.const 0x2100))) (then unreachable))"; + +fn install_storing(s: &mut Session, id: &str, first: &str) -> Value { + let data = vectorcraft_format::base64_encode(&storing(id, first)); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"]})) +} + +/// [`install_storing`] in place of another module with the same id (`replace: true`). +fn replace_storing(s: &mut Session, id: &str, first: &str) -> Value { + let data = vectorcraft_format::base64_encode(&storing(id, first)); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})) +} + +/// A plug-in's settings and cache are kept in the plug-in store under its id: the next session +/// has them, replacing the plug-in keeps them, another plug-in can't read them, `plugin.clearCache` +/// empties the cache alone and `plugin.remove` deletes both. The caps and the bytes each call is +/// charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`], +/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test, since +/// clearing every plug-in's cache would empty theirs. +#[test] +fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() { + let folder = crate::tests_links::Folder::new("plugin-storage"); + let (id, other) = ("test.engine.storing", "test.engine.storing.other"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + let dir = crate::store::plugin_data_dir(&folder.0, id); + assert!(dir.join("settings.json").is_file()); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3)); + + // A restart: installed again from the store, and replaced by a module that reads. + vectorcraft_plugins::registry::remove(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + let listed = run(&mut next, "plugin.list", json!({})); + let entry = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap(); + assert_eq!(entry["cacheBytes"], json!(3)); + assert_eq!(replace_storing(&mut next, id, READS)["cacheBytes"], json!(3)); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + + // Another plug-in has settings of its own: "zoom" isn't set there. + install_storing(&mut next, other, READS); + assert!(next.execute("plugin.run", &json!({"id": other, "ids": []})).is_err()); + + // Clearing the cache leaves the settings. + assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3})); + assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0)); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + replace_storing(&mut next, other, WRITES); + run(&mut next, "plugin.run", json!({"id": other, "ids": []})); + let all = run(&mut next, "plugin.clearCache", json!({})); + let cleared = all["cleared"].as_array().unwrap(); + assert!(cleared.contains(&json!(id)) && cleared.contains(&json!(other)) && all["bytesFreed"] == json!(3), "{all}"); + assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(0)); + assert!(next.execute("plugin.clearCache", &json!({"id": "test.engine.storing.missing"})).is_err()); + + run(&mut next, "plugin.remove", json!({"id": id})); + assert!(!dir.exists(), "removed with the plug-in"); + run(&mut next, "plugin.remove", json!({"id": other})); + plugin_storage_is_capped_and_survives_a_damaged_settings_file(); + plugin_storage_calls_are_charged_the_files_they_read_and_write(); +} + +/// A `storage` folder in the plug-in store that is a link leads out of the store: the plug-in +/// installs without `replace` although a folder for its id is there, its storage calls fail as +/// unavailable, and removing the plug-in deletes nothing the link leads to. +#[test] +fn plugin_storage_behind_a_link_is_left_alone() { + let folder = crate::tests_links::Folder::new("plugin-storage-link"); + let outside = crate::tests_links::Folder::new("plugin-storage-outside"); + #[cfg(unix)] + let made = std::os::unix::fs::symlink(&outside.0, folder.0.join("storage")).is_ok(); + // Without Developer Mode or elevation Windows makes no symbolic link, but a junction. + #[cfg(windows)] + let made = std::os::windows::fs::symlink_dir(&outside.0, folder.0.join("storage")).is_ok() + || std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(folder.0.join("storage")) + .arg(&outside.0) + .output() + .is_ok_and(|o| o.status.success()); + #[cfg(not(any(unix, windows)))] + let made = false; + if !made { + eprintln!("skipped: symbolic links can't be made here"); + return; + } + let id = "test.engine.storagelink"; + let kept = crate::store::plugin_data_dir(&folder.0, id).join("settings.json"); + std::fs::create_dir_all(kept.parent().unwrap()).unwrap(); + std::fs::write(&kept, b"{}").unwrap(); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, ""); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let m = &mut vectorcraft_plugins::storage::Meter::unlimited(); + assert_eq!(st.set_setting("zoom", b"hello", m), Err(vectorcraft_plugins::storage::StorageError::Unavailable)); + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(kept.is_file(), "left where the link leads"); +} + +/// `plugin.remove` with the id of a plug-in that isn't installed deletes the settings and cache the +/// plug-in store still keeps for it; with neither, it fails as before. +#[test] +fn removing_an_id_that_is_not_installed_deletes_its_storage() { + let folder = crate::tests_links::Folder::new("plugin-storage-orphan"); + let id = "test.engine.storageorphan"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + let dir = crate::store::plugin_data_dir(&folder.0, id); + std::fs::create_dir_all(dir.join("cache")).unwrap(); + std::fs::write(dir.join("settings.json"), b"{}").unwrap(); + std::fs::write(dir.join("cache").join("tile.bin"), b"abc").unwrap(); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + assert_eq!(run(&mut s, "plugin.remove", json!({"id": id})), json!({"removed": id})); + assert!(!dir.exists(), "the storage of an id that isn't installed is deleted"); + assert!(s.execute("plugin.remove", &json!({"id": id})).is_err(), "nothing left to remove"); +} + +/// The settings file and the cache are checked: a file that doesn't read is moved aside and the +/// settings start empty; a write past a cap fails and changes nothing; cache entries whose names +/// differ only in case are two files; a removed plug-in's storage takes no more writes. +fn plugin_storage_is_capped_and_survives_a_damaged_settings_file() { + use vectorcraft_plugins::storage::{self, Meter, StorageError}; + let folder = crate::tests_links::Folder::new("plugin-storage-caps"); + let id = "test.engine.storagecaps"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, ""); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let dir = crate::store::plugin_data_dir(&folder.0, id); + std::fs::create_dir_all(&dir).unwrap(); + let m = &mut Meter::unlimited(); + + // A truncated settings file. + std::fs::write(dir.join("settings.json"), br#"{"zoom": "aGVsbG"#).unwrap(); + assert_eq!(st.setting("zoom", m).unwrap(), None); + assert!(dir.join("settings.unreadable.json").is_file() && !dir.join("settings.json").exists()); + st.set_setting("zoom", b"hello", m).unwrap(); + assert_eq!(st.setting("zoom", m).unwrap(), Some(b"hello".to_vec())); + // Damaged again, it replaces the file moved aside before. + std::fs::write(dir.join("settings.json"), b"[").unwrap(); + assert_eq!(st.setting("zoom", m).unwrap(), None); + assert_eq!(std::fs::read(dir.join("settings.unreadable.json")).unwrap(), b"["); + st.set_setting("zoom", b"hello", m).unwrap(); + plugin_storage_writes_are_atomic(&*st, &dir, &folder.0); + + // At most 256 keys: a new one is refused, an existing one still changes. + let keys: std::collections::BTreeMap = (0..storage::MAX_SETTINGS).map(|i| (format!("k{i}"), "eA==".to_string())).collect(); + std::fs::write(dir.join("settings.json"), serde_json::to_vec(&keys).unwrap()).unwrap(); + assert_eq!(st.set_setting("one-more", b"x", m), Err(StorageError::Full)); + st.set_setting("k0", b"y", m).unwrap(); + assert_eq!(st.setting("k0", m).unwrap(), Some(b"y".to_vec())); + plugin_storage_skips_oversize_files(&*st, &dir); + + // At most 1 MiB in all: eleven values of 64 KiB fit, a twelfth doesn't, and nothing changes. + let big = vec![7_u8; storage::MAX_SETTING_BYTES]; + let keys: std::collections::BTreeMap = (0..11).map(|i| (format!("k{i}"), vectorcraft_format::base64_encode(&big))).collect(); + std::fs::write(dir.join("settings.json"), serde_json::to_vec(&keys).unwrap()).unwrap(); + assert_eq!(st.set_setting("k11", &big, m), Err(StorageError::Full)); + assert_eq!((st.setting("k11", m).unwrap(), st.setting("k0", m).unwrap()), (None, Some(big.clone()))); + + // The cache: entry size, then the total, here nearly reached by a file already there. + assert_eq!(st.set_cache_entry("big", &vec![0; storage::MAX_CACHE_ENTRY + 1], m), Err(StorageError::Full)); + std::fs::create_dir_all(dir.join("cache")).unwrap(); + std::fs::File::create(dir.join("cache").join("filler.bin")).unwrap().set_len(storage::MAX_CACHE_BYTES - 2).unwrap(); + // A temporary file a failed write left behind counts toward no cap, and clearing removes it. + let leftover = dir.join("cache").join(".tile.bin.1-0.tmp"); + std::fs::write(&leftover, b"tmp").unwrap(); + assert_eq!(st.set_cache_entry("tile", b"abc", m), Err(StorageError::Full)); + assert_eq!(st.cache_entry("tile", m).unwrap(), None); + st.set_cache_entry("tile", b"ab", m).unwrap(); + assert_eq!(st.cache_bytes(), storage::MAX_CACHE_BYTES); + assert_eq!(st.clear_cache().unwrap(), storage::MAX_CACHE_BYTES + 3); + assert!(!leftover.exists()); + + // Names differing in case are two entries. + st.set_cache_entry("Tile", b"1", m).unwrap(); + st.set_cache_entry("tile", b"22", m).unwrap(); + assert_eq!((st.cache_entry("Tile", m).unwrap(), st.cache_entry("tile", m).unwrap()), (Some(b"1".to_vec()), Some(b"22".to_vec()))); + assert_eq!(st.cache_bytes(), 3); + + // More items in the cache folder than a write looks at: the write is refused whatever the + // items it looked at hold, and clearing still reaches every entry. + let cache = dir.join("cache"); + for i in 0..crate::store::MAX_CACHE_SCAN { + std::fs::create_dir(cache.join(format!("d{i:05}"))).unwrap(); + } + std::fs::File::create(cache.join("z.bin")).unwrap().set_len(storage::MAX_CACHE_BYTES).unwrap(); + assert_eq!(st.set_cache_entry("more", b"1", m), Err(StorageError::Full)); + assert_eq!(st.clear_cache().unwrap(), storage::MAX_CACHE_BYTES + 3); + assert!(!cache.join("z.bin").exists()); + + // An id that differs only in case has storage of its own, which removing this one leaves. + let upper_id = "test.engine.StorageCaps"; + s.attach_plugin_storage(upper_id); + let upper = vectorcraft_plugins::registry::storage(upper_id).unwrap(); + assert_eq!(upper.setting("k0", m).unwrap(), None); + upper.set_setting("k0", b"upper", m).unwrap(); + assert_eq!(st.setting("k0", m).unwrap(), Some(big)); + // So do ids whose `_` and `.` could be read as each other's escapes. + let folders: std::collections::BTreeSet<_> = + ["a.b", "a_0b", "a_b", "A.b", "a__b", "a._b", "a_.b"].iter().map(|id| crate::store::plugin_data_dir(&folder.0, id)).collect(); + assert_eq!(folders.len(), 7, "{folders:?}"); + + // Removing the plug-in closes the storage a run still holds, even one from before the plug-in + // was replaced, so nothing is written back. + s.attach_plugin_storage(id); + run(&mut s, "plugin.remove", json!({"id": id})); + assert_eq!(st.set_setting("zoom", b"again", m), Err(StorageError::Unavailable)); + assert_eq!(st.set_cache_entry("tile", b"again", m), Err(StorageError::Unavailable)); + assert!(!dir.exists()); + assert_eq!(upper.setting("k0", m).unwrap(), Some(b"upper".to_vec())); + s.remove_plugin_storage(upper_id).unwrap(); +} + +/// Files larger than the storage reads are not read: a settings file larger than 1 MiB, although +/// each of its values would fit, is moved aside and the settings start empty; a cache entry file +/// larger than an entry may be fails as unavailable. `st` is the storage in `dir`. +fn plugin_storage_skips_oversize_files(st: &dyn vectorcraft_plugins::storage::Storage, dir: &std::path::Path) { + use vectorcraft_plugins::storage::{self, Meter, StorageError}; + let m = &mut Meter::unlimited(); + let value = vectorcraft_format::base64_encode(&vec![7_u8; storage::MAX_SETTING_BYTES]); + let keys: std::collections::BTreeMap = (0..13).map(|i| (format!("k{i}"), value.clone())).collect(); + let oversize = serde_json::to_vec(&keys).unwrap(); + assert!(oversize.len() > storage::MAX_SETTINGS_BYTES); + std::fs::write(dir.join("settings.json"), &oversize).unwrap(); + assert_eq!(st.setting("k0", m).unwrap(), None); + assert!(dir.join("settings.unreadable.json").is_file() && !dir.join("settings.json").exists()); + std::fs::create_dir_all(dir.join("cache")).unwrap(); + let huge = dir.join("cache").join("huge.bin"); + std::fs::File::create(&huge).unwrap().set_len(storage::MAX_CACHE_ENTRY as u64 + 1).unwrap(); + assert_eq!(st.cache_entry("huge", m), Err(StorageError::Unavailable)); + std::fs::remove_file(&huge).unwrap(); +} + +/// Writes are atomic: a new file takes the old one's name, so another name for the old file (a hard +/// link) keeps its content. `st` is the storage in `dir` and holds the setting "zoom"; `outside` is +/// a folder outside it. +fn plugin_storage_writes_are_atomic(st: &dyn vectorcraft_plugins::storage::Storage, dir: &std::path::Path, outside: &std::path::Path) { + let m = &mut vectorcraft_plugins::storage::Meter::unlimited(); + let settings_copy = outside.join("settings-copy.json"); + let before = std::fs::read(dir.join("settings.json")).unwrap(); + std::fs::hard_link(dir.join("settings.json"), &settings_copy).unwrap(); + st.set_setting("zoom", b"world", m).unwrap(); + assert_eq!(std::fs::read(&settings_copy).unwrap(), before); + assert_eq!(st.setting("zoom", m).unwrap(), Some(b"world".to_vec())); + st.set_cache_entry("tile", b"abc", m).unwrap(); + let entry_copy = outside.join("tile-copy.bin"); + let entry = std::fs::read_dir(dir.join("cache")).unwrap().map(|e| e.unwrap().path()).find(|p| p.is_file()).unwrap(); + std::fs::hard_link(&entry, &entry_copy).unwrap(); + st.set_cache_entry("tile", b"xyz", m).unwrap(); + assert_eq!(std::fs::read(&entry_copy).unwrap(), b"abc"); + assert_eq!(st.cache_entry("tile", m).unwrap(), Some(b"xyz".to_vec())); + assert!(st.remove_cache_entry("tile").unwrap()); + std::fs::remove_file(&settings_copy).unwrap(); + std::fs::remove_file(&entry_copy).unwrap(); +} + +/// Each storage call is charged the bytes it reads and writes, before it does: a settings call the +/// whole settings file it reads and the whole file it writes, a cache call the entry it reads or +/// writes. A call that would pass what its meter has left fails and changes nothing. +fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { + use vectorcraft_plugins::storage::{Meter, StorageError}; + let folder = crate::tests_links::Folder::new("plugin-storage-meter"); + let id = "test.engine.storagemeter"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, ""); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let file = crate::store::plugin_data_dir(&folder.0, id).join("settings.json"); + let size = || std::fs::metadata(&file).unwrap().len(); + st.set_setting("big", &[7; 60_000], &mut Meter::unlimited()).unwrap(); + st.set_setting("small", b"x", &mut Meter::unlimited()).unwrap(); + + // Reading a one-byte setting reads the whole file. + let before = size(); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("small", &mut m).unwrap(), Some(b"x".to_vec())); + assert_eq!(m.used(), before); + // Setting it reads the file and writes it again, and so does deleting one. + let mut m = Meter::unlimited(); + st.set_setting("small", b"yz", &mut m).unwrap(); + assert_eq!(m.used(), before + size()); + let (before, mut m) = (size(), Meter::unlimited()); + assert!(st.delete_setting("small", &mut m).unwrap()); + assert_eq!(m.used(), before + size()); + st.set_setting("small", b"y", &mut Meter::unlimited()).unwrap(); + + // Less left than the file: nothing is read. Enough to read it but not to write it again: + // nothing changes. + let mut m = Meter::new(size() - 1); + assert_eq!(st.setting("small", &mut m), Err(StorageError::Budget)); + assert_eq!(m.used(), 0); + assert_eq!(st.set_setting("small", b"z", &mut Meter::new(size())), Err(StorageError::Budget)); + assert_eq!(st.setting("small", &mut Meter::unlimited()).unwrap(), Some(b"y".to_vec())); + + // A cache write is charged the entry it writes, a read the entry it reads. + let mut m = Meter::unlimited(); + st.set_cache_entry("tile", &[1; 1000], &mut m).unwrap(); + assert_eq!(m.used(), 1000); + let mut m = Meter::unlimited(); + assert_eq!(st.cache_entry("tile", &mut m).unwrap().map(|v| v.len()), Some(1000)); + assert_eq!(m.used(), 1000); + assert_eq!(st.cache_entry("tile", &mut Meter::new(999)), Err(StorageError::Budget)); + assert_eq!(st.set_cache_entry("tile", &[2; 10], &mut Meter::new(9)), Err(StorageError::Budget)); + assert_eq!(st.cache_entry("tile", &mut Meter::unlimited()).unwrap().map(|v| v.len()), Some(1000)); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A module with other bytes than the plug-in installed with its id, or, with none installed, one +/// whose id has settings and a cache in the plug-in store, installs only with `replace: true`, and +/// then keeps them; the same module installs again without it, and `replace` must be true or false. +#[test] +fn another_module_under_an_id_with_settings_needs_replace() { + let folder = crate::tests_links::Folder::new("plugin-storage-replace"); + let id = "test.engine.storingreplace"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + let hash = run(&mut s, "plugin.info", json!({"id": id}))["hash"].clone(); + // The same module again. + install_storing(&mut s, id, WRITES); + + let reads = vectorcraft_format::base64_encode(&storing(id, READS)); + let install = |s: &mut Session, extra: Value| { + let mut p = json!({"dataBase64": reads, "grant": ["settings", "cache"]}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("plugin.install", &p) + }; + let Err(e) = install(&mut s, json!({})) else { panic!("another module took the plug-in's place") }; + assert!(e.to_string().contains(&format!("“Test {id}” is another module than the plug-in with id \"{id}\"")), "{e}"); + assert!(e.to_string().contains("`replace: true`"), "{e}"); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsReplace"], json!(true)); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["hash"], hash); + for bad in [json!({"replace": "yes"}), json!({"replace": 1})] { + let Err(e) = install(&mut s, bad) else { panic!("a bad `replace` was taken") }; + assert!(e.to_string().contains("`replace` must be true or false"), "{e}"); + } + assert!(install(&mut s, json!({"replace": false})).is_err()); + install(&mut s, json!({"replace": true})).unwrap(); + // It reads what the one it replaced set. + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3)); + + // Not installed, but with settings in the store: another module needs `replace` too. + vectorcraft_plugins::registry::remove(id); + let writes = vectorcraft_format::base64_encode(&storing(id, WRITES)); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": writes}))["needsReplace"], json!(true)); + assert!(s.execute("plugin.install", &json!({"dataBase64": writes, "grant": ["settings", "cache"]})).is_err()); + run(&mut s, "plugin.install", json!({"dataBase64": writes, "grant": ["settings", "cache"], "replace": true})); + run(&mut s, "plugin.remove", json!({"id": id})); + // Once removed, with its settings and cache, it installs as a first install does. + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsReplace"], json!(false)); + install(&mut s, json!({})).unwrap(); + run(&mut s, "plugin.remove", json!({"id": id})); +} diff --git a/crates/engine/src/tests/select_ids_first.rs b/crates/engine/src/tests/select_ids_first.rs index da6c83c87..35d5165cd 100644 --- a/crates/engine/src/tests/select_ids_first.rs +++ b/crates/engine/src/tests/select_ids_first.rs @@ -208,7 +208,7 @@ fn id_errors_name_the_command_run() { for (given, want) in [ (json!([999]), "no such object id 999".to_string()), (json!([999, layer]), "no such object id 999".to_string()), - (json!([ids[0], ids[1], -1]), "invalid object id -1: expected a non-negative integer".to_string()), + (json!([ids[0], ids[1], -1]), "`ids` entry 2 is -1, not a non-negative integer object id".to_string()), (json!(ids[0]), "ids must be an array of non-negative integer object ids".to_string()), ] { let mut p = match *cmd { diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 5499dfd88..d46688bdf 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4819,6 +4819,16 @@ fn arb_ai_token() -> impl Strategy { "\n%AI5_BeginRaster\n", "\n%AI5_EndRaster\n", "\n%%BeginData: 12\rXI\n", + // Image operands past 2^25 pixels, of odd 1-bit rows, and past the hard limits: as + // many pixels as a u32 side allows, more samples than the bytes allowed, sizes that + // overflow when multiplied out, and a matrix that maps the box to nothing. + "[1 0 0 1 0 0] 0 0 6000 6000 6000 6000 1 1 0 0 1 0 4 4 0 0 ", + "[1 0 0 1 0 0] 0 0 17 13 17 13 1 1 0 0 1 0 4 4 0 0 ", + "[1 0 0 1 0 0] 0 0 9 9 65536 32769 1 1 0 0 1 0 4 4 0 0 ", + "[1 0 0 1 0 0] 0 0 9 9 23171 23171 8 4 1 0 1 0 4 4 0 0 ", + "[1 0 0 1 0 0] 0 0 9 9 4294967295 4294967295 8 4 1 0 1 0 4 4 0 0 ", + "[1 0 0 1 0 0] 0 0 9 9 1e308 1e308 8 3 1 0 0 0 4 4 0 0 ", + "[0 0 0 0 0 0] 0 0 9 9 46341 46341 1 1 0 0 1 0 4 4 0 0 ", "\n%%EndData\n", "\n%AI5_BeginGradient: (G)\n", "\n%AI14_BeginSymbol\n", diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 70e1014ad..4a60760f8 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -30,8 +30,9 @@ fn config() -> ProptestConfig { c } +/// Installs `wasm`, in place of any module installed with its id before (`replace: true`). fn install(s: &mut Session, wasm: &[u8]) -> Result { - s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})).map_err(|e| e.to_string()) + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "replace": true})).map_err(|e| e.to_string()) } /// The document renders and exports without panicking. @@ -722,4 +723,66 @@ proptest! { prop_assert!(total == 0 || done <= total); } } + + /// A plug-in that calls the storage imports with hostile keys (`..`, separators, device names, + /// control characters, too long), values, lengths and pointers in and out of its memory runs to + /// an output, never a panic; every call returns a length or a storage code, and the settings and + /// cache of another plug-in in the same store stay as they were. + #[test] + fn hostile_storage_arguments_never_crash( + calls in prop::collection::vec( + ( + 0..6usize, + prop_oneof![any::(), 0x1ff00..0x20100i32, Just(0x2000)], + prop_oneof![any::(), 0..80i32], + prop_oneof![any::(), 0x1ff00..0x20100i32, Just(0x3000)], + prop_oneof![any::(), 0..5000i32], + ), + 0..16, + ), + key in prop::collection::vec(prop_oneof![Just(b'a'), Just(b'.'), Just(b'/'), Just(b'\\'), Just(b'_'), any::()], 0..80), + ) { + use vectorcraft_plugins::host::STORAGE_BUDGET; + use vectorcraft_plugins::storage::Meter; + const FUNCS: [&str; 6] = ["vc_settings_get", "vc_settings_set", "vc_cache_read", "vc_cache_write", "vc_settings_delete", "vc_cache_clear"]; + let folder = std::env::temp_dir().join(format!("vectorcraft-plugin-fuzz-storage-{}", std::process::id())); + let mut s = Session::new(); + s.attach_plugin_store(folder.clone()); + let storing = |id: &str| format!(r#"{{"id":"{id}","name":"F","kind":"filter","api":2,"permissions":["settings","cache"]}}"#); + let grant = json!(["settings", "cache"]); + let other = wat::parse_str(tpl::returning(&storing("fuzz.storage.other"), r#"{"objects":[]}"#)).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": grant})).unwrap(); + let theirs = vectorcraft_plugins::registry::storage("fuzz.storage.other").unwrap(); + let m = &mut Meter::unlimited(); + theirs.set_setting("mine", b"kept", m).unwrap(); + theirs.set_cache_entry("mine", b"kept", m).unwrap(); + // Each call's result is checked: a length (0 or more) or one of the storage codes. + let check = |call: String| format!("(local.set $r {call}) (if (i32.lt_s (local.get $r) (i32.const {STORAGE_BUDGET})) (then unreachable))"); + let body: String = calls + .iter() + .map(|&(f, kp, kl, vp, vl)| match f { + 0..4 => check(format!("(call ${} (i32.const {kp}) (i32.const {kl}) (i32.const {vp}) (i32.const {vl}))", FUNCS[f])), + _ => check(format!("(call ${} (i32.const {kp}) (i32.const {kl}))", FUNCS[f])), + }) + .collect(); + let text: String = key.iter().map(|b| format!("\\{b:02x}")).collect(); + let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::STORAGE_IMPORTS); + let wasm = wat::parse_str(tpl::with_imports(&storing("fuzz.storage"), &imports, &format!("(local $r i32) {body}"), r#"{"objects":[]}"#)).unwrap(); + let r = catch_quiet(|| { + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant})).map_err(|e| e.to_string())?; + let p = vectorcraft_plugins::registry::get("fuzz.storage").ok_or("not installed")?; + p.run(b"{\"objects\":[]}", b"{}").map_err(|e| e.to_string()) + }); + let out = r.map_err(|msg| TestCaseError::fail(format!("panicked on {calls:?}: {msg}")))?; + prop_assert!(out.is_ok(), "{:?}", out); + prop_assert_eq!(theirs.setting("mine", m).unwrap(), Some(b"kept".to_vec())); + prop_assert_eq!(theirs.cache_entry("mine", m).unwrap(), Some(b"kept".to_vec())); + prop_assert_eq!(theirs.cache_bytes(), 4); + // The host names every file: the store holds the two modules, their grants and their folders. + let names = |dir: &std::path::Path| std::fs::read_dir(dir).unwrap().filter_map(Result::ok).map(|e| e.file_name()).collect::>(); + prop_assert!(names(&folder).len() <= 4, "{:?}", names(&folder)); + prop_assert!(names(&folder.join("storage")).len() <= 2, "{:?}", names(&folder.join("storage"))); + let _ = s.execute("plugin.remove", &json!({"id": "fuzz.storage"})); + let _ = s.execute("plugin.remove", &json!({"id": "fuzz.storage.other"})); + } } diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index b0359e172..c29301583 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -40,11 +40,13 @@ pub(crate) use envelope::MAX_CHECKED; pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS; +#[cfg(test)] +pub(crate) use read::with_image_limits; pub use read::{ - BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, PlacedPage, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, - palette, read, read_beside, variables, + BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, read, + read_beside, variables, }; -pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member}; +pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, read_over_page, slot_frame, slot_of, symbol_art_member}; pub(crate) use read::is_look_slot; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index f49c598dc..b98a79abd 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -34,7 +34,9 @@ //! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …` //! and the samples after `XI`. 8-bit CMYK samples keep their ink amounts in a CMYK TIFF, and //! an alpha channel that hides part of such an image becomes its opacity mask; other samples -//! become an RGBA PNG. +//! become an RGBA PNG. Images larger than 2^25 pixels keep their samples' channels (1-bit gray as +//! a 1-bit PNG), and are averaged down past a target size ([`raster`]); past the hard limits, one +//! is a box that the page's art replaces when there is a page ([`read_over_page`]). //! - Linked images in the version 7 form (§8.2, §8.3): `(path) modified XG`, then `XF` with the //! operands `XI` takes and no samples. Each is a linked image of that file showing a placeholder //! over the image's place until its file is found. An `XF` without a file name before it, or @@ -134,6 +136,7 @@ mod document; mod masks; mod on_path; mod ops; +mod raster; mod types; pub(crate) use art::join_and; @@ -143,10 +146,12 @@ pub use document::{ }; pub(crate) use document::{slot_frame, slot_of}; pub(crate) use ops::read_art_style; +#[cfg(test)] +pub(crate) use raster::with_image_limits; +pub use types::PlacedPage; use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead}; #[cfg(test)] pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED}; -pub use types::{IMAGE_TOO_LARGE, PlacedPage}; pub(super) use types::{blend_mode, blend_name}; #[path = "legacy_text.rs"] @@ -235,19 +240,32 @@ pub(super) fn remember_xml_id(xml_ids: &mut HashMap>, byt /// when `large_canvas` is set: an EPS's page is drawn at the full size, but a `.ai`'s PDF part may /// keep the reduced one, which the PDF import reads as it is (#2031). pub fn read(data: &[u8], large_canvas: bool) -> Result { + read_with(data, large_canvas, false) +} + +/// [`read`] for a file whose page art replaces the objects the reader doesn't read (see +/// [`Reader::unread_box`]): an image past the hard limits ([`raster::Limits`]) is then such an +/// object, where [`read`] leaves it out with a note. +pub(crate) fn read_over_page(data: &[u8], large_canvas: bool) -> Result { + read_with(data, large_canvas, true) +} + +fn read_with(data: &[u8], large_canvas: bool, page_art: bool) -> Result { let mut r = Reader::with_names(data); r.large_canvas = large_canvas.then_some(1.0); + r.page_art = page_art; r.run()?; r.finish() } /// [`read`] for editing data kept apart from the program `ps` it came with (an EPS's private /// data): the program's header gives the author and the custom colors the editing data's header -/// doesn't. -pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8]) -> Result { +/// doesn't. `page_art`: as [`read_over_page`] reads a file. +pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8], page_art: bool) -> Result { let header = Reader::header_of(ps); let mut r = Reader::with_names(data); r.large_canvas = large_canvas.then_some(1.0); + r.page_art = page_art; // The program's custom colors have swatches of their names, which no gradient or pattern takes. for n in &header.custom_colors { r.global_names.reserve(&n.name); @@ -587,6 +605,8 @@ struct Reader<'a> { /// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one, `None` /// when the declared factor is not applied ([`read`]). large_canvas: Option, + /// The file has page art to replace the objects that aren't read ([`read_over_page`]). + page_art: bool, /// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not. canvas_scale: f64, /// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels). @@ -756,6 +776,7 @@ impl<'a> Reader<'a> { art_size: None, template_center: None, large_canvas: None, + page_art: false, canvas_scale: 1.0, ruler_units: None, pattern: None, diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 7f0b75823..7d2565172 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -1,8 +1,9 @@ use super::containers::close; use super::document::TEXT_SLOT; +use super::raster::{self, Limits, Shape}; use super::types::{ - FLIP, Frame, IMAGE_TOO_LARGE, Kind, LINKED_PLACEHOLDERS, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART, - NON_NATIVE_ART_UNREAD, PLACED_CROP, PLACED_PAGE, Place, PlacedPage, PlacedTail, + FLIP, Frame, IMAGE_TOO_LARGE, Kind, LARGE_IMAGES, LINKED_PLACEHOLDERS, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, + NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, PLACED_CROP, PLACED_PAGE, Place, PlacedPage, PlacedTail, }; use super::{ Affine, AnchorKind, Appearance, AppearanceItem, Arc, BezPath, CANVAS_CENTRE, FillLayer, ImageBlob, ImageObject, Inks, Instance, LinkInfo, Node, @@ -377,7 +378,7 @@ impl<'a> Reader<'a> { Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before, _ => vals, }; - self.image_from(vals, data) + self.image_from(vals, data, false) } /// The matrix and the numbers of an image's operands (`[a b c d tx ty] llx lly urx ury w h bits @@ -411,8 +412,22 @@ impl<'a> Reader<'a> { } } - /// An image of operands `vals` and samples `data`. - pub(super) fn image_from(&mut self, vals: Vec, data: &[u8]) -> Result<(), String> { + /// The bytes of samples (colour and alpha) that the image of `XI` operands `vals` reads: 0 when + /// it isn't read (operands missing, or past the hard limits). + pub(super) fn sample_bytes(&self, vals: &[V]) -> usize { + let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default(); + let n = Self::nums(vals); + let [_, _, _, _, w, h, bits, kind, alpha, ..] = n.as_slice() else { return 0 }; + let shape = Shape { w: *w as u64, h: *h as u64, channels: channels(&space, *kind) as u64, bits: *bits as u64, alpha: *alpha > 0.0 }; + match (shape.step(&Limits::get()), shape.lens()) { + (Some(_), Some((colour, alpha))) => usize::try_from(colour + alpha).unwrap_or(0), + _ => 0, + } + } + + /// An image of operands `vals` and samples `data` (`decoded`: bytes already, whatever the + /// operands say). + pub(super) fn image_from(&mut self, vals: Vec, data: &[u8], decoded: bool) -> Result<(), String> { // A file `XG` named before this image isn't the file of a later `XF`. self.image_link = None; let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default(); @@ -427,41 +442,42 @@ impl<'a> Reader<'a> { // ImageMask 1: the image is transparent and takes a color (version 7 specification §8.1). let image_mask = rest.first() == Some(&1.0); let (w, h) = (*w as u64, *h as u64); - if w.saturating_mul(h) > MAX_PIXELS { - self.image_left_out(IMAGE_TOO_LARGE); - return Ok(()); - } if w == 0 || h == 0 || ![1.0, 8.0].contains(bits) { self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); } - let channels: usize = match space.as_str() { - "DeviceGray" => 1, - "DeviceCMYK" => 4, - "DeviceRGB" => 3, - _ => match *kind as i64 { - 1 => 1, - 4 => 4, - _ => 3, - }, + let channels = channels(&space, *kind); + // The size is checked before anything is decoded or allocated. + let shape = Shape { w, h, channels: channels as u64, bits: *bits as u64, alpha: *alpha > 0.0 }; + let (Some(k), Some((need, alpha_len))) = (shape.step(&Limits::for_channels(channels)), shape.lens()) else { + return self.too_large_image(&m, w, h); }; - let decoded; - let data = if *binary == 0.0 { - let digits: Vec = data.iter().copied().filter(|b| b.is_ascii_hexdigit()).collect(); - decoded = crate::import::lex::hex_decode(&digits).unwrap_or_default(); - decoded.as_slice() + // Within the limits, these fit in memory (see `Limits::bytes`). + let (need, alpha_len) = (need as usize, alpha_len as usize); + let hex; + let data = if *binary == 0.0 && !decoded { + let (max, mut half) = (need + alpha_len, None); + let mut out = Vec::with_capacity(max.min(data.len() / 2)); + raster::push_hex(&mut out, &mut half, data, max); + raster::end_hex(&mut out, half, max); + hex = out; + hex.as_slice() } else { data }; - let row = if *bits == 1.0 { (w as usize * channels).div_ceil(8) } else { w as usize * channels }; - let need = row.saturating_mul(h as usize); + let row = shape.row().unwrap_or(0) as usize; // An alpha channel follows the colour samples, one byte a pixel. - let alpha_len = if *alpha > 0.0 { (w * h) as usize } else { 0 }; let (Some(samples), opacity) = (data.get(..need), data.get(need..need.saturating_add(alpha_len))) else { self.image_left_out("an image whose data is cut short was left out"); return Ok(()); }; let opacity = opacity.filter(|_| alpha_len > 0); + let gray1 = *bits == 1.0 && channels == 1 && opacity.is_none(); + if k > 1 || w * h > raster::SMALL || (gray1 && !image_mask) { + // An image mask keeps the color that colorizes it. + let colorize = (image_mask && channels == 1 && *bits == 1.0).then(|| self.mask_paint()); + return self.large_image(samples, opacity, shape, k, &m, colorize); + } // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK // image's, and an alpha channel that hides part of the image becomes its opacity mask. if channels == 4 @@ -595,6 +611,67 @@ impl<'a> Reader<'a> { self.add_image_colorized(im, None, colorize, hidden) } + /// An image of `s` past [`raster::SMALL`] pixels, or reduced by the step `k`, or 1-bit gray: + /// 1-bit gray at full size is a 1-bit PNG of its rows as they are; other images keep their + /// channels a byte each (gray or RGB in a PNG with the alpha channel, CMYK in a TIFF with the + /// alpha channel as its opacity mask), averaged over blocks of `k` × `k` pixels. The matrix + /// `m` places the image where its full size would be. + fn large_image(&mut self, samples: &[u8], opacity: Option<&[u8]>, s: Shape, k: u64, m: &[f64], colorize: Option) -> Result<(), String> { + let (ow, oh) = (s.w.div_ceil(k) as u32, s.h.div_ceil(k) as u32); + if k == 1 && s.bits == 1 && s.channels == 1 && opacity.is_none() { + let Some(png) = raster::gray1_png(samples, ow, oh, s.row().unwrap_or(0) as usize) else { + return self.too_large_image(m, s.w, s.h); + }; + return self.add_raster(ImageBlob::new("image/png", png), None, m, (ow, oh), colorize); + } + let cmyk = s.channels == 4; + let (pixels, mask) = raster::reduce(samples, opacity, s, k, !cmyk); + let blob = if cmyk { + Inks::new(ow, oh, pixels).and_then(|i| ImageBlob::cmyk_tiff(&i)) + } else { + use image::ImageEncoder as _; + let colour = match (s.channels, opacity.is_some()) { + (1, false) => image::ExtendedColorType::L8, + (1, true) => image::ExtendedColorType::La8, + (_, false) => image::ExtendedColorType::Rgb8, + (_, true) => image::ExtendedColorType::Rgba8, + }; + let mut png = vec![]; + image::codecs::png::PngEncoder::new(&mut png).write_image(&pixels, ow, oh, colour).ok().map(|()| ImageBlob::new("image/png", png)) + }; + // One that can't be encoded is left to the page's art, as one past the hard limits. + let Some(blob) = blob else { + return self.too_large_image(m, s.w, s.h); + }; + // Each pixel as read stands for a block of the full size's. + let (sx, sy) = (s.w as f64 / f64::from(ow), s.h as f64 / f64::from(oh)); + let m: Vec = match m { + [a, b, c, d, tx, ty] => vec![a * sx, b * sx, c * sy, d * sy, *tx, *ty], + other => other.to_vec(), + }; + if k > 1 { + self.warn(&format!("an embedded image of {} × {} pixels was reduced to {ow} × {oh} pixels to keep the layers", s.w, s.h)); + } + self.add_raster(blob, mask.as_deref(), &m, (ow, oh), colorize) + } + + /// An image of `w` × `h` pixels past the hard limits, which matrix `m` maps into art space: a + /// box for the page's art to replace ([`Reader::unread_box`]), or, without page art, left out + /// with a note. + fn too_large_image(&mut self, m: &[f64], w: u64, h: u64) -> Result<(), String> { + if !self.page_art { + self.image_left_out(IMAGE_TOO_LARGE); + return Ok(()); + } + let [a, b, c, d, tx, ty] = m else { + self.image_left_out("an image VectorCraft can't read was left out"); + return Ok(()); + }; + let xf = self.space() * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP; + let bounds = xf.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + self.unread_box(LARGE_IMAGES, bounds, false).map(|_| ()) + } + /// Add the image `blob` (`w` × `h` pixels), which the matrix `m` maps into art space, with /// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the /// image, and `colorize` as the color that colorizes it. @@ -978,6 +1055,20 @@ fn cmyk_rgb([c, m, y, k]: [u8; 4]) -> [u8; 3] { [rgb[0], rgb[1], rgb[2]] } +/// The colour channels of an image in the colour space `space` (`XN`), else of image type `kind`. +fn channels(space: &str, kind: f64) -> usize { + match space { + "DeviceGray" => 1, + "DeviceCMYK" => 4, + "DeviceRGB" => 3, + _ => match kind as i64 { + 1 => 1, + 4 => 4, + _ => 3, + }, + } +} + /// `a`, `a and b`, `a, b and c`. pub(crate) fn join_and(items: &[&str]) -> String { match items { diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index dfc55aa7a..68f981751 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -3,8 +3,9 @@ use std::collections::HashMap; use super::super::brush; use super::art::{four, sane, two}; use super::ops::Dictionaries; +use super::raster::{end_hex, push_hex}; use super::types::{ - FLIP, GState, Kind, MAX_DEPTH, MAX_HEX, MAX_LISTED, MAX_NODES, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, MAX_SYMBOLS, + FLIP, GState, Kind, MAX_DEPTH, MAX_LISTED, MAX_NODES, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, MAX_SYMBOLS, PatternHead, Place, Raster, SKIPPED, SymbolHead, VERSIONED_UNENDED, }; use super::{Arc, BrushArtColors, Global, Lexer, Node, NodeKind, PatternDef, Reader, Rect, Tok, V, marker, obj}; @@ -52,9 +53,7 @@ impl<'a> Reader<'a> { && !line.is_empty() && line.bytes().all(|b| b.is_ascii_hexdigit()) { - if r.hex.len() < MAX_HEX { - r.hex.extend_from_slice(line.as_bytes()); - } + push_hex(&mut r.samples, &mut r.half, line.as_bytes(), r.max); return Ok(()); } // The header's comments (AI7 specification §2.1.1; DSC 3.0). @@ -182,8 +181,9 @@ impl<'a> Reader<'a> { } else if line.starts_with("AI5_EndRaster") { // A file `XG` named in this raster object is no later one's. self.image_link = None; - if let Some(Raster { operands: Some(vals), hex, .. }) = self.raster.take() { - return self.image_from(vals, &hex); + if let Some(Raster { operands: Some(vals), mut samples, half, max, .. }) = self.raster.take() { + end_hex(&mut samples, half, max); + return self.image_from(vals, &samples, true); } } else if line.starts_with("AI5_BeginPlace") { // One that is still open (no `~` ended it) isn't read. diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index d5d28aab6..4861e342d 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -390,8 +390,10 @@ impl<'a> Reader<'a> { "TZ" => self.tz(&vals), // An image whose samples follow in comment lines (read at `%AI5_EndRaster`). "XI" if self.raster.is_some() => { + let max = self.sample_bytes(&vals); if let Some(r) = self.raster.as_mut() { r.operands = Some(vals); + r.max = max; } } // A linked image (§8.2, §8.3): `(path) modified XG` names the file of the `XF` image after diff --git a/crates/eps/src/import/ai/read/raster.rs b/crates/eps/src/import/ai/read/raster.rs new file mode 100644 index 000000000..da88fd144 --- /dev/null +++ b/crates/eps/src/import/ai/read/raster.rs @@ -0,0 +1,268 @@ +//! The size of an embedded image (`XI`), checked before its samples are decoded: read at full size +//! up to a target number of pixels, averaged down in whole steps past it, and not read at all past +//! the hard limits ([`Limits`]). + +use std::io::Write as _; + +/// Images up to this many pixels read as they always have: an RGBA PNG, or a CMYK TIFF. +pub(super) const SMALL: u64 = 1 << 25; +/// Most bytes of one image's samples, its alpha channel included. +pub(super) const MAX_BYTES: u64 = 512 << 20; +/// Most pixels of one image. +pub(super) const MAX_PIXELS: u64 = 1 << 31; +/// Longest side of an image as it comes in (pixels). +const MAX_SIDE: u64 = 65_535; + +/// How large an image is read. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Limits { + /// Most pixels of an image as it comes in; larger ones are reduced. + pub(super) target: u64, + /// Most pixels of an image read at all. + pub(super) pixels: u64, + /// Most bytes of an image's samples, its alpha channel included. + pub(super) bytes: u64, +} + +#[cfg(test)] +thread_local! { + static TEST_LIMITS: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +impl Limits { + /// The limits on this machine: [`target`] pixels, [`MAX_PIXELS`] and [`MAX_BYTES`]. + pub(super) fn get() -> Self { + #[cfg(test)] + if let Some(l) = TEST_LIMITS.with(std::cell::Cell::get) { + return l; + } + static TARGET: std::sync::OnceLock = std::sync::OnceLock::new(); + let target = *TARGET.get_or_init(|| target(vectorcraft_render::installed_memory())); + Self { target, pixels: MAX_PIXELS, bytes: MAX_BYTES } + } + + /// The limits for an image of `channels` colour channels: a CMYK image comes in at most as + /// large as a CMYK TIFF holds ([`vectorcraft_doc::cmyk::MAX_CMYK_PIXELS`]). + pub(super) fn for_channels(channels: usize) -> Self { + let mut l = Self::get(); + if channels == 4 { + l.target = l.target.min(vectorcraft_doc::cmyk::MAX_CMYK_PIXELS); + } + l + } +} + +/// `f` run with images read under `target` pixels, `pixels` and `bytes` in place of this machine's +/// limits. +#[cfg(test)] +pub(crate) fn with_image_limits(target: u64, pixels: u64, bytes: u64, f: impl FnOnce() -> T) -> T { + TEST_LIMITS.with(|l| l.set(Some(Limits { target, pixels, bytes }))); + let out = f(); + TEST_LIMITS.with(|l| l.set(None)); + out +} + +/// Most pixels of an image as it comes in, on a machine with `memory` bytes installed (`None`: not +/// known): a thirty-second of it, from 2^26 to 2^28 pixels (an RGBA decode of at most 1 GiB); on the +/// web, a quarter of the largest image the renderer decodes, at most 2^27. +fn target(memory: Option) -> u64 { + if cfg!(target_arch = "wasm32") { + (1u64 << 27).min(vectorcraft_doc::MAX_RASTER_ALLOC / 4) + } else { + memory.map_or(1 << 26, |m| (m / 32).clamp(1 << 26, 1 << 28)) + } +} + +/// An image's layout from its `XI` operands. +#[derive(Clone, Copy, Debug)] +pub(super) struct Shape { + pub(super) w: u64, + pub(super) h: u64, + /// Colour channels a pixel: 1, 3 or 4. + pub(super) channels: u64, + /// Bits a channel: 1 or 8. + pub(super) bits: u64, + /// An alpha channel, a byte a pixel, follows the colour samples. + pub(super) alpha: bool, +} + +impl Shape { + /// Bytes in a row of colour samples (a 1-bit row is padded to a whole byte). + pub(super) fn row(&self) -> Option { + Some(self.w.checked_mul(self.channels)?.checked_mul(self.bits)?.div_ceil(8)) + } + + /// Bytes of colour samples, and of the alpha channel. + pub(super) fn lens(&self) -> Option<(u64, u64)> { + let colour = self.row()?.checked_mul(self.h)?; + let alpha = if self.alpha { self.w.checked_mul(self.h)? } else { 0 }; + colour.checked_add(alpha)?; + Some((colour, alpha)) + } + + /// The step `k` the image is reduced by (1: full size): the smallest that brings it within + /// `limits.target` pixels and [`MAX_SIDE`] a side. `None` past `limits.pixels` or + /// `limits.bytes`. + pub(super) fn step(&self, limits: &Limits) -> Option { + let pixels = self.w.checked_mul(self.h)?; + let (colour, alpha) = self.lens()?; + if pixels > limits.pixels || colour + alpha > limits.bytes { + return None; + } + let fits = |k: u64| { + let (w, h) = (self.w.div_ceil(k), self.h.div_ceil(k)); + w <= MAX_SIDE && h <= MAX_SIDE && w.saturating_mul(h) <= limits.target.max(1) + }; + // No step below these brings it within the limits. + let mut k = ((pixels as f64 / limits.target.max(1) as f64).sqrt().floor() as u64) + .max(self.w.div_ceil(MAX_SIDE)) + .max(self.h.div_ceil(MAX_SIDE)) + .max(1); + while !fits(k) { + k += 1; + } + Some(k) + } +} + +/// The samples of the image `s` (rows of [`Shape::row`] bytes; a set bit is the channel at full +/// strength) and its `alpha` channel averaged over blocks of `k` × `k` pixels into a byte a channel, +/// each block at the right and bottom edges over the pixels it has. With `inline`, the alpha follows +/// each pixel's channels; otherwise it comes back on its own. +pub(super) fn reduce(samples: &[u8], alpha: Option<&[u8]>, s: Shape, k: u64, inline: bool) -> (Vec, Option>) { + let (w, h, k) = (s.w as usize, s.h as usize, k.max(1) as usize); + let (ch, row) = (s.channels as usize, s.row().unwrap_or(0) as usize); + let (ow, oh) = (w.div_ceil(k), h.div_ceil(k)); + let n = ch + usize::from(alpha.is_some()); + let mut out = Vec::with_capacity(ow * oh * if inline { n } else { ch }); + let mut alpha_out = (alpha.is_some() && !inline).then(|| Vec::with_capacity(ow * oh)); + let mut sums = vec![0u64; ow * n]; + for oy in 0..oh { + sums.fill(0); + let rows = oy * k..((oy + 1) * k).min(h); + let tall = rows.len() as u64; + for y in rows { + let line = samples.get(y * row..(y + 1) * row).unwrap_or_default(); + let opacity = alpha.and_then(|a| a.get(y * w..(y + 1) * w)); + for x in 0..w { + let at = x / k * n; + for c in 0..ch { + let i = x * ch + c; + let v = + if s.bits == 1 { line.get(i / 8).map_or(0, |b| ((b >> (7 - i % 8)) & 1) * 255) } else { line.get(i).copied().unwrap_or(0) }; + sums[at + c] += u64::from(v); + } + if alpha.is_some() { + sums[at + ch] += u64::from(opacity.and_then(|o| o.get(x)).copied().unwrap_or(255)); + } + } + } + for ox in 0..ow { + let count = ((((ox + 1) * k).min(w) - ox * k) as u64 * tall).max(1); + let avg = |sum: u64| ((sum + count / 2) / count) as u8; + out.extend(sums[ox * n..ox * n + ch].iter().map(|v| avg(*v))); + if alpha.is_some() { + let a = avg(sums[ox * n + ch]); + match alpha_out.as_mut() { + Some(o) => o.push(a), + None => out.push(a), + } + } + } + } + (out, alpha_out) +} + +/// A grayscale PNG of 1-bit rows as they are (`row` bytes each, a set bit white): `w` × `h` +/// pixels without expanding them. +pub(super) fn gray1_png(samples: &[u8], w: u32, h: u32, row: usize) -> Option> { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + for y in 0..h as usize { + // Filter type 0 (none), then the row. + z.write_all(&[0]).ok()?; + z.write_all(samples.get(y * row..(y + 1) * row)?).ok()?; + } + let data = z.finish().ok()?; + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + // Bit depth 1, colour type 0 (gray), deflate, filter method 0, no interlacing. + let ihdr = [&w.to_be_bytes()[..], &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat(); + for (ty, body) in [(b"IHDR", ihdr.as_slice()), (b"IDAT", data.as_slice()), (b"IEND", &[])] { + png.extend_from_slice(&u32::try_from(body.len()).ok()?.to_be_bytes()); + png.extend_from_slice(ty); + png.extend_from_slice(body); + let mut crc = flate2::Crc::new(); + crc.update(ty); + crc.update(body); + png.extend_from_slice(&crc.sum().to_be_bytes()); + } + Some(png) +} + +/// Decode the hex digits in `text` onto `out`, `half` holding a digit that waits for its pair, up +/// to `max` bytes; other characters are skipped. +pub(super) fn push_hex(out: &mut Vec, half: &mut Option, text: &[u8], max: usize) { + for &b in text { + if out.len() >= max { + return; + } + let Some(v) = char::from(b).to_digit(16) else { continue }; + match half.take() { + Some(high) => out.push(high << 4 | v as u8), + None => *half = Some(v as u8), + } + } +} + +/// The last digit of [`push_hex`]'s, without its pair, as the byte it starts. +pub(super) fn end_hex(out: &mut Vec, half: Option, max: usize) { + if let Some(high) = half + && out.len() < max + { + out.push(high << 4); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A CMYK image is reduced to the size a CMYK TIFF holds, whatever the target. + #[test] + fn cmyk_images_come_in_at_most_as_large_as_a_cmyk_tiff() { + let shape = |channels| Shape { w: 9000, h: 9000, channels, bits: 8, alpha: false }; + let (rgb, cmyk) = with_image_limits(1 << 28, MAX_PIXELS, MAX_BYTES * 4, || { + (shape(3).step(&Limits::for_channels(3)), shape(4).step(&Limits::for_channels(4))) + }); + assert_eq!(rgb, Some(1)); + let k = cmyk.unwrap(); + assert_eq!(k, 2); + assert!(9000u64.div_ceil(k).pow(2) <= vectorcraft_doc::cmyk::MAX_CMYK_PIXELS); + } + + /// The step keeps each side within 65,535 pixels, and sizes past the limits or overflowing + /// aren't read. + #[test] + fn steps_keep_sides_within_the_limit() { + let limits = Limits { target: 1 << 28, pixels: MAX_PIXELS, bytes: MAX_BYTES }; + let long = Shape { w: 200_000, h: 1, channels: 1, bits: 1, alpha: false }; + assert_eq!(long.step(&limits), Some(4)); + let huge = Shape { w: u64::MAX, h: 2, channels: 3, bits: 8, alpha: true }; + assert_eq!(huge.step(&limits), None); + let many = Shape { w: 1 << 16, h: 1 << 16, channels: 1, bits: 1, alpha: false }; + assert_eq!(many.step(&limits), None); + } + + /// Hex digits are decoded across calls, other characters skipped, up to the bytes asked for. + #[test] + fn hex_is_decoded_up_to_the_bytes_asked_for() { + let (mut out, mut half) = (vec![], None); + push_hex(&mut out, &mut half, b"F f0 1", 8); + push_hex(&mut out, &mut half, b"2a", 8); + end_hex(&mut out, half, 8); + assert_eq!(out, [0xff, 0x01, 0x2a]); + let (mut out, mut half) = (vec![], None); + push_hex(&mut out, &mut half, b"0102030", 2); + end_hex(&mut out, half, 2); + assert_eq!(out, [1, 2]); + } +} diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 5fe93923a..5d34e248e 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -21,17 +21,18 @@ pub(super) const BLEND_BUDGET: usize = 50_000; /// Most points in one path. pub(super) const MAX_POINTS: usize = 4_000_000; -/// Largest image read (pixels). +/// Largest placed file's preview read (pixels). pub(super) const MAX_PIXELS: u64 = 1 << 25; /// Most placed files whose other placement ([`super::Structure::placed_elsewhere`]) is kept. pub(super) const MAX_PLACED_ELSEWHERE: usize = 4096; -/// The note for an image past [`MAX_PIXELS`]: the file then opens from its PDF part, which reads -/// larger images (#1479), unless that part can't be read either; then the image is left out. -pub const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out"; +/// The note for an image past the hard limits ([`super::raster::Limits`]) in a file without page +/// art to replace it. +pub(super) const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out"; -/// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size). -pub(super) const MAX_HEX: usize = (MAX_PIXELS as usize) * 10; +/// What an image past the hard limits is, as the notes name it: with page art, its box becomes +/// that art (#1479). +pub(super) const LARGE_IMAGES: &str = "very large images"; /// Largest side of the document and its artboards (points). pub(super) const MAX_SIDE: f64 = 1e6; @@ -281,8 +282,12 @@ pub(super) struct Raster { /// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to /// `%AI5_EndRaster`, the form the version 7 specification documents (§8). pub(super) operands: Option>, - /// Those hex digits. - pub(super) hex: Vec, + /// The bytes of those hex digits, up to `max`. + pub(super) samples: Vec, + /// The bytes of samples the image reads (0 when it isn't read). + pub(super) max: usize, + /// A digit waiting for its pair. + pub(super) half: Option, } /// A placed file's operands, from `` ` `` and its preview, until `~`. diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index e5c927670..07236c2c6 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -783,7 +783,7 @@ fn pattern_names_are_shown_up_to_their_newline() { // Nor the name of a custom color the program of an EPS gives, or None's. let ps = b"%!PS-Adobe-3.0\n%%CMYKCustomColor: 0 0 1 0 (Outer Ink)\n%%EndComments\n"; let data = stream(&format!("{}{}{}", def("Outer Ink\\n1", 8), def("[None]\\n1", 8), layer(&filled("Outer Ink\\n1")))); - let doc = super::read_beside(data.as_bytes(), true, ps).unwrap().doc; + let doc = super::read_beside(data.as_bytes(), true, ps, false).unwrap().doc; assert_eq!(doc.patterns.iter().map(|p| p.name.as_str()).collect::>(), ["Outer Ink 2", "[None] 2"]); assert!(matches!(doc.swatch("Outer Ink").unwrap().paint, Paint::Solid { .. })); assert!(matches!(fill(&art(&doc)[0]), Paint::Pattern { pattern, .. } if pattern == "Outer Ink 2")); @@ -2613,6 +2613,29 @@ fn a_drawn_look_that_is_an_image_stands_for_its_object() { assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings); } +/// Samples in hex comment lines after `XI` (the version 7 specification's form) are read up to the +/// bytes the image needs; an image past the hard limits isn't read, whatever lines follow it. +#[test] +fn hex_comment_line_samples_are_read_up_to_the_image() { + let raster = |(w, h): (u32, u32), lines: &str| { + let s = format!( + "%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n\ + [ 1 0 0 1 0 100 ] {w} {h} 0 Xh\n[ 1 0 0 1 0 100 ] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI\n{lines}%AI5_EndRaster\nN\nLB\n%AI5_EndLayer--\n" + ); + let mut out = stream("").into_bytes(); + let at = out.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap(); + out.splice(at..at, s.into_bytes()); + out + }; + // 2 × 1: red, green, then digits past the image's six bytes. + let r = read(&raster((2, 1), "%FF00\n%0000FF\n%00FFFFFF\n")).unwrap(); + let img = pixels(&r.doc).0; + assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 255, 0, 255])); + let r = read(&raster((70_000, 70_000), "%FF0000\n%00FF00\n")).unwrap(); + assert!(art(&r.doc).is_empty()); + assert!(r.warnings.iter().any(|w| w == "an image too large to read from the editing data was left out"), "{:?}", r.warnings); +} + /// The name and opacity mask after an image follow the operators that end its raster: `XH`, the /// versioned `Xr` and the `N` after `%AI5_EndRaster`. #[test] @@ -2911,7 +2934,7 @@ fn a_version_7_linked_image_without_its_file_name_is_left_out_with_a_warning() { let r = read(stream7(&layer7("L", &raster7("(a.tif) 0 XG\n", m, (100_000, 100_000), "XF", ""))).as_bytes()).unwrap(); let a = art(&r.doc); assert!(matches!(&a[..], [n] if matches!(&n.kind, NodeKind::Image(im) if (im.width, im.height) == (100_000, 100_000))), "{a:?}"); - assert!(!r.warnings.iter().any(|w| w == read::IMAGE_TOO_LARGE), "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w == "an image too large to read from the editing data was left out"), "{:?}", r.warnings); let twice = raster7("(a.tif) 0 XG\n(b.tif) 0 XG\n", m, (2, 2), "XF", ""); let r = read(stream7(&layer7("L", &twice)).as_bytes()).unwrap(); let a = art(&r.doc); diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 7e6dd4cfc..57d961181 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -59,7 +59,9 @@ pub(super) fn build( ) -> Result<(Document, Vec), String> { // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art // space, as its PDF part may (#2031). - build_from(ai::read(data, matches!(page, Page::Art))?, data, visible, page, outlined, trust, unit) + let large_canvas = matches!(page, Page::Art); + let structure = if visible.is_some() { ai::read_over_page(data, large_canvas)? } else { ai::read(data, large_canvas)? }; + build_from(structure, data, visible, page, outlined, trust, unit) } /// [`build`] without a page, for a `.ai` whose PDF part can't be read, its first page `page` diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 0575eebcc..597979855 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -24,7 +24,7 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus // can't be used. // Private data kept apart from the program: the program's own header gives the author and // custom colors the private data's header may lack. - let read = |d: &[u8]| if matches!(data, Ok(Cow::Owned(_))) { ai::read_beside(d, true, ps) } else { ai::read(d, true) }; + let read = |d: &[u8]| if matches!(data, Ok(Cow::Owned(_))) { ai::read_beside(d, true, ps, true) } else { ai::read_over_page(d, true) }; let (tree, result) = match data.as_deref() { Ok(d) => match read(d) { Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)), @@ -160,14 +160,9 @@ pub fn layered_ai_from( (visible, all, false) }; let built = structure - .unwrap_or_else(|| ai::read(&data, false).map(Box::new)) + .unwrap_or_else(|| ai::read_over_page(&data, false).map(Box::new)) .and_then(|structure| build_from(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit)); match built { - // An image too large for the editing data's reader would be missing from the layers, while - // the PDF part has it: the file comes in as its PDF part instead (#1479). - Ok((_, notes)) if notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => { - from_pdf_part(visible, warnings, "an image is too large to read from them") - } Ok((mut done, notes)) => { done.setup.bleed = visible.setup.bleed; let mut all = warnings; @@ -193,14 +188,13 @@ pub fn ai_layers_alone(private: &[u8], pages: &[(f64, f64)], unit: f64) -> Resul if !has_layers(&data) { return Err(AiLayers { data: Ok(data), structure: None }); } - let structure = match ai::read(&data, false) { + // An image past the hard limits is read as an unread box, which the PDF part's art replaces. + let structure = match ai::read_over_page(&data, false) { Ok(structure) => structure, Err(why) => return Err(AiLayers { data: Ok(data), structure: Some(Err(why)) }), }; match build_without_page(structure, &data, pages, unit) { - // An image too large for the editing data's reader is missing from the layers: the PDF - // part has it (as in `layered_ai`). - Ok(Ok((doc, notes))) if !notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => Ok((doc, notes)), + Ok(Ok((doc, notes))) => Ok((doc, notes)), Ok(_) => Err(AiLayers { data: Ok(data), structure: None }), Err(structure) => Err(AiLayers { data: Ok(data), structure: Some(Ok(structure)) }), } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 32c4ff796..1a3ec8ff9 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1215,27 +1215,89 @@ fn in_points(mut d: Document, k: f64) -> Document { d } -/// #1479: an image larger than the editing data's reader takes would be missing from the layers, -/// while the PDF part has it (it reads larger images): the file comes in as its PDF part, with a -/// note saying why, rather than without the image. +/// A 1-bit gray image of `w` × `h` pixels in the format's operators, its samples `rows` (binary, +/// rows padded to whole bytes, a set bit white), placed by the matrix `m`. +fn raster_1bit(m: &str, (w, h): (u32, u32), rows: &str) -> String { + format!( + "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ {m} ] {w} {h} 0 Xh\n\ + [ {m} ] 0 0 {w} {h} {w} {h} 1 1 0 0 1 0 4 4 0 0\n%%BeginData: {}\rXI\n{rows}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN", + rows.len() + ) +} + +/// #1479: an image past 2^25 pixels keeps the layers, at full size; a 1-bit one is a 1-bit PNG of +/// its rows. #[test] -fn an_ai_file_with_an_image_too_large_for_its_editing_data_comes_in_as_its_pdf_part() { - let raster = |w: u32, h: u32| { - format!( - "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 1 0 0 1 0 100 ] {w} {h} 0 Xh\n\ - [ 1 0 0 1 0 100 ] 0 0 {w} {h} {w} {h} 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN" - ) - }; - // 6,000 × 6,000 pixels: past the reader's 2^25. - let art = native(&layer("Texture", true, &format!("{}\n{}", square(10, 10), raster(6000, 6000)))); +fn an_ai_file_with_an_image_past_2_25_pixels_keeps_its_layers() { + // 6,000 × 6,000 pixels, each row black and white in turn ('U' is 01010101). + let image = raster_1bit("0.0005 0 0 0.0005 50 14", (6000, 6000), &"U".repeat(750 * 6000)); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Texture", true, &image))); let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); - assert!(!read, "the PDF part is used: {warnings:?}"); - assert!(warnings.iter().any(|w| w.contains("an image is too large to read from them")), "{warnings:?}"); - assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 1, "the PDF part's art"); - // An image the reader takes keeps the layers. - let art = native(&layer("Texture", true, &format!("{}\n{}", square(10, 10), raster(1, 1)))); - let (_, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); assert!(read, "{warnings:?}"); + assert_eq!(names(&d), ["Live", "Texture"]); + assert_eq!(kinds(&d.layers[0]), ["path"]); + let [image] = d.layers[1].children().unwrap().as_slice() else { panic!("{:?}", d.layers[1]) }; + let NodeKind::Image(im) = &image.kind else { panic!("{image:?}") }; + assert_eq!((im.width, im.height), (6000, 6000)); + let png = &d.images[&im.key].bytes; + // Bit depth 1, colour type 0 (gray). + assert_eq!(png.get(24..26), Some([1, 0].as_slice())); + let gray = image::load_from_memory(png).unwrap().to_luma8(); + assert_eq!((gray.get_pixel(0, 0)[0], gray.get_pixel(1, 0)[0], gray.get_pixel(5999, 5999)[0]), (0, 255, 255)); + assert!(warnings.iter().all(|w| !w.contains("image")), "{warnings:?}"); +} + +/// An image past the target size is averaged down by the smallest whole step that fits, a 1-bit +/// one into gray coverage, the blocks at its edges over the pixels they have; it covers the box +/// the full size would. +#[test] +fn an_ai_image_past_the_target_size_is_reduced_and_keeps_the_layers() { + // 17 × 13 pixels: rows black, and in turn (the first one) white but for columns 0, 8 and 16. + let rows: String = (0..13).map(|y| if y % 2 == 0 { "\u{7f}\u{7f}\0" } else { "\0\0\0" }).collect(); + let image = raster_1bit("1 0 0 1 50 14", (17, 13), &rows); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Texture", true, &image))); + let (d, warnings, read) = crate::import::ai::with_image_limits(20, 1 << 31, 512 << 20, || { + crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0) + }); + assert!(read, "{warnings:?}"); + assert_eq!(names(&d), ["Live", "Texture"]); + assert!(warnings.contains(&"an embedded image of 17 × 13 pixels was reduced to 5 × 4 pixels to keep the layers".to_string()), "{warnings:?}"); + let [image] = d.layers[1].children().unwrap().as_slice() else { panic!("{:?}", d.layers[1]) }; + let NodeKind::Image(im) = &image.kind else { panic!("{image:?}") }; + assert_eq!((im.width, im.height), (5, 4)); + let full = im.xf.transform_rect_bbox(vectorcraft_geom::Rect::new(0.0, 0.0, 5.0, 4.0)); + assert!( + (full.x0 - 50.0).abs() < 1e-9 && (full.x1 - 67.0).abs() < 1e-9 && (full.y0 - 86.0).abs() < 1e-9 && (full.y1 - 99.0).abs() < 1e-9, + "{full:?}" + ); + let gray = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8(); + // Steps of 4: blocks of rows 0–11 are half white rows, the last block row 12 alone; blocks of + // columns 0–3 and 8–11 have 3 white columns of 4, and the last block column 16 alone. + let rows: Vec> = (0..4).map(|y| (0..5).map(|x| gray.get_pixel(x, y)[0]).collect()).collect(); + assert_eq!(rows, [vec![96, 128, 96, 128, 0], vec![96, 128, 96, 128, 0], vec![96, 128, 96, 128, 0], vec![191, 255, 191, 255, 0]]); +} + +/// An image past the hard limits is a box that the page's art inside it replaces, and the +/// layers stay; without page art (the editing data alone), it is left out with a note. +#[test] +fn an_ai_image_past_the_hard_limits_comes_in_as_the_pages_art() { + let image = raster_1bit("1 0 0 1 50 14", (4, 4), &"\0".repeat(4)); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Texture", true, &image))); + let (d, notes, read) = + crate::import::ai::with_image_limits(20, 15, 512 << 20, || crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0)); + assert!(read, "{notes:?}"); + assert_eq!(names(&d), ["Live", "Texture"]); + assert_eq!(kinds(&d.layers[1]), ["group[1]"]); + replacement_is_cyan(&d, 1, 0); + assert!(notes.iter().any(|n| n.contains("very large images") && n.contains("they came in as the page's art")), "{notes:?}"); + // Past the bytes the samples may take, too. + let (_, notes, read) = + crate::import::ai::with_image_limits(20, 1 << 31, 1, || crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0)); + assert!(read && notes.iter().any(|n| n.contains("very large images")), "{notes:?}"); + let (d, notes) = crate::import::ai::with_image_limits(20, 15, 512 << 20, || crate::ai_alone(&private(&art))).unwrap(); + assert_eq!(names(&d), ["Live", "Texture"]); + assert!(d.layers[1].children().unwrap().is_empty()); + assert!(notes.iter().any(|n| n == "an image too large to read from the editing data was left out"), "{notes:?}"); } /// A `.ai` whose layers hold a placed PDF and no text object: its PDF part draws the placed file, @@ -1322,7 +1384,9 @@ fn an_ai_file_that_comes_in_as_its_pdf_part_keeps_the_swatches_of_its_palette() let mut part = pdf_part(10.0, 86.0); let gold = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.2, 0.9, 0.1)); part.swatches.push(vectorcraft_color::swatch::Swatch { name: "Gold ink".into(), paint: gold, global: true, spot: true }); - let (d, warnings, read) = crate::layered_ai(&private(&art), part, vec![], false, true, 1.0); + // An image past 2^25 pixels isn't read here, and the page has no art of its own in its box. + let (d, warnings, read) = + crate::import::ai::with_image_limits(1 << 26, 1 << 25, 512 << 20, || crate::layered_ai(&private(&art), part, vec![], false, true, 1.0)); assert!(!read, "the PDF part is used: {warnings:?}"); let swatches: Vec<&str> = d.swatches.iter().map(|s| s.name.as_str()).collect(); assert_eq!(swatches, ["[None]", "White", "Gold ink"], "{warnings:?}"); diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs index f47d8e69b..017df5a80 100644 --- a/crates/plugins/src/host.rs +++ b/crates/plugins/src/host.rs @@ -1,20 +1,28 @@ //! What a plug-in may import from the host ([`IMPORTS`]), from plug-in API level 2 on: //! `vc_progress` reports how far a run has got, `vc_cancelled` tells it the user or a host limit -//! asked it to stop, and `vc_log` writes a log line. A module may import them only when its -//! manifest gives `api` 2 and lists the `progress` permission, and the host checks each import when -//! the module is loaded ([`check_imports`], [`check_granted`]): one it doesn't offer, one with -//! another signature and one the manifest doesn't allow are refused by name. +//! asked it to stop, and `vc_log` writes a log line (the `progress` permission); `vc_settings_*` +//! read and write its settings (`settings`) and `vc_cache_*` its cache (`cache`, [`crate::storage`]). +//! A module may import one only when its manifest gives `api` 2 and lists the import's permission, +//! and the host checks each import when the module is loaded ([`check_imports`], [`check_granted`]): +//! one it doesn't offer, one with another signature and one the manifest doesn't allow are refused +//! by name. //! //! Each call's arguments are checked: progress is clamped and passed on only when it changes (with //! a known total, to another tenth of a percent; past [`MAX_PROGRESS_REPORTS`], once per //! [`PROGRESS_STRIDE`] changes), log text must lie inside the module's memory and be UTF-8, and //! each line ([`MAX_LOG_LINE`]) and each run ([`MAX_LOG_LINES`], [`MAX_LOG_BYTES`], -//! [`MAX_LOG_READ`]) are capped. Nothing a module passes can panic the host. +//! [`MAX_LOG_READ`]) are capped. Keys and values must lie inside the module's memory, keys must be +//! [`crate::storage::valid_name`]s, values are capped, and so are each run's storage calls +//! ([`MAX_STORAGE_CALLS`]) and the bytes they move ([`MAX_STORAGE_BYTES`]). Nothing a module passes +//! can panic the host. + +use std::sync::Arc; use wasmi::{Caller, Engine, Extern, ExternType, Linker, Module, StoreLimits, ValType}; use crate::manifest::Manifest; use crate::permissions::{self, PROGRESS_API, Permission}; +use crate::storage::{self, Meter, Storage, StorageError}; use crate::{Error, Result}; /// The module a plug-in imports host functions from (the name a Rust or C toolchain gives @@ -60,16 +68,51 @@ pub const LOG_NOT_UTF8: i32 = -2; /// What `vc_log` returns: the level is not 0 to 3. pub const LOG_BAD_LEVEL: i32 = -3; -/// One host import: its name and its type. +/// What a storage import that writes or removes returns when it did. +pub const STORAGE_DONE: i32 = 0; +/// What a storage import returns: the key or entry has no value. +pub const STORAGE_NOT_FOUND: i32 = -1; +/// What a storage import returns: a key, value or buffer doesn't lie inside the module's memory. +pub const STORAGE_OUTSIDE_MEMORY: i32 = -2; +/// What a storage import returns: the key is not a [`crate::storage::valid_name`]. +pub const STORAGE_BAD_NAME: i32 = -3; +/// What a storage import returns: the write would pass a cap ([`crate::storage`]); nothing changed. +pub const STORAGE_FULL: i32 = -4; +/// What a storage import returns: there is nowhere to keep anything (no plug-in store, as on the +/// web, or a module that isn't installed). +pub const STORAGE_UNAVAILABLE: i32 = -5; +/// What a storage import returns: reading or writing the file failed. +pub const STORAGE_FAILED: i32 = -6; +/// What a storage import returns: the run used up its storage calls or bytes +/// ([`MAX_STORAGE_CALLS`], [`MAX_STORAGE_BYTES`]). +pub const STORAGE_BUDGET: i32 = -7; + +/// Most storage calls one run makes; later ones return [`STORAGE_BUDGET`]. +pub const MAX_STORAGE_CALLS: usize = 10_000; + +/// Most bytes one run's storage calls read and write, all together, counting the whole settings +/// file each settings call reads and writes and each cache entry read or written; a call that would +/// pass it returns [`STORAGE_BUDGET`]. +pub const MAX_STORAGE_BYTES: usize = 1 << 30; + +/// One host import: its name, its type and the permission it needs. pub struct Import { pub name: &'static str, params: &'static [ValType], results: &'static [ValType], /// Its type as WebAssembly text writes it. pub signature: &'static str, + pub permission: Permission, } -/// The host imports of API level 2, in module [`MODULE`]: +const I32: ValType = ValType::I32; + +/// An import that takes `params` i32 arguments and returns an i32. +const fn storage_import(name: &'static str, params: &'static [ValType], signature: &'static str, permission: Permission) -> Import { + Import { name, params, results: &[I32], signature, permission } +} + +/// The host imports of API level 2, in module [`MODULE`]. With `progress`: /// /// - `vc_progress(done: i64, total: i64)`: `done` of `total` steps are finished (`total` 0 or less: /// not known). Negative values count as 0 and `done` is clamped to `total`; @@ -77,17 +120,71 @@ pub struct Import { /// - `vc_log(level: i32, ptr: i32, len: i32) -> i32`: writes the UTF-8 text at `ptr` as a log line /// of `level` (0 error, 1 warning, 2 information, 3 debug) → [`LOG_WRITTEN`], [`LOG_DROPPED`], /// [`LOG_OUTSIDE_MEMORY`], [`LOG_NOT_UTF8`] or [`LOG_BAD_LEVEL`]. -pub const IMPORTS: [Import; 3] = [ - Import { name: "vc_progress", params: &[ValType::I64, ValType::I64], results: &[], signature: "(param i64 i64)" }, - Import { name: "vc_cancelled", params: &[], results: &[ValType::I32], signature: "(result i32)" }, +/// +/// With `settings`, for the key of `key_len` bytes at `key`: +/// +/// - `vc_settings_get(key, key_len, out, out_cap) -> i32`: the value's length, its bytes copied to +/// `out` when they fit in `out_cap` (nothing is copied otherwise); +/// - `vc_settings_set(key, key_len, value, value_len) -> i32`: sets it → [`STORAGE_DONE`]; +/// - `vc_settings_delete(key, key_len) -> i32`: removes it → [`STORAGE_DONE`]. +/// +/// With `cache`, the same for the entry named at `name`: `vc_cache_read`, `vc_cache_write` and +/// `vc_cache_clear(name, name_len)`, which removes the entry, or every entry when `name_len` is 0. +/// +/// A storage call that fails returns [`STORAGE_NOT_FOUND`], [`STORAGE_OUTSIDE_MEMORY`], +/// [`STORAGE_BAD_NAME`], [`STORAGE_FULL`], [`STORAGE_UNAVAILABLE`], [`STORAGE_FAILED`] or +/// [`STORAGE_BUDGET`]. +pub const IMPORTS: [Import; 9] = [ + Import { + name: "vc_progress", + params: &[ValType::I64, ValType::I64], + results: &[], + signature: "(param i64 i64)", + permission: Permission::Progress, + }, + Import { name: "vc_cancelled", params: &[], results: &[I32], signature: "(result i32)", permission: Permission::Progress }, Import { name: "vc_log", - params: &[ValType::I32, ValType::I32, ValType::I32], - results: &[ValType::I32], + params: &[I32, I32, I32], + results: &[I32], signature: "(param i32 i32 i32) (result i32)", + permission: Permission::Progress, }, + storage_import("vc_settings_get", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Settings), + storage_import("vc_settings_set", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Settings), + storage_import("vc_settings_delete", &[I32, I32], "(param i32 i32) (result i32)", Permission::Settings), + storage_import("vc_cache_read", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Cache), + storage_import("vc_cache_write", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Cache), + storage_import("vc_cache_clear", &[I32, I32], "(param i32 i32) (result i32)", Permission::Cache), ]; +/// Which of a plug-in's stores a storage import reads or writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Area { + Settings, + Cache, +} + +impl Area { + /// The largest value one key or entry holds. + fn max_value(self) -> usize { + match self { + Area::Settings => storage::MAX_SETTING_BYTES, + Area::Cache => storage::MAX_CACHE_ENTRY, + } + } +} + +/// What a storage import returns for `e`. +fn storage_code(e: &StorageError) -> i32 { + match e { + StorageError::Full => STORAGE_FULL, + StorageError::Unavailable => STORAGE_UNAVAILABLE, + StorageError::Io(_) => STORAGE_FAILED, + StorageError::Budget => STORAGE_BUDGET, + } +} + /// A log line's level. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LogLevel { @@ -189,6 +286,11 @@ pub(crate) struct Run { /// Bytes of log text written (with their escapes) and read. log_bytes: usize, log_read: usize, + /// Where the plug-in keeps its settings and cache (none while the module is being loaded), and + /// how many storage calls the run made and bytes they moved. + storage: Option>, + storage_calls: usize, + storage_bytes: usize, pub(crate) report: Report, } @@ -204,10 +306,111 @@ impl Run { skipped: 0, log_bytes: 0, log_read: 0, + storage: None, + storage_calls: 0, + storage_bytes: 0, report: Report::default(), } } + /// The run, with the plug-in's settings and cache kept in `storage`. + pub(crate) fn with_storage(mut self, storage: Option>) -> Self { + self.storage = storage; + self + } + + /// Makes one storage call with `call`, on the plug-in's storage and a [`Meter`] holding the + /// run's storage bytes left, and counts the call and the bytes it read and wrote → the code for + /// a run that has used up its calls or has no storage, else what `call` returns. + fn metered(&mut self, call: impl FnOnce(&dyn Storage, &mut Meter) -> std::result::Result) -> std::result::Result { + if self.storage_calls >= MAX_STORAGE_CALLS { + return Err(STORAGE_BUDGET); + } + self.storage_calls += 1; + let Some(store) = self.storage.clone() else { return Err(STORAGE_UNAVAILABLE) }; + let mut meter = Meter::new(MAX_STORAGE_BYTES.saturating_sub(self.storage_bytes) as u64); + let done = call(store.as_ref(), &mut meter); + // What the call read before it failed counts too. + self.storage_bytes = self.storage_bytes.saturating_add(usize::try_from(meter.used()).unwrap_or(usize::MAX)); + done.map_err(|e| storage_code(&e)) + } + + /// The key or entry name of `len` bytes at `ptr` in `memory`, checked, or the code a storage + /// import returns for it. + fn storage_name(memory: &[u8], ptr: usize, len: usize) -> std::result::Result { + let bytes = ptr.checked_add(len).and_then(|end| memory.get(ptr..end)).ok_or(STORAGE_OUTSIDE_MEMORY)?; + let name = std::str::from_utf8(bytes).ok().filter(|n| storage::valid_name(n)).ok_or(STORAGE_BAD_NAME)?; + Ok(name.to_string()) + } + + /// `vc_settings_get` and `vc_cache_read`: copies the value of the key at `name` into the + /// `out_cap` bytes at `out` when it fits → its length. + fn storage_read(&mut self, area: Area, memory: &mut [u8], name: (usize, usize), out: (usize, usize)) -> i32 { + let key = match Self::storage_name(memory, name.0, name.1) { + Ok(key) => key, + Err(code) => return code, + }; + if out.1 > 0 && out.0.checked_add(out.1).is_none_or(|end| end > memory.len()) { + return STORAGE_OUTSIDE_MEMORY; + } + // The storage counts what it read whether or not the value fits the buffer. + let got = self.metered(|store, meter| match area { + Area::Settings => store.setting(&key, meter), + Area::Cache => store.cache_entry(&key, meter), + }); + let value = match got { + Ok(Some(value)) => value, + Ok(None) => return STORAGE_NOT_FOUND, + Err(code) => return code, + }; + let Ok(len) = i32::try_from(value.len()) else { return STORAGE_FAILED }; + if value.len() <= out.1 + && let Some(dest) = memory.get_mut(out.0..out.0 + value.len()) + { + dest.copy_from_slice(&value); + } + len + } + + /// `vc_settings_set` and `vc_cache_write`: sets the key at `name` to the `value` bytes. + fn storage_write(&mut self, area: Area, memory: &[u8], name: (usize, usize), value: (usize, usize)) -> i32 { + let key = match Self::storage_name(memory, name.0, name.1) { + Ok(key) => key, + Err(code) => return code, + }; + let Some(bytes) = value.0.checked_add(value.1).and_then(|end| memory.get(value.0..end)) else { return STORAGE_OUTSIDE_MEMORY }; + if bytes.len() > area.max_value() { + return STORAGE_FULL; + } + let written = self.metered(|store, meter| match area { + Area::Settings => store.set_setting(&key, bytes, meter), + Area::Cache => store.set_cache_entry(&key, bytes, meter), + }); + written.map_or_else(|code| code, |()| STORAGE_DONE) + } + + /// `vc_settings_delete` and `vc_cache_clear`: removes the key at `name`, or with `vc_cache_clear` + /// and a `name` of no bytes, every cache entry. + fn storage_remove(&mut self, area: Area, memory: &[u8], name: (usize, usize)) -> i32 { + let key = match (area, name.1) { + (Area::Cache, 0) => None, + _ => match Self::storage_name(memory, name.0, name.1) { + Ok(key) => Some(key), + Err(code) => return code, + }, + }; + let removed = self.metered(|store, meter| match (area, key) { + (Area::Settings, Some(key)) => store.delete_setting(&key, meter), + (Area::Cache, Some(key)) => store.remove_cache_entry(&key), + (_, None) => store.clear_cache().map(|_| true), + }); + match removed { + Ok(true) => STORAGE_DONE, + Ok(false) => STORAGE_NOT_FOUND, + Err(code) => code, + } + } + fn progress(&mut self, done: i64, total: i64) { let total = u64::try_from(total).unwrap_or(0); let done = u64::try_from(done).unwrap_or(0); @@ -286,55 +489,89 @@ pub(crate) struct Host { pub(crate) run: Run, } -/// A linker that offers the host imports when `granted`, and no import otherwise. -pub(crate) fn linker(engine: &Engine, granted: bool) -> Linker { +/// The module's memory and the store's state, for an import that reads or writes the memory. +fn memory_and_host<'a>(caller: &'a mut Caller<'_, Host>) -> Option<(&'a mut [u8], &'a mut Host)> { + let Some(Extern::Memory(memory)) = caller.get_export("memory") else { return None }; + Some(memory.data_and_store_mut(caller)) +} + +/// An i32 argument as the unsigned offset or length it stands for. +fn offset(v: i32) -> usize { + v as u32 as usize +} + +/// A linker that offers the host imports whose permission is in `granted`, and no other import. +pub(crate) fn linker(engine: &Engine, granted: &[Permission]) -> Linker { let mut linker = Linker::::new(engine); - if granted { - // Each name is defined once, so none of these fails. + // Each name is defined once, so none of these fails. + if granted.contains(&Permission::Progress) { let _ = linker.func_wrap(MODULE, "vc_progress", |mut caller: Caller<'_, Host>, done: i64, total: i64| { caller.data_mut().run.progress(done, total); }); let _ = linker.func_wrap(MODULE, "vc_cancelled", |caller: Caller<'_, Host>| -> i32 { caller.data().run.cancelled() }); let _ = linker.func_wrap(MODULE, "vc_log", |mut caller: Caller<'_, Host>, level: i32, ptr: i32, len: i32| -> i32 { - let Some(Extern::Memory(memory)) = caller.get_export("memory") else { return LOG_OUTSIDE_MEMORY }; - let (data, host) = memory.data_and_store_mut(&mut caller); - host.run.log(level, data, ptr as u32 as usize, len as u32 as usize) + let Some((data, host)) = memory_and_host(&mut caller) else { return LOG_OUTSIDE_MEMORY }; + host.run.log(level, data, offset(ptr), offset(len)) + }); + } + for (area, permission, get, set, remove) in [ + (Area::Settings, Permission::Settings, "vc_settings_get", "vc_settings_set", "vc_settings_delete"), + (Area::Cache, Permission::Cache, "vc_cache_read", "vc_cache_write", "vc_cache_clear"), + ] { + if !granted.contains(&permission) { + continue; + } + let _ = linker.func_wrap(MODULE, get, move |mut caller: Caller<'_, Host>, key: i32, key_len: i32, out: i32, out_cap: i32| -> i32 { + let Some((data, host)) = memory_and_host(&mut caller) else { return STORAGE_OUTSIDE_MEMORY }; + host.run.storage_read(area, data, (offset(key), offset(key_len)), (offset(out), offset(out_cap))) + }); + let _ = linker.func_wrap(MODULE, set, move |mut caller: Caller<'_, Host>, key: i32, key_len: i32, value: i32, value_len: i32| -> i32 { + let Some((data, host)) = memory_and_host(&mut caller) else { return STORAGE_OUTSIDE_MEMORY }; + host.run.storage_write(area, data, (offset(key), offset(key_len)), (offset(value), offset(value_len))) + }); + let _ = linker.func_wrap(MODULE, remove, move |mut caller: Caller<'_, Host>, key: i32, key_len: i32| -> i32 { + let Some((data, host)) = memory_and_host(&mut caller) else { return STORAGE_OUTSIDE_MEMORY }; + host.run.storage_remove(area, data, (offset(key), offset(key_len))) }); } linker } -/// Checks that every import of `module` is a host import with the host's signature → their names. -/// Any other import is refused, and the error names it. -pub(crate) fn check_imports(module: &Module) -> Result> { - let mut names = Vec::new(); +/// Checks that every import of `module` is a host import with the host's signature → them. Any +/// other import is refused, and the error names it. +pub(crate) fn check_imports(module: &Module) -> Result> { + let mut found = Vec::new(); for imp in module.imports() { let offered = if imp.module() == MODULE { IMPORTS.iter().find(|i| i.name == imp.name()) } else { None }; let (from, name) = (permissions::display_text(imp.module(), 64), permissions::display_text(imp.name(), 64)); let Some(host) = offered else { return Err(Error::Abi(format!( - "the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network" + "the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network" ))); }; match imp.ty() { - ExternType::Func(f) if f.params() == host.params && f.results() == host.results => names.push(host.name), + ExternType::Func(f) if f.params() == host.params && f.results() == host.results => found.push(host), _ => { let signature = host.signature; return Err(Error::Abi(format!("the module imports `env.{name}` with another type than the host's: it is {signature}"))); } } } - Ok(names) + Ok(found) } -/// Checks that `manifest` allows the host imports `imports`: `api` 2 and the `progress` -/// permission. The error names the first import it doesn't allow. -pub(crate) fn check_granted(manifest: &Manifest, imports: &[&str]) -> Result<()> { - match imports.first() { - Some(name) if manifest.api < PROGRESS_API || !manifest.permissions().contains(&Permission::Progress) => Err(Error::Abi(format!( - "the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress`" - ))), - _ => Ok(()), +/// Checks that `manifest` allows the host imports `imports`: `api` 2 and each import's permission. +/// The error names the first import it doesn't allow. +pub(crate) fn check_granted(manifest: &Manifest, imports: &[&Import]) -> Result<()> { + let granted = manifest.permissions(); + match imports.iter().find(|i| manifest.api < PROGRESS_API || !granted.contains(&i.permission)) { + Some(import) => { + let (name, permission) = (import.name, import.permission.name()); + Err(Error::Abi(format!( + "the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}`" + ))) + } + None => Ok(()), } } diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 763eeb100..38fff38c1 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -3,14 +3,16 @@ //! A plug-in is a WebAssembly module that implements the VectorCraft plug-in ABI (v1, documented //! in `docs/plugins.md`). It runs in [`wasmi`], a pure-Rust interpreter, with: //! -//! - **no host imports but the three of API level 2** ([`host`]: progress, Cancel and log lines, -//! with the `progress` permission): no WASI, file system, network, clock or randomness. A module -//! that imports anything else is rejected; +//! - **no host imports but those of API level 2** ([`host`]: progress, Cancel and log lines with +//! the `progress` permission, and settings and a cache of its own with the `settings` and `cache` +//! permissions, [`storage`]): no WASI, file system, network, clock or randomness. A module that +//! imports anything else is rejected; //! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is //! also enforced (native builds), so infinite loops end with an error. A filter may ask for a //! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it; //! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits; -//! - a **fresh instance per run**, so no state leaks between calls. +//! - a **fresh instance per run**, so no state leaks between calls; what a plug-in keeps is only +//! what it writes to its settings and cache. //! //! Every failure (malformed module, trap, exhausted budget, bad output) is an [`Error`]; nothing //! a module does can panic or hang the host. @@ -38,6 +40,7 @@ pub mod permissions; pub mod registry; mod runtime; pub mod safe_mode; +pub mod storage; pub mod wat; pub use dialog::ParamUi; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 3a3b679ac..dc37df755 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -312,10 +312,11 @@ impl Manifest { return Err(Error::Manifest(format!("`api` is {}; this host implements plug-in API levels 1 to {API_LEVEL}", self.api))); } if let Some(names) = &self.permissions - && permissions::validate(self.kind, names)?.contains(&Permission::Progress) + && let Some(host) = permissions::validate(self.kind, names)?.into_iter().find(|p| permissions::HOST.contains(p)) && self.api < PROGRESS_API { - return Err(Error::Manifest(format!("permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}"))); + let permission = host.name(); + return Err(Error::Manifest(format!("permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}"))); } self.validate_extensions()?; match (self.budget, self.kind) { @@ -690,6 +691,9 @@ mod tests { assert_eq!(serde_json::to_value(&level2).unwrap()["api"], json!(2)); let plain2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2}"#).unwrap(); assert!(!plain2.permissions().contains(&Permission::Progress), "never implied"); + let storing = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["cache","settings"]}"#).unwrap(); + assert_eq!(storing.permissions(), [Permission::Settings, Permission::Cache]); + assert!(!plain2.permissions().iter().any(|p| permissions::HOST.contains(p)), "never implied"); for (bad, says) in [ (r#"{"id":"a","name":"x","kind":"filter","permissions":["doc.read","net.fetch"]}"#, "unknown permission `net.fetch`"), (r#"{"id":"a","name":"x","kind":"filter","permissions":["ui.panel"]}"#, "reserved"), @@ -697,6 +701,8 @@ mod tests { (r#"{"id":"a","name":"x","kind":"filter","permissions":"doc.read"}"#, "manifest"), (r#"{"id":"a","name":"x","kind":"filter","permissions":["progress"]}"#, "`progress` needs plug-in API level 2"), (r#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["progress"]}"#, "`progress` needs plug-in API level 2"), + (r#"{"id":"a","name":"x","kind":"filter","permissions":["settings"]}"#, "`settings` needs plug-in API level 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["doc.read","cache"]}"#, "`cache` needs plug-in API level 2"), (r#"{"id":"a","name":"x","kind":"filter","api":3}"#, "`api` is 3"), (r#"{"id":"a","name":"x","kind":"filter","api":0}"#, "`api` is 0"), (r#"{"id":"a","name":"x","kind":"filter","api":-1}"#, "manifest"), diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 4b5daecf6..f3f138778 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -5,8 +5,9 @@ //! `doc.read`, `doc.write`, `state` and `progress` are the names of the plug-in capability model in //! storytold/photocraft#662; the other names are VectorCraft's own. A manifest without //! `permissions` gets the ones its kind implies ([`Permission::implied`]): what every plug-in of -//! that kind could do before manifests listed them. `progress`, which lets a plug-in use the host -//! imports of API level 2 ([`crate::host`]), is never implied: a manifest lists it. +//! that kind could do before manifests listed them. `progress`, `settings` and `cache`, which let a +//! plug-in use the host imports of API level 2 ([`crate::host`]), are never implied: a manifest +//! lists them. use serde::{Deserialize, Serialize}; @@ -16,8 +17,8 @@ use crate::{Error, Kind, Result}; /// this. pub const API_LEVEL: u32 = 2; -/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress` -/// permission they need. +/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress`, +/// `settings` and `cache` permissions they need. pub const PROGRESS_API: u32 = 2; /// Most permissions a manifest may list. @@ -59,10 +60,16 @@ pub enum Permission { /// host imports of API level 2 ([`crate::host::IMPORTS`]). #[serde(rename = "progress")] Progress, + /// Keep settings of its own between runs and sessions ([`crate::storage`]). + #[serde(rename = "settings")] + Settings, + /// Keep a cache of its own, which the user can clear ([`crate::storage`]). + #[serde(rename = "cache")] + Cache, } /// Every permission this host grants, in the order prompts list them. -pub const ALL: [Permission; 7] = [ +pub const ALL: [Permission; 9] = [ Permission::DocRead, Permission::DocWrite, Permission::State, @@ -70,8 +77,14 @@ pub const ALL: [Permission; 7] = [ Permission::ExportWrite, Permission::ImportRead, Permission::Progress, + Permission::Settings, + Permission::Cache, ]; +/// The permissions of the host imports, which any kind may list and none implies; each needs plug-in +/// API level [`PROGRESS_API`]. +pub const HOST: [Permission; 3] = [Permission::Progress, Permission::Settings, Permission::Cache]; + impl Permission { /// Its name in a manifest and in `plugin.install`'s `grant`. pub fn name(self) -> &'static str { @@ -83,6 +96,8 @@ impl Permission { Permission::ExportWrite => "export.write", Permission::ImportRead => "import.read", Permission::Progress => "progress", + Permission::Settings => "settings", + Permission::Cache => "cache", } } @@ -101,17 +116,19 @@ impl Permission { Permission::ExportWrite => "Write the document as a file in its own format", Permission::ImportRead => "Read files you open in its format and make documents from them", Permission::Progress => "Show its progress, see Cancel and write log lines", + Permission::Settings => "Keep settings of its own", + Permission::Cache => "Keep a cache of its own, which you can clear", } } - /// May a plug-in of `kind` list it: one its kind implies, or `progress`, which any kind may - /// list. + /// May a plug-in of `kind` list it: one its kind implies, or one of the host imports' ([`HOST`]), + /// which any kind may list. pub fn applies(self, kind: Kind) -> bool { - self == Permission::Progress || Permission::implied(kind).contains(&self) + HOST.contains(&self) || Permission::implied(kind).contains(&self) } /// The permissions a manifest of `kind` without `permissions` gets. A manifest may also list - /// `progress` ([`Permission::applies`]). + /// the host imports' ([`HOST`], [`Permission::applies`]). pub fn implied(kind: Kind) -> &'static [Permission] { match kind { Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State], @@ -258,6 +275,16 @@ mod tests { assert!( [Kind::Filter, Kind::Effect, Kind::Exporter, Kind::Importer].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress)) ); + // So may `settings` and `cache`, which no kind implies either. + assert_eq!( + validate(Kind::Effect, &list(&["cache", "settings", "doc.read", "effect.render"])).unwrap(), + [Permission::DocRead, Permission::EffectRender, Permission::Settings, Permission::Cache] + ); + assert!(validate(Kind::Exporter, &list(&["settings", "export.write", "doc.read"])).is_ok()); + assert_eq!(validate(Kind::Filter, &list(&["cache"])).unwrap(), [Permission::Cache]); + for kind in [Kind::Filter, Kind::Effect, Kind::Exporter, Kind::Importer] { + assert!(HOST.iter().all(|p| p.applies(kind) && !Permission::implied(kind).contains(p))); + } for (kind, names, says) in [ (Kind::Importer, &["import.read", "doc.read"][..], "`doc.read` doesn't apply to a plug-in of kind `importer`"), (Kind::Importer, &[], "needs the permission `import.read`"), @@ -265,6 +292,8 @@ mod tests { (Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), (Kind::Filter, &["ui.panel"], "`ui.panel` is reserved"), (Kind::Filter, &["progress", "progress"], "`progress` is listed twice"), + (Kind::Filter, &["cache", "cache"], "`cache` is listed twice"), + (Kind::Filter, &["storage"], "unknown permission `storage`"), (Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), (Kind::Exporter, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"), (Kind::Effect, &["doc.read"], "needs the permission `effect.render`"), diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index 8586df411..c0a2ba32e 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -10,12 +10,30 @@ use std::sync::{Arc, Mutex, RwLock}; #[cfg(not(target_arch = "wasm32"))] use crate::Error; use crate::permissions::Grant; +use crate::storage::Storage; use crate::{Limits, Plugin, Result}; static PLUGINS: RwLock>> = RwLock::new(BTreeMap::new()); static REVISION: AtomicU64 = AtomicU64::new(0); /// What the user approved, by plug-in id. static GRANTS: RwLock> = RwLock::new(BTreeMap::new()); +/// Where each plug-in keeps its settings and cache, by plug-in id. +static STORAGE: RwLock>> = RwLock::new(BTreeMap::new()); + +/// Keeps the settings and cache of plug-in `id` in `storage` from now on (`None`: nowhere, so its +/// storage calls fail as unavailable). A run of the plug-in reaches only its own id's storage. +pub fn set_storage(id: &str, storage: Option>) { + let mut all = STORAGE.write().unwrap_or_else(|e| e.into_inner()); + match storage { + Some(s) => all.insert(id.to_string(), s), + None => all.remove(id), + }; +} + +/// Where plug-in `id` keeps its settings and cache, if anywhere. +pub fn storage(id: &str) -> Option> { + STORAGE.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() +} /// Records what the user approved for a plug-in, replacing an earlier grant for its id. pub fn grant(g: Grant) { diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 9a81f4f35..7eba48485 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -160,8 +160,9 @@ impl Plugin { let hash = crate::permissions::sha256_hex(bytes); let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), hash, source: None, serial: 0 }; let deadline = Deadline::new(p.limits.wall_time_ms); - // The manifest isn't read yet: every host import is linked, and what it reports is dropped. - let mut live = p.instantiate(Run::new("", false, false, deadline), true, &mut Report::default())?; + // The manifest isn't read yet: every host import is linked, what it reports is dropped and + // there is no storage to reach. + let mut live = p.instantiate(Run::new("", false, false, deadline), &crate::permissions::ALL, &mut Report::default())?; let version: TypedFunc<(), i32> = live.func("vc_abi_version")?; let v = p.call(&mut live, &version, (), p.limits.fuel_base, &deadline)?; if v != ABI_VERSION { @@ -221,9 +222,9 @@ impl Plugin { self.serial = serial; } - /// A new instance for `run`, with the host imports linked when `granted`. When the module's - /// start function fails, what it reported through the host imports goes to `report`. - fn instantiate(&self, run: Run, granted: bool, report: &mut Report) -> Result { + /// A new instance for `run`, with the host imports of the permissions `granted` linked. When the + /// module's start function fails, what it reported through the host imports goes to `report`. + fn instantiate(&self, run: Run, granted: &[Permission], report: &mut Report) -> Result { let limits = StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build(); let mut store = Store::new(&self.engine, Host { limits, run }); @@ -293,7 +294,8 @@ impl Plugin { } /// [`Plugin::run_limited`], and what the run reported through the host imports: its last - /// progress report and its log lines, which also go to the app's log under its id. Inside a + /// progress report and its log lines, which also go to the app's log under its id. Its storage + /// imports reach the storage of its id ([`crate::registry::storage`]). Inside a /// [`vectorcraft_doc::cancel::scope`] its progress goes to the scope's sink (a live effect's /// doesn't) and `vc_cancelled` sees the scope's Cancel. pub fn run_reporting(&self, input: &[u8], params: &[u8], limits: &Limits) -> (Result>, Report) { @@ -312,8 +314,8 @@ impl Plugin { total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?; let deadline = Deadline::new(limits.wall_time_ms); let m = &self.manifest; - let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline); - let mut live = self.instantiate(run, m.permissions().contains(&Permission::Progress), report)?; + let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline).with_storage(crate::registry::storage(&m.id)); + let mut live = self.instantiate(run, &m.permissions(), report)?; let out = self.run_live(&mut live, input, params, limits, &deadline); *report = std::mem::take(&mut live.store.data_mut().run.report); out diff --git a/crates/plugins/src/storage.rs b/crates/plugins/src/storage.rs new file mode 100644 index 000000000..f3ef2e899 --- /dev/null +++ b/crates/plugins/src/storage.rs @@ -0,0 +1,151 @@ +//! What a plug-in keeps between runs, from plug-in API level 2 on: **settings**, a few named values +//! (the `settings` permission), and a **cache** of named entries the user can clear (the `cache` +//! permission). A plug-in reads and writes them through the host imports ([`crate::host::IMPORTS`]), +//! naming each by a key ([`valid_name`]), never by a path; the host keeps them per plug-in id +//! ([`Storage`], given with [`crate::registry::set_storage`]), so one plug-in can't reach another's. +//! +//! The caps are VectorCraft's own values: [`MAX_SETTINGS`] keys of at most [`MAX_SETTING_BYTES`] +//! each in a settings file of at most [`MAX_SETTINGS_BYTES`], and cache entries of at most +//! [`MAX_CACHE_ENTRY`] bytes, [`MAX_CACHE_BYTES`] and [`MAX_CACHE_ENTRIES`] in all. A write that +//! would pass one fails with [`StorageError::Full`] and changes nothing. + +/// Longest key or cache entry name, in bytes. +pub const MAX_NAME: usize = 64; + +/// Most settings a plug-in keeps. +pub const MAX_SETTINGS: usize = 256; + +/// Largest value of one setting, in bytes. +pub const MAX_SETTING_BYTES: usize = 64 << 10; + +/// Largest settings file, in bytes: the keys and the values in base64, as JSON. +pub const MAX_SETTINGS_BYTES: usize = 1 << 20; + +/// Largest cache entry, in bytes. +pub const MAX_CACHE_ENTRY: usize = 64 << 20; + +/// Most bytes a plug-in's cache holds, all entries together. +pub const MAX_CACHE_BYTES: u64 = 512 << 20; + +/// Most entries a plug-in's cache holds. +pub const MAX_CACHE_ENTRIES: usize = 4096; + +/// Why a storage call failed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StorageError { + /// The write would pass a cap; nothing was changed. + Full, + /// There is nowhere to keep anything: no plug-in store is attached, as in the web build. + Unavailable, + /// Reading or writing the file failed, with the system's reason. + Io(String), + /// The call would read or write more bytes than its [`Meter`] has left; nothing was changed. + Budget, +} + +/// The bytes a storage call may still read and write, and those it has. The storage charges each +/// file it reads or writes, at its size, before it does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Meter { + left: u64, + used: u64, +} + +impl Meter { + /// A meter that lets a call read and write `left` bytes. + pub fn new(left: u64) -> Self { + Meter { left, used: 0 } + } + + /// A meter with no limit, for callers that don't count. + pub fn unlimited() -> Self { + Meter::new(u64::MAX) + } + + /// Counts `bytes` about to be read or written → [`StorageError::Budget`], counting nothing, + /// when that is more than is left. + pub fn take(&mut self, bytes: u64) -> Result<(), StorageError> { + if bytes > self.left { + return Err(StorageError::Budget); + } + self.left -= bytes; + self.used = self.used.saturating_add(bytes); + Ok(()) + } + + /// The bytes counted so far. + pub fn used(&self) -> u64 { + self.used + } +} + +impl std::fmt::Display for StorageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StorageError::Full => write!(f, "the plug-in's storage is full"), + StorageError::Unavailable => write!(f, "no plug-in storage is available"), + StorageError::Io(why) => write!(f, "{why}"), + StorageError::Budget => write!(f, "the run has used up its storage bytes"), + } + } +} + +/// Where one plug-in's settings and cache are kept. Every key and name the host passes is a +/// [`valid_name`], and every value is within its cap ([`MAX_SETTING_BYTES`], [`MAX_CACHE_ENTRY`]); +/// the storage enforces the totals. The calls that read or write a file charge `meter` the bytes +/// they read and write ([`Meter::take`]) before they do: a settings call the whole settings file it +/// reads and the whole file it writes, a cache call the entry it reads or writes. +pub trait Storage: Send + Sync { + /// The value of setting `key`, if it has one. + fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError>; + /// Sets `key` to `value`. + fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError>; + /// Removes setting `key` → whether it had a value. + fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result; + /// The cache entry `name`, if there is one. + fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError>; + /// Writes the cache entry `name`. + fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError>; + /// Removes the cache entry `name` → whether there was one. + fn remove_cache_entry(&self, name: &str) -> Result; + /// Removes every cache entry → the bytes they held. + fn clear_cache(&self) -> Result; + /// The bytes the cache holds now. + fn cache_bytes(&self) -> u64; +} + +/// Is `name` a key or cache entry name a plug-in may use: 1 to [`MAX_NAME`] characters from +/// `A-Z a-z 0-9 _ -`, and not a name Windows keeps for a device (`CON`, `PRN`, `AUX`, `NUL`, +/// `COM0` to `COM9`, `LPT0` to `LPT9`, in any case)? Keys differ in case: `a` and `A` are two. +pub fn valid_name(name: &str) -> bool { + (1..=MAX_NAME).contains(&name.len()) && name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) && !device_name(name) +} + +/// Is `name` one of the device names Windows keeps? +fn device_name(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + match upper.as_bytes() { + [a, b, c] => matches!(&[*a, *b, *c], b"CON" | b"PRN" | b"AUX" | b"NUL"), + [a, b, c, d] => matches!(&[*a, *b, *c], b"COM" | b"LPT") && d.is_ascii_digit(), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_are_short_plain_and_never_a_device() { + for ok in ["a", "Z", "zoom-level", "tile_12", &"x".repeat(MAX_NAME), "CONSOLE", "COM10", "LPTX", "con1"] { + assert!(valid_name(ok), "{ok}"); + } + for bad in ["", "..", ".", "a/b", "a\\b", "a.b", "a b", "a\u{0}", "\u{7}", "é", &"x".repeat(MAX_NAME + 1), "CON", "con", "Nul", "aux", "prn"] + { + assert!(!valid_name(bad), "{bad:?}"); + } + for n in 0..10 { + assert!(!valid_name(&format!("COM{n}")) && !valid_name(&format!("lpt{n}"))); + } + } +} diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index a6583501e..0beabd34c 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -59,6 +59,16 @@ pub const HOST_IMPORTS: &str = r#"(import "env" "vc_progress" (func $vc_progress (import "env" "vc_cancelled" (func $vc_cancelled (result i32))) (import "env" "vc_log" (func $vc_log (param i32 i32 i32) (result i32)))"#; +/// The storage imports of plug-in API level 2 ([`crate::host::IMPORTS`]), as `$vc_settings_get`, +/// `$vc_settings_set`, `$vc_settings_delete`, `$vc_cache_read`, `$vc_cache_write` and +/// `$vc_cache_clear`; a manifest that uses them lists `settings` and `cache`. +pub const STORAGE_IMPORTS: &str = r#"(import "env" "vc_settings_get" (func $vc_settings_get (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_settings_set" (func $vc_settings_set (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_settings_delete" (func $vc_settings_delete (param i32 i32) (result i32))) + (import "env" "vc_cache_read" (func $vc_cache_read (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_cache_write" (func $vc_cache_write (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_cache_clear" (func $vc_cache_clear (param i32 i32) (result i32)))"#; + /// A plug-in of API level 2 (its manifest gives `api` 2 and lists `progress`) that imports the /// host functions ([`HOST_IMPORTS`]) and whose `vc_run` runs `first`, which may call them, then /// returns `output`. diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index 1d8e64dce..abc2cd05e 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -6,6 +6,7 @@ use std::time::{Duration, Instant}; use serde_json::{Value, json}; +use vectorcraft_plugins::storage::{Meter, Storage, StorageError}; use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, Report, heavy_terms, registry, wat as tpl}; /// The example Desaturate plug-in, built from `crates/plugins/example`. @@ -395,8 +396,8 @@ fn load_err(bytes: &[u8]) -> String { Plugin::load(bytes, Limits::default()).expect_err("the module must be refused").to_string() } -/// A module may import only the host's three functions, with the host's types, and only when its -/// manifest gives `api` 2 and lists `progress`; the error names the import it refuses. +/// A module may import only the host's functions, with the host's types, and the progress ones only +/// when its manifest gives `api` 2 and lists `progress`; the error names the import it refuses. #[test] fn host_imports_are_checked_when_a_module_is_loaded() { let with = |manifest: &str, import: &str| build(&tpl::with_imports(manifest, import, "", INPUT)); @@ -541,6 +542,206 @@ fn what_a_start_function_reports_before_it_fails_is_kept() { assert_eq!(report.progress, Some((1, 4))); } +/// Settings and a cache kept in memory, for the storage imports. Each call charges its meter the +/// bytes of the value it reads or writes. +#[derive(Default)] +struct Kept { + settings: std::sync::Mutex>>, + cache: std::sync::Mutex>>, +} + +/// The value `key` has in `kept`, once `meter` is charged its bytes. +fn charged( + kept: &std::sync::Mutex>>, + key: &str, + meter: &mut Meter, +) -> Result>, StorageError> { + let value = kept.lock().unwrap().get(key).cloned(); + meter.take(value.as_ref().map_or(0, |v| v.len() as u64))?; + Ok(value) +} + +impl Storage for Kept { + fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError> { + charged(&self.settings, key, meter) + } + fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + meter.take(value.len() as u64)?; + self.settings.lock().unwrap().insert(key.into(), value.to_vec()); + Ok(()) + } + fn delete_setting(&self, key: &str, _: &mut Meter) -> Result { + Ok(self.settings.lock().unwrap().remove(key).is_some()) + } + fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError> { + charged(&self.cache, name, meter) + } + fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + meter.take(value.len() as u64)?; + self.cache.lock().unwrap().insert(name.into(), value.to_vec()); + Ok(()) + } + fn remove_cache_entry(&self, name: &str) -> Result { + Ok(self.cache.lock().unwrap().remove(name).is_some()) + } + fn clear_cache(&self) -> Result { + let mut cache = self.cache.lock().unwrap(); + let freed = cache.values().map(|v| v.len() as u64).sum(); + cache.clear(); + Ok(freed) + } + fn cache_bytes(&self) -> u64 { + self.cache.lock().unwrap().values().map(|v| v.len() as u64).sum() + } +} + +/// A filter `id` of API level 2 that lists `settings` and `cache`. +fn storing(id: &str) -> String { + format!(r#"{{"id":"{id}","name":"Storing","version":"1","kind":"filter","api":2,"permissions":["settings","cache"]}}"#) +} + +/// Instructions that trap unless `call` returns `want`. +fn expect(call: &str, want: i32) -> String { + format!("(if (i32.ne {call} (i32.const {want})) (then unreachable))") +} + +/// The module fields of a storage test: the storage imports, keys at 0x2000 ("zoom", "..", "a/b", +/// "CON", a tab, 65 "x"), the value "hello" at 0x2100; 0x3000 is free for an output buffer. +fn storage_data() -> String { + format!(r#"{} (data (i32.const 0x2000) "zoom..a/bCON\09{}") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS, "x".repeat(65)) +} + +/// Each storage import needs its own permission: a module that imports one its manifest doesn't +/// list is refused, and the error names the import and the permission. +#[test] +fn storage_imports_need_their_own_permission() { + let with = |manifest: &str, import: &str| build(&tpl::with_imports(manifest, import, "", INPUT)); + let get = r#"(import "env" "vc_settings_get" (func (param i32 i32 i32 i32) (result i32)))"#; + let clear = r#"(import "env" "vc_cache_clear" (func (param i32 i32) (result i32)))"#; + let e = load_err(&with(LEVEL2, get)); + assert!( + e.contains("imports `env.vc_settings_get`, which a plug-in may use only at plug-in API level 2 (`api` 2) and with the permission `settings`"), + "{e}" + ); + let settings_only = r#"{"id":"test.settings","name":"S","kind":"filter","api":2,"permissions":["settings"]}"#; + assert!(Plugin::load(&with(settings_only, get), Limits::default()).is_ok()); + let e = load_err(&with(settings_only, clear)); + assert!( + e.contains("imports `env.vc_cache_clear`, which a plug-in may use only at plug-in API level 2 (`api` 2) and with the permission `cache`"), + "{e}" + ); + let wrong = load_err(&with(&storing("test.wrong"), r#"(import "env" "vc_cache_clear" (func (param i32) (result i32)))"#)); + assert!(wrong.contains("imports `env.vc_cache_clear` with another type than the host's: it is (param i32 i32) (result i32)"), "{wrong}"); + assert!(Plugin::load(&build(&tpl::with_imports(&storing("test.all"), tpl::STORAGE_IMPORTS, "", INPUT)), Limits::default()).is_ok()); +} + +/// A plug-in sets, reads and removes its settings and cache entries through the storage imports, +/// by key; a value too large for its buffer returns its length and copies nothing; a key or buffer +/// outside its memory, a key that isn't a plain name and a value over its cap are errors it sees. +#[test] +fn storage_imports_read_and_write_the_plugins_own_storage() { + use vectorcraft_plugins::host::{STORAGE_BAD_NAME, STORAGE_DONE, STORAGE_FULL, STORAGE_NOT_FOUND, STORAGE_OUTSIDE_MEMORY}; + let id = "test.storage.own"; + let kept = std::sync::Arc::new(Kept::default()); + registry::set_storage(id, Some(kept.clone())); + let zoom = "(i32.const 0x2000) (i32.const 4)"; + let first = [ + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 16))"), STORAGE_NOT_FOUND), + expect(&format!("(call $vc_settings_set {zoom} (i32.const 0x2100) (i32.const 5))"), STORAGE_DONE), + // Too small a buffer: the length, and nothing copied. + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 4))"), 5), + expect("(i32.load8_u (i32.const 0x3000))", 0), + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 16))"), 5), + expect("(i32.load (i32.const 0x3000))", i32::from_le_bytes(*b"hell")), + expect(&format!("(call $vc_cache_write {zoom} (i32.const 0x2100) (i32.const 0))"), STORAGE_DONE), + expect(&format!("(call $vc_cache_read {zoom} (i32.const 0x3000) (i32.const 0))"), 0), + expect(&format!("(call $vc_cache_write {zoom} (i32.const 0x2100) (i32.const 3))"), STORAGE_DONE), + // Hostile keys: `..`, `a/b`, `CON`, a tab, 65 bytes, no bytes. + expect("(call $vc_settings_set (i32.const 0x2004) (i32.const 2) (i32.const 0x2100) (i32.const 5))", STORAGE_BAD_NAME), + expect("(call $vc_settings_set (i32.const 0x2006) (i32.const 3) (i32.const 0x2100) (i32.const 5))", STORAGE_BAD_NAME), + expect("(call $vc_cache_write (i32.const 0x2009) (i32.const 3) (i32.const 0x2100) (i32.const 5))", STORAGE_BAD_NAME), + expect("(call $vc_cache_read (i32.const 0x200c) (i32.const 1) (i32.const 0x3000) (i32.const 16))", STORAGE_BAD_NAME), + expect("(call $vc_settings_get (i32.const 0x200d) (i32.const 65) (i32.const 0x3000) (i32.const 16))", STORAGE_BAD_NAME), + expect("(call $vc_settings_delete (i32.const 0x2000) (i32.const 0))", STORAGE_BAD_NAME), + // Outside memory: the key, the value, the buffer. + expect("(call $vc_settings_get (i32.const -1) (i32.const 4) (i32.const 0x3000) (i32.const 16))", STORAGE_OUTSIDE_MEMORY), + expect(&format!("(call $vc_settings_set {zoom} (i32.const 0x2100) (i32.const -1))"), STORAGE_OUTSIDE_MEMORY), + expect(&format!("(call $vc_cache_read {zoom} (i32.mul (memory.size) (i32.const 65536)) (i32.const 1))"), STORAGE_OUTSIDE_MEMORY), + // A setting over its cap changes nothing. + expect( + &format!("(call $vc_settings_set {zoom} (i32.const 0) (i32.const {}))", vectorcraft_plugins::storage::MAX_SETTING_BYTES + 1), + STORAGE_FULL, + ), + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 16))"), 5), + ] + .concat(); + let p = Plugin::load(&build(&tpl::with_imports(&storing(id), &storage_data(), &first, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes(), "every call returned what it should"); + assert_eq!(kept.settings.lock().unwrap().get("zoom"), Some(&b"hello".to_vec())); + assert_eq!(kept.cache.lock().unwrap().get("zoom"), Some(&b"hel".to_vec())); + // Removing: one setting, then a missing one, then the whole cache, then a missing entry. + let removing = [ + expect("(call $vc_settings_delete (i32.const 0x2000) (i32.const 4))", STORAGE_DONE), + expect("(call $vc_settings_delete (i32.const 0x2000) (i32.const 4))", STORAGE_NOT_FOUND), + expect("(call $vc_cache_clear (i32.const 0) (i32.const 0))", STORAGE_DONE), + expect("(call $vc_cache_clear (i32.const 0x2000) (i32.const 4))", STORAGE_NOT_FOUND), + ] + .concat(); + let p = Plugin::load(&build(&tpl::with_imports(&storing(id), &storage_data(), &removing, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes(), "every call returned what it should"); + assert!(kept.settings.lock().unwrap().is_empty() && kept.cache.lock().unwrap().is_empty()); + registry::set_storage(id, None); +} + +/// A read counts the value's bytes toward the run's storage bytes even when its buffer is too small +/// and nothing is copied, so reading a large entry into no buffer again and again ends in +/// `STORAGE_BUDGET`. +#[test] +fn a_read_into_too_small_a_buffer_still_counts_its_bytes() { + use vectorcraft_plugins::host::{MAX_STORAGE_BYTES, STORAGE_BUDGET}; + use vectorcraft_plugins::storage::MAX_CACHE_ENTRY; + let id = "test.storage.budget"; + let kept = std::sync::Arc::new(Kept::default()); + kept.cache.lock().unwrap().insert("zoom".into(), vec![0; MAX_CACHE_ENTRY]); + registry::set_storage(id, Some(kept.clone())); + let read = "(call $vc_cache_read (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 0))"; + let fits = MAX_STORAGE_BYTES / MAX_CACHE_ENTRY; + let reading = [vec![expect(read, MAX_CACHE_ENTRY as i32); fits], vec![expect(read, STORAGE_BUDGET)]].concat().concat(); + let p = Plugin::load(&build(&tpl::with_imports(&storing(id), &storage_data(), &reading, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes(), "every call returned what it should"); + registry::set_storage(id, None); +} + +/// A plug-in reaches only the storage of its own id, and a plug-in with none (not installed with a +/// plug-in store, as on the web) gets `STORAGE_UNAVAILABLE`. +#[test] +fn a_plugin_reaches_only_its_own_storage() { + use vectorcraft_plugins::host::{STORAGE_DONE, STORAGE_NOT_FOUND, STORAGE_UNAVAILABLE}; + let (mine, theirs) = (std::sync::Arc::new(Kept::default()), std::sync::Arc::new(Kept::default())); + registry::set_storage("test.storage.mine", Some(mine.clone())); + registry::set_storage("test.storage.theirs", Some(theirs.clone())); + theirs.settings.lock().unwrap().insert("zoom".into(), b"theirs".to_vec()); + theirs.cache.lock().unwrap().insert("zoom".into(), b"theirs".to_vec()); + let zoom = "(i32.const 0x2000) (i32.const 4)"; + let looking = [ + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 16))"), STORAGE_NOT_FOUND), + expect(&format!("(call $vc_cache_read {zoom} (i32.const 0x3000) (i32.const 16))"), STORAGE_NOT_FOUND), + expect("(call $vc_cache_clear (i32.const 0) (i32.const 0))", STORAGE_DONE), + expect(&format!("(call $vc_settings_set {zoom} (i32.const 0x2100) (i32.const 5))"), STORAGE_DONE), + ] + .concat(); + let p = Plugin::load(&build(&tpl::with_imports(&storing("test.storage.mine"), &storage_data(), &looking, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes()); + assert_eq!(theirs.settings.lock().unwrap().get("zoom"), Some(&b"theirs".to_vec())); + assert_eq!(theirs.cache_bytes(), 6); + assert_eq!(mine.settings.lock().unwrap().get("zoom"), Some(&b"hello".to_vec())); + let nowhere = expect(&format!("(call $vc_settings_set {zoom} (i32.const 0x2100) (i32.const 5))"), STORAGE_UNAVAILABLE); + let p = Plugin::load(&build(&tpl::with_imports(&storing("test.storage.nowhere"), &storage_data(), &nowhere, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes()); + registry::set_storage("test.storage.mine", None); + registry::set_storage("test.storage.theirs", None); +} + /// A heavy filter installs once the user agrees, which loading a folder doesn't ask: it is skipped /// and reported. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f67be2fb4..0c6ae4e1c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4580,6 +4580,7 @@ axis Both И двете @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градиентът „{name}“ не е дефиниран във файла: заменен е с обикновен цвят @msg an image VectorCraft can't read was left out изображение, което VectorCraft не може да разчете, беше пропуснато @msg an image whose data is cut short was left out изображение с непълни данни беше пропуснато +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers вградено изображение от {_1} × {_2} пиксела беше намалено до {_3} × {_4} пиксела, за да се запазят слоевете @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel свързаните картини показват записания във файла предварителен преглед с ниска резолюция, докато файловете им бъдат намерени: презакачете ги в панела „Връзки“ @msg linked type areas past VectorCraft's limits were left out свързаните текстови области извън ограниченията на VectorCraft бяха пропуснати @msg type that changes writing direction inside a text object opens in the direction of its first character текст, който сменя посоката на писане в рамките на текстов обект, се отваря в посоката на първия си знак @@ -4656,6 +4657,7 @@ axis Both И двете @msg object ids are non-negative integers, not {v} идентификаторите на обектите са неотрицателни цели числа, а не {v} @msg {key} must be an array of non-negative integer object ids {key} трябва да бъде масив от идентификатори на обекти, които са неотрицателни цели числа @msg invalid object id {value}: expected a non-negative integer невалиден идентификатор на обект {value}: очаква се неотрицателно цяло число +@msg `{key}` entry {index} is {value}, not a non-negative integer object id елемент {index} на `{key}` е {value}, а не идентификатор на обект (неотрицателно цяло число) @msg no such object id {value} няма обект с идентификатор {value} @msg automation filesystem access is not granted: read authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за четене: {path} @msg automation filesystem access is not granted: write authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за запис: {path} @@ -5729,6 +5731,8 @@ axis Y Axis Y ос Install “{name}”, which asks for these permissions? Да се инсталира ли „{name}“, който иска тези права? ID: {id} ID: {id} Version: {version} Версия: {version} + Install “{name}” in place of the plug-in with the same ID? Да се инсталира ли „{name}“ на мястото на приставката със същия ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Този файл не е приставката, инсталирана преди с този ID. Той получава настройките и кеша, пазени за този ID. File SHA-256: {hash} SHA-256 на файла: {hash} Read the objects it is given Четене на обектите, които получава Change, add and delete objects in the document Промяна, добавяне и изтриване на обекти в документа @@ -5736,6 +5740,9 @@ axis Y Axis Y ос Change how the objects its effect is applied to are drawn Промяна на начина, по който се рисуват обектите, към които е приложен ефектът му Write the document as a file in its own format Записване на документа като файл в собствен формат Show its progress, see Cancel and write log lines Показване на напредъка, разпознаване на отказ и записване на редове в дневника + Keep settings of its own Съхраняване на собствени настройки + Keep a cache of its own, which you can clear Съхраняване на собствен кеш, който можете да изчистите + Clear Plug-in Cache Изчистване на кеша на добавката # Actions panel: action sets (#3712). New Set… Нов набор… New Set Нов набор diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 2d12728b1..01a263f6a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4978,6 +4978,7 @@ imageTrace Outline Obris @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradijent „{name}” nije definisan u datoteci: umjesto njega koristi se obična boja @msg an image VectorCraft can't read was left out izostavljena je slika koju VectorCraft ne može pročitati @msg an image whose data is cut short was left out izostavljena je slika čiji su podaci skraćeni +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers ugrađena slika od {_1} × {_2} piksela smanjena je na {_3} × {_4} piksela kako bi se zadržali slojevi @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel povezane slike prikazuju pregled niske rezolucije sačuvan u datoteci dok se njihove datoteke ne pronađu: ponovo ih povežite u panelu Veze @msg linked type areas past VectorCraft's limits were left out povezana područja teksta izvan ograničenja programa VectorCraft su izostavljena @msg type that changes writing direction inside a text object opens in the direction of its first character tekst koji mijenja smjer pisanja unutar tekstualnog objekta otvara se u smjeru svog prvog znaka @@ -5013,6 +5014,7 @@ imageTrace Outline Obris @msg object ids are non-negative integers, not {v} ID-ovi objekata su nenegativni cijeli brojevi, a ne {v} @msg {key} must be an array of non-negative integer object ids {key} mora biti niz nenegativnih cjelobrojnih ID-ova objekata @msg invalid object id {value}: expected a non-negative integer nevažeći ID objekta {value}: očekuje se nenegativan cijeli broj +@msg `{key}` entry {index} is {value}, not a non-negative integer object id stavka {index} u `{key}` je {value}, a ne ID objekta (nenegativan cijeli broj) @msg no such object id {value} nema objekta s ID-om {value} @msg id {value} is a layer, not an object id {value} je sloj, a ne objekat @msg id {value} is a layer, not an object; move layers with layer.move id {value} je sloj, a ne objekat; slojeve pomjerajte s layer.move @@ -5752,6 +5754,9 @@ axis Y Axis Y osa Install “{name}”, which asks for these permissions? Instalirati „{name}”, koji traži ove dozvole? ID: {id} ID: {id} Version: {version} Verzija: {version} + Install “{name}” in place of the plug-in with the same ID? Instalirati „{name}” umjesto dodatka s istim ID-om? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ova datoteka nije dodatak ranije instaliran s ovim ID-om. Dobija postavke i predmemoriju sačuvane za taj ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” je drugi modul od dodatka s ID-om {_1} i dobio bi postavke i predmemoriju sačuvane za njega: instalirajte ga s `replace: true` kada korisnik pristane File SHA-256: {hash} SHA-256 datoteke: {hash} Read the objects it is given Čitati objekte koje dobije Change, add and delete objects in the document Mijenjati, dodavati i brisati objekte u dokumentu @@ -5759,10 +5764,13 @@ axis Y Axis Y osa Change how the objects its effect is applied to are drawn Mijenjati kako se crtaju objekti na koje je primijenjen njegov efekt Write the document as a file in its own format Zapisati dokument kao datoteku u vlastitom formatu Show its progress, see Cancel and write log lines Prikazati napredak, prepoznati otkazivanje i zapisivati redove dnevnika -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} dozvola `progress` zahtijeva nivo API-ja dodataka {PROGRESS_API}: navedite `api` {PROGRESS_API} + Keep settings of its own Čuvati vlastite postavke + Keep a cache of its own, which you can clear Čuvati vlastitu predmemoriju koju možete očistiti + Clear Plug-in Cache Očisti predmemoriju dodatka +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} dozvola `{permission}` zahtijeva nivo API-ja dodataka {PROGRESS_API}: navedite `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modul uvozi `env.{name}` s drugim tipom od domaćinovog: domaćinov tip je {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul uvozi `env.{name}`, koji dodatak smije koristiti samo na nivou API-ja dodataka {PROGRESS_API} (`api` {PROGRESS_API}) i s dozvolom `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul uvozi `{from}.{name}`, koji domaćin ne nudi: dodatak smije uvoziti samo `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivo API-ja dodataka 2), a nikakav WASI, datoteke ni mrežu +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modul uvozi `env.{name}`, koji dodatak smije koristiti samo na nivou API-ja dodataka {PROGRESS_API} (`api` {PROGRESS_API}) i s dozvolom `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modul uvozi `{from}.{name}`, koji domaćin ne nudi: dodatak smije uvoziti samo funkcije `env.vc_*` koje navodi docs/plugins.md (nivo API-ja dodataka 2), a nikakav WASI, datoteke ni mrežu @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; ovaj domaćin podržava nivoe API-ja dodataka od 1 do {API_LEVEL} @msg `grant` must be a list of permission names `grant` mora biti lista naziva dozvola @msg `grant` names an unknown permission `{shown}` `grant` navodi nepoznatu dozvolu `{shown}` diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 08e67668a..f333dc0ab 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5036,6 +5036,7 @@ digits Malayalam Malaiàlam @msg an image VectorCraft can't read was left out s’ha omès una imatge que VectorCraft no pot llegir @msg graphs come in as plain art, without their data or settings els gràfics s’importen com a il·lustració normal, sense les seves dades ni la seva configuració @msg an image whose data is cut short was left out s’ha omès una imatge les dades de la qual estan tallades +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers una imatge incrustada de {_1} × {_2} píxels s’ha reduït a {_3} × {_4} píxels per conservar les capes @msg an object can't be read no es pot llegir un objecte @msg an object in perspective would leave the edited grid un objecte en perspectiva sortiria de la quadrícula editada @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objecte admet un efecte 3D. Editeu les opcions de l’efecte existent o suprimiu-lo primer. @@ -5206,6 +5207,7 @@ digits Malayalam Malaiàlam @msg invalid image data for `{0}` dades d’imatge no vàlides per a `{0}` @msg invalid native panel set conjunt de panells natius no vàlid @msg invalid object id {value}: expected a non-negative integer id d’objecte {value} no vàlid: s’esperava un enter no negatiu +@msg `{key}` entry {index} is {value}, not a non-negative integer object id l’entrada {index} de `{key}` és {value}, no un id d’objecte (un enter no negatiu) @msg Invalid panel layout; restored the default dock Disposició de panells no vàlida; s’ha restaurat l’acoblament per defecte @msg invalid parameters for `{cmd}`: {msg} paràmetres no vàlids per a `{cmd}`: {msg} @msg invalid PDF setting: {0} configuració del PDF no vàlida: {0} @@ -5875,6 +5877,9 @@ digits Malayalam Malaiàlam Install “{name}”, which asks for these permissions? Instal·la «{name}», que demana aquests permisos? ID: {id} ID: {id} Version: {version} Versió: {version} + Install “{name}” in place of the plug-in with the same ID? Instal·la «{name}» en lloc del complement amb el mateix ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Aquest fitxer no és el complement instal·lat abans amb aquest ID. Rep la configuració i la memòria cau desades per a aquest ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» és un mòdul diferent del complement amb l’ID {_1} i rebria la configuració i la memòria cau desades per a aquest: instal·la’l amb `replace: true` quan l’usuari hi estigui d’acord File SHA-256: {hash} SHA-256 del fitxer: {hash} Read the objects it is given Llegir els objectes que rep Change, add and delete objects in the document Canviar, afegir i suprimir objectes del document @@ -5882,10 +5887,13 @@ digits Malayalam Malaiàlam Change how the objects its effect is applied to are drawn Canviar com es dibuixen els objectes als quals s’aplica el seu efecte Write the document as a file in its own format Escriure el document com un fitxer en un format propi Show its progress, see Cancel and write log lines Mostrar el progrés, detectar la cancel·lació i escriure línies de registre -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permís `progress` necessita el nivell d’API de complements {PROGRESS_API}: indiqueu `api` {PROGRESS_API} + Keep settings of its own Desar una configuració pròpia + Keep a cache of its own, which you can clear Desar una memòria cau pròpia, que podeu buidar + Clear Plug-in Cache Buida la memòria cau del complement +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permís `{permission}` necessita el nivell d’API de complements {PROGRESS_API}: indiqueu `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} el mòdul importa `env.{name}` amb un tipus diferent del de l’amfitrió: el de l’amfitrió és {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el mòdul importa `env.{name}`, que un complement només pot fer servir al nivell d’API de complements {PROGRESS_API} (`api` {PROGRESS_API}) i amb el permís `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el mòdul importa `{from}.{name}`, que l’amfitrió no ofereix: un complement només pot importar `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivell d’API de complements 2), i cap WASI, fitxer ni xarxa +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` el mòdul importa `env.{name}`, que un complement només pot fer servir al nivell d’API de complements {PROGRESS_API} (`api` {PROGRESS_API}) i amb el permís `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network el mòdul importa `{from}.{name}`, que l’amfitrió no ofereix: un complement només pot importar les funcions `env.vc_*` que enumera docs/plugins.md (nivell d’API de complements 2), i cap WASI, fitxer ni xarxa @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` és {_1}; aquest amfitrió admet els nivells d’API de complements de l’1 al {API_LEVEL} @msg `grant` must be a list of permission names `grant` ha de ser una llista de noms de permisos @msg `grant` names an unknown permission `{shown}` `grant` esmenta un permís desconegut `{shown}` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 493368e06..d6c840c29 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5045,6 +5045,7 @@ imageTrace Outline Obrys @msg an image VectorCraft can't read was left out obrázek, který VectorCraft neumí přečíst, byl vynechán @msg graphs come in as plain art, without their data or settings grafy se načtou jako obyčejná grafika, bez svých dat a nastavení @msg an image whose data is cut short was left out obrázek s useknutými daty byl vynechán +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers vložený obrázek o rozměrech {_1} × {_2} pixelů byl zmenšen na {_3} × {_4} pixelů, aby se zachovaly vrstvy @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel propojené obrázky se zobrazují jako náhled v nízkém rozlišení uložený v souboru, dokud se nenajdou jejich soubory: znovu je propojte v panelu Propojení @msg linked type areas past VectorCraft's limits were left out propojené textové oblasti nad limity aplikace VectorCraft byly vynechány @msg type that changes writing direction inside a text object opens in the direction of its first character text, který mění směr psaní uvnitř textového objektu, se otevře ve směru svého prvního znaku @@ -5080,6 +5081,7 @@ imageTrace Outline Obrys @msg object ids are non-negative integers, not {v} ID objektů jsou nezáporná celá čísla, ne {v} @msg {key} must be an array of non-negative integer object ids {key} musí být pole ID objektů, která jsou nezáporná celá čísla @msg invalid object id {value}: expected a non-negative integer neplatné ID objektu {value}: očekává se nezáporné celé číslo +@msg `{key}` entry {index} is {value}, not a non-negative integer object id položka {index} v `{key}` je {value}, nikoli ID objektu (nezáporné celé číslo) @msg no such object id {value} objekt s ID {value} neexistuje @msg id {value} is a layer, not an object ID {value} je vrstva, nikoli objekt @msg id {value} is a layer, not an object; move layers with layer.move ID {value} je vrstva, nikoli objekt; vrstvy přesunete pomocí layer.move @@ -5861,6 +5863,9 @@ axis Y Axis osa Y Install “{name}”, which asks for these permissions? Nainstalovat „{name}“, který žádá o tato oprávnění? ID: {id} ID: {id} Version: {version} Verze: {version} + Install “{name}” in place of the plug-in with the same ID? Nainstalovat „{name}“ místo zásuvného modulu se stejným ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tento soubor není zásuvný modul dříve nainstalovaný s tímto ID. Získá nastavení a mezipaměť uchovávané pro toto ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ je jiný modul než zásuvný modul s ID {_1} a získal by nastavení a mezipaměť uchovávané pro něj: nainstalujte ho s `replace: true`, jakmile uživatel souhlasí File SHA-256: {hash} SHA-256 souboru: {hash} Read the objects it is given Číst objekty, které dostane Change, add and delete objects in the document Měnit, přidávat a mazat objekty v dokumentu @@ -5868,10 +5873,13 @@ axis Y Axis osa Y Change how the objects its effect is applied to are drawn Měnit, jak se kreslí objekty, na které je použit jeho efekt Write the document as a file in its own format Zapsat dokument jako soubor ve vlastním formátu Show its progress, see Cancel and write log lines Zobrazit průběh, rozpoznat zrušení a zapisovat řádky protokolu -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} oprávnění `progress` vyžaduje úroveň API zásuvných modulů {PROGRESS_API}: uveďte `api` {PROGRESS_API} + Keep settings of its own Uchovávat vlastní nastavení + Keep a cache of its own, which you can clear Uchovávat vlastní mezipaměť, kterou můžete vymazat + Clear Plug-in Cache Vymazat mezipaměť zásuvného modulu +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} oprávnění `{permission}` vyžaduje úroveň API zásuvných modulů {PROGRESS_API}: uveďte `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modul importuje `env.{name}` s jiným typem, než má hostitel: typ hostitele je {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul importuje `env.{name}`, který zásuvný modul smí používat jen na úrovni API zásuvných modulů {PROGRESS_API} (`api` {PROGRESS_API}) a s oprávněním `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul importuje `{from}.{name}`, který hostitel nenabízí: zásuvný modul smí importovat jen `env.vc_progress`, `env.vc_cancelled` a `env.vc_log` (úroveň API zásuvných modulů 2), a žádné WASI, soubory ani síť +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modul importuje `env.{name}`, který zásuvný modul smí používat jen na úrovni API zásuvných modulů {PROGRESS_API} (`api` {PROGRESS_API}) a s oprávněním `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modul importuje `{from}.{name}`, který hostitel nenabízí: zásuvný modul smí importovat jen funkce `env.vc_*`, které uvádí docs/plugins.md (úroveň API zásuvných modulů 2), a žádné WASI, soubory ani síť @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; tento hostitel podporuje úrovně API zásuvných modulů 1 až {API_LEVEL} @msg `grant` must be a list of permission names `grant` musí být seznam názvů oprávnění @msg `grant` names an unknown permission `{shown}` `grant` uvádí neznámé oprávnění `{shown}` diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31a2073df..148f26266 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4668,6 +4668,7 @@ imageTrace Outline Kontur @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradienten »{name}« er ikke defineret i filen: der bruges en flad farve i stedet @msg an image VectorCraft can't read was left out et billede, som VectorCraft ikke kan læse, blev udeladt @msg an image whose data is cut short was left out et billede, hvis data er afkortet, blev udeladt +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers et indlejret billede på {_1} × {_2} pixel blev formindsket til {_3} × {_4} pixel for at bevare lagene @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel linkede billeder viser den gemte forhåndsvisning i lav opløsning, indtil deres filer findes: link dem igen i Links-panelet @msg linked type areas past VectorCraft's limits were left out sammenkædede tekstområder ud over VectorCrafts grænser blev udeladt @msg type that changes writing direction inside a text object opens in the direction of its first character tekst, der skifter skriveretning inde i et tekstobjekt, åbnes i retningen for sit første tegn @@ -4703,6 +4704,7 @@ imageTrace Outline Kontur @msg object ids are non-negative integers, not {v} objekt-id'er er ikke-negative heltal, ikke {v} @msg {key} must be an array of non-negative integer object ids {key} skal være et array af objekt-id'er, der er ikke-negative heltal @msg invalid object id {value}: expected a non-negative integer ugyldigt objekt-id {value}: et ikke-negativt heltal var forventet +@msg `{key}` entry {index} is {value}, not a non-negative integer object id element {index} i `{key}` er {value}, ikke et objekt-id (et ikke-negativt heltal) @msg no such object id {value} der findes intet objekt med id {value} @msg automation filesystem access is not granted: read authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: læserettighed mangler: {path} @msg automation filesystem access is not granted: write authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: skriverettighed mangler: {path} @@ -5744,6 +5746,8 @@ axis Y Axis Y-akse Install “{name}”, which asks for these permissions? Installér »{name}«, som beder om disse rettigheder? ID: {id} ID: {id} Version: {version} Version: {version} + Install “{name}” in place of the plug-in with the same ID? Installér »{name}« i stedet for det plug-in, der har samme id? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Denne fil er ikke det plug-in, der før var installeret med dette id. Den får de indstillinger og den cache, der er gemt for dette id. File SHA-256: {hash} Filens SHA-256: {hash} Read the objects it is given Læse de objekter, den får Change, add and delete objects in the document Ændre, tilføje og slette objekter i dokumentet @@ -5751,6 +5755,9 @@ axis Y Axis Y-akse Change how the objects its effect is applied to are drawn Ændre, hvordan de objekter, dens effekt er anvendt på, tegnes Write the document as a file in its own format Skrive dokumentet som en fil i sit eget format Show its progress, see Cancel and write log lines Vise sin fremdrift, se en annullering og skrive logrækker + Keep settings of its own Gemme sine egne indstillinger + Keep a cache of its own, which you can clear Gemme sin egen cache, som du kan rydde + Clear Plug-in Cache Ryd plug-in-cache # Actions panel: action sets (#3712). New Set… Nyt sæt… New Set Nyt sæt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ffdb4bfc4..539b9a0e5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4652,6 +4652,7 @@ theme Appearance Erscheinungsbild @msg an image VectorCraft can't read was left out ein Bild, das VectorCraft nicht lesen kann, wurde ausgelassen @msg graphs come in as plain art, without their data or settings Diagramme werden als einfache Grafik übernommen, ohne ihre Daten und Einstellungen @msg an image whose data is cut short was left out ein Bild mit abgeschnittenen Daten wurde ausgelassen +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers ein eingebettetes Bild mit {_1} × {_2} Pixeln wurde auf {_3} × {_4} Pixel verkleinert, damit die Ebenen erhalten bleiben @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel verknüpfte Bilder zeigen die in der Datei gespeicherte Vorschau in niedriger Auflösung, bis ihre Dateien gefunden werden: Verknüpfen Sie sie im Bedienfeld „Verknüpfungen“ neu @msg linked type areas past VectorCraft's limits were left out verkettete Textbereiche jenseits der Grenzen von VectorCraft wurden ausgelassen @msg type that changes writing direction inside a text object opens in the direction of its first character Text, der innerhalb eines Textobjekts die Schreibrichtung wechselt, wird in der Richtung seines ersten Zeichens geöffnet @@ -4734,6 +4735,7 @@ theme Appearance Erscheinungsbild @msg object ids are non-negative integers, not {v} Objekt-IDs sind nicht negative ganze Zahlen, nicht {v} @msg {key} must be an array of non-negative integer object ids {key} muss ein Array von Objekt-IDs sein, die nicht negative ganze Zahlen sind @msg invalid object id {value}: expected a non-negative integer ungültige Objekt-ID {value}: erwartet wird eine nicht negative ganze Zahl +@msg `{key}` entry {index} is {value}, not a non-negative integer object id Eintrag {index} von `{key}` ist {value}, keine Objekt-ID (nicht negative ganze Zahl) @msg no such object id {value} kein Objekt mit der ID {value} @msg id {value} is a layer, not an object die ID {value} ist eine Ebene, kein Objekt @msg id {value} is a layer, not an object; move layers with layer.move die ID {value} ist eine Ebene, kein Objekt; verschieben Sie Ebenen mit layer.move @@ -5443,6 +5445,9 @@ axis Y Axis Y-Achse Install “{name}”, which asks for these permissions? „{name}“ installieren, das diese Berechtigungen verlangt? ID: {id} ID: {id} Version: {version} Version: {version} + Install “{name}” in place of the plug-in with the same ID? „{name}“ anstelle des Plug-ins mit derselben ID installieren? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Diese Datei ist nicht das Plug-in, das vorher mit dieser ID installiert war. Sie erhält die Einstellungen und den Cache, die für diese ID gespeichert sind. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ ist ein anderes Modul als das Plug-in mit der ID {_1} und würde die dafür gespeicherten Einstellungen und den Cache erhalten: Installieren Sie es mit `replace: true`, sobald der Benutzer zustimmt File SHA-256: {hash} Datei-SHA-256: {hash} Read the objects it is given Die Objekte lesen, die es erhält Change, add and delete objects in the document Objekte im Dokument ändern, hinzufügen und löschen @@ -5450,10 +5455,13 @@ axis Y Axis Y-Achse Change how the objects its effect is applied to are drawn Ändern, wie die Objekte gezeichnet werden, auf die sein Effekt angewendet ist Write the document as a file in its own format Das Dokument als Datei in einem eigenen Format schreiben Show its progress, see Cancel and write log lines Den Fortschritt anzeigen, ein Abbrechen erkennen und Protokollzeilen schreiben -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} die Berechtigung `progress` braucht die Plug-in-API-Stufe {PROGRESS_API}: Geben Sie `api` {PROGRESS_API} an + Keep settings of its own Eigene Einstellungen speichern + Keep a cache of its own, which you can clear Einen eigenen Cache anlegen, den Sie leeren können + Clear Plug-in Cache Plug-in-Cache leeren +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} die Berechtigung `{permission}` braucht die Plug-in-API-Stufe {PROGRESS_API}: Geben Sie `api` {PROGRESS_API} an @msg the module imports `env.{name}` with another type than the host's: it is {signature} das Modul importiert `env.{name}` mit einem anderen Typ als dem des Hosts: Der Typ des Hosts ist {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` das Modul importiert `env.{name}`, das ein Plug-in nur auf der Plug-in-API-Stufe {PROGRESS_API} (`api` {PROGRESS_API}) und mit der Berechtigung `progress` verwenden darf -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network das Modul importiert `{from}.{name}`, das der Host nicht anbietet: Ein Plug-in darf nur `env.vc_progress`, `env.vc_cancelled` und `env.vc_log` importieren (Plug-in-API-Stufe 2), aber kein WASI, keine Dateien und kein Netzwerk +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` das Modul importiert `env.{name}`, das ein Plug-in nur auf der Plug-in-API-Stufe {PROGRESS_API} (`api` {PROGRESS_API}) und mit der Berechtigung `{permission}` verwenden darf +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network das Modul importiert `{from}.{name}`, das der Host nicht anbietet: Ein Plug-in darf nur die `env.vc_*`-Funktionen importieren, die docs/plugins.md aufführt (Plug-in-API-Stufe 2), aber kein WASI, keine Dateien und kein Netzwerk @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ist {_1}; dieser Host unterstützt die Plug-in-API-Stufen 1 bis {API_LEVEL} @msg `grant` must be a list of permission names `grant` muss eine Liste von Berechtigungsnamen sein @msg `grant` names an unknown permission `{shown}` `grant` nennt eine unbekannte Berechtigung `{shown}` diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 1e5df1547..f91344f15 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4685,6 +4685,7 @@ digits Malayalam Μαλαγιάλαμ @msg an image VectorCraft can't read was left out παραλείφθηκε εικόνα που δεν μπορεί να διαβάσει το VectorCraft @msg graphs come in as plain art, without their data or settings τα γραφήματα εισάγονται ως απλή γραφική, χωρίς τα δεδομένα και τις ρυθμίσεις τους @msg an image whose data is cut short was left out παραλείφθηκε εικόνα με περικομμένα δεδομένα +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers μια ενσωματωμένη εικόνα {_1} × {_2} pixel μειώθηκε σε {_3} × {_4} pixel για να διατηρηθούν τα επίπεδα @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel οι συνδεδεμένες εικόνες εμφανίζουν την προεπισκόπηση χαμηλής ανάλυσης που έχει αποθηκευτεί, μέχρι να βρεθούν τα αρχεία τους: επανασυνδέστε τις στον πίνακα Σύνδεσμοι @msg linked type areas past VectorCraft's limits were left out οι συνδεδεμένες περιοχές κειμένου πέρα από τα όρια του VectorCraft παραλείφθηκαν @msg type that changes writing direction inside a text object opens in the direction of its first character το κείμενο που αλλάζει κατεύθυνση γραφής μέσα σε ένα αντικείμενο κειμένου ανοίγει με την κατεύθυνση του πρώτου χαρακτήρα του @@ -4758,6 +4759,7 @@ digits Malayalam Μαλαγιάλαμ @msg object ids are non-negative integers, not {v} τα αναγνωριστικά αντικειμένων πρέπει να είναι μη αρνητικοί ακέραιοι, όχι {v} @msg {key} must be an array of non-negative integer object ids {key}: πρέπει να είναι πίνακας αναγνωριστικών αντικειμένων που είναι μη αρνητικοί ακέραιοι @msg invalid object id {value}: expected a non-negative integer μη έγκυρο αναγνωριστικό αντικειμένου {value}: αναμένεται μη αρνητικός ακέραιος +@msg `{key}` entry {index} is {value}, not a non-negative integer object id το στοιχείο {index} του `{key}` είναι {value}, όχι αναγνωριστικό αντικειμένου (μη αρνητικός ακέραιος) @msg no such object id {value} δεν υπάρχει αντικείμενο με αναγνωριστικό {value} @msg id {value} is a layer, not an object το αναγνωριστικό {value} είναι επίπεδο, όχι αντικείμενο @msg id {value} is a layer, not an object; move layers with layer.move το αναγνωριστικό {value} είναι επίπεδο, όχι αντικείμενο· μετακινήστε τα επίπεδα με το layer.move @@ -5812,6 +5814,9 @@ axis Y Axis Άξονας Y Install “{name}”, which asks for these permissions? Να εγκατασταθεί το «{name}», που ζητά αυτά τα δικαιώματα; ID: {id} Αναγνωριστικό: {id} Version: {version} Έκδοση: {version} + Install “{name}” in place of the plug-in with the same ID? Να εγκατασταθεί το «{name}» στη θέση του πρόσθετου με το ίδιο αναγνωριστικό; + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Αυτό το αρχείο δεν είναι το πρόσθετο που ήταν εγκατεστημένο πριν με αυτό το αναγνωριστικό. Παίρνει τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό το αναγνωριστικό. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees το «{name}» είναι άλλη λειτουργική μονάδα από το πρόσθετο με αναγνωριστικό {_1} και θα έπαιρνε τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό: εγκαταστήστε το με `replace: true` μόλις συμφωνήσει ο χρήστης File SHA-256: {hash} SHA-256 αρχείου: {hash} Read the objects it is given Ανάγνωση των αντικειμένων που του δίνονται Change, add and delete objects in the document Αλλαγή, προσθήκη και διαγραφή αντικειμένων στο έγγραφο @@ -5819,10 +5824,13 @@ axis Y Axis Άξονας Y Change how the objects its effect is applied to are drawn Αλλαγή του τρόπου σχεδίασης των αντικειμένων στα οποία εφαρμόζεται το εφέ του Write the document as a file in its own format Εγγραφή του εγγράφου ως αρχείου σε δική του μορφή Show its progress, see Cancel and write log lines Εμφάνιση της προόδου, αναγνώριση της ακύρωσης και εγγραφή γραμμών καταγραφής -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} το δικαίωμα `progress` χρειάζεται το επίπεδο API προσθέτων {PROGRESS_API}: δώστε `api` {PROGRESS_API} + Keep settings of its own Διατήρηση δικών του ρυθμίσεων + Keep a cache of its own, which you can clear Διατήρηση δικής του κρυφής μνήμης, την οποία μπορείτε να εκκαθαρίσετε + Clear Plug-in Cache Εκκαθάριση κρυφής μνήμης προσθέτου +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} το δικαίωμα `{permission}` χρειάζεται το επίπεδο API προσθέτων {PROGRESS_API}: δώστε `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} η λειτουργική μονάδα εισάγει το `env.{name}` με άλλον τύπο από αυτόν του κεντρικού υπολογιστή: ο τύπος του είναι {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` η λειτουργική μονάδα εισάγει το `env.{name}`, το οποίο ένα πρόσθετο μπορεί να χρησιμοποιεί μόνο στο επίπεδο API προσθέτων {PROGRESS_API} (`api` {PROGRESS_API}) και με το δικαίωμα `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network η λειτουργική μονάδα εισάγει το `{from}.{name}`, το οποίο δεν προσφέρει ο κεντρικός υπολογιστής: ένα πρόσθετο μπορεί να εισάγει μόνο τα `env.vc_progress`, `env.vc_cancelled` και `env.vc_log` (επίπεδο API προσθέτων 2) και κανένα WASI, αρχεία ή δίκτυο +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` η λειτουργική μονάδα εισάγει το `env.{name}`, το οποίο ένα πρόσθετο μπορεί να χρησιμοποιεί μόνο στο επίπεδο API προσθέτων {PROGRESS_API} (`api` {PROGRESS_API}) και με το δικαίωμα `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network η λειτουργική μονάδα εισάγει το `{from}.{name}`, το οποίο δεν προσφέρει ο κεντρικός υπολογιστής: ένα πρόσθετο μπορεί να εισάγει μόνο τις συναρτήσεις `env.vc_*` που απαριθμεί το docs/plugins.md (επίπεδο API προσθέτων 2) και κανένα WASI, αρχεία ή δίκτυο @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} το `api` είναι {_1}· αυτός ο κεντρικός υπολογιστής υποστηρίζει τα επίπεδα API προσθέτων 1 έως {API_LEVEL} @msg `grant` must be a list of permission names το `grant` πρέπει να είναι λίστα ονομάτων δικαιωμάτων @msg `grant` names an unknown permission `{shown}` το `grant` αναφέρει άγνωστο δικαίωμα `{shown}` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c0a5d4a8d..1190a8cf4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4736,6 +4736,7 @@ axis Both Ambos @msg an image VectorCraft can't read was left out se omitió una imagen que VectorCraft no puede leer @msg graphs come in as plain art, without their data or settings los gráficos se importan como ilustración normal, sin sus datos ni sus ajustes @msg an image whose data is cut short was left out se omitió una imagen con datos incompletos +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers una imagen incrustada de {_1} × {_2} píxeles se redujo a {_3} × {_4} píxeles para conservar las capas @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel las imágenes vinculadas muestran la vista previa de baja resolución guardada en el archivo hasta que se encuentren sus archivos: vuelva a vincularlas en el panel Vínculos @msg linked type areas past VectorCraft's limits were left out se omitieron las áreas de texto enlazadas que superan los límites de VectorCraft @msg type that changes writing direction inside a text object opens in the direction of its first character el texto que cambia de dirección de escritura dentro de un objeto de texto se abre en la dirección de su primer carácter @@ -4797,6 +4798,7 @@ axis Both Ambos @msg object ids are non-negative integers, not {v} los identificadores de objeto son enteros no negativos, no {v} @msg {key} must be an array of non-negative integer object ids {key} debe ser una lista de identificadores de objeto que sean enteros no negativos @msg invalid object id {value}: expected a non-negative integer identificador de objeto no válido {value}: se esperaba un entero no negativo +@msg `{key}` entry {index} is {value}, not a non-negative integer object id la entrada {index} de `{key}` es {value}, no un identificador de objeto (un entero no negativo) @msg no such object id {value} no existe el objeto con el identificador {value} @msg id {value} is a layer, not an object el identificador {value} es una capa, no un objeto @msg id {value} is a layer, not an object; move layers with layer.move el identificador {value} es una capa, no un objeto; mueva las capas con layer.move @@ -5802,6 +5804,9 @@ axis Y Axis Eje Y Install “{name}”, which asks for these permissions? ¿Instalar “{name}”, que pide estos permisos? ID: {id} ID: {id} Version: {version} Versión: {version} + Install “{name}” in place of the plug-in with the same ID? ¿Instalar “{name}” en lugar del plugin con el mismo ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este archivo no es el plugin instalado antes con este ID. Recibe la configuración y la caché guardadas para ese ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” es un módulo distinto del plugin con ID {_1} y recibiría la configuración y la caché guardadas para él: instálalo con `replace: true` cuando el usuario lo acepte File SHA-256: {hash} SHA-256 del archivo: {hash} Read the objects it is given Leer los objetos que recibe Change, add and delete objects in the document Cambiar, añadir y eliminar objetos del documento @@ -5809,10 +5814,13 @@ axis Y Axis Eje Y Change how the objects its effect is applied to are drawn Cambiar cómo se dibujan los objetos a los que se aplica su efecto Write the document as a file in its own format Escribir el documento como un archivo en su propio formato Show its progress, see Cancel and write log lines Mostrar su progreso, detectar la cancelación y escribir líneas de registro -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permiso `progress` necesita el nivel de API de plugins {PROGRESS_API}: indique `api` {PROGRESS_API} + Keep settings of its own Guardar una configuración propia + Keep a cache of its own, which you can clear Guardar una caché propia, que usted puede vaciar + Clear Plug-in Cache Vaciar caché del plugin +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permiso `{permission}` necesita el nivel de API de plugins {PROGRESS_API}: indique `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} el módulo importa `env.{name}` con un tipo distinto del del host: el del host es {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el módulo importa `env.{name}`, que un plugin solo puede usar en el nivel de API de plugins {PROGRESS_API} (`api` {PROGRESS_API}) y con el permiso `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el módulo importa `{from}.{name}`, que el host no ofrece: un plugin solo puede importar `env.vc_progress`, `env.vc_cancelled` y `env.vc_log` (nivel de API de plugins 2), y ningún WASI, archivo ni red +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` el módulo importa `env.{name}`, que un plugin solo puede usar en el nivel de API de plugins {PROGRESS_API} (`api` {PROGRESS_API}) y con el permiso `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network el módulo importa `{from}.{name}`, que el host no ofrece: un plugin solo puede importar las funciones `env.vc_*` que enumera docs/plugins.md (nivel de API de plugins 2), y ningún WASI, archivo ni red @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` es {_1}; este host admite los niveles de API de plugins de 1 a {API_LEVEL} @msg `grant` must be a list of permission names `grant` debe ser una lista de nombres de permisos @msg `grant` names an unknown permission `{shown}` `grant` nombra un permiso desconocido `{shown}` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d22d2867d..77efc8e94 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4947,6 +4947,7 @@ imageTrace Outline Ääriviiva @msg an image VectorCraft can't read was left out kuva, jota VectorCraft ei voi lukea, jätettiin pois @msg graphs come in as plain art, without their data or settings kaaviot tuodaan tavallisena grafiikkana ilman niiden tietoja ja asetuksia @msg an image whose data is cut short was left out kuva, jonka data on katkennut, jätettiin pois +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers upotettu {_1} × {_2} kuvapisteen kuva pienennettiin kokoon {_3} × {_4} kuvapistettä, jotta tasot säilyvät @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel linkitetyt kuvat näyttävät tiedostoon tallennetun matalan tarkkuuden esikatselun, kunnes niiden tiedostot löytyvät: linkitä ne uudelleen Linkit-paneelissa @msg linked type areas past VectorCraft's limits were left out VectorCraftin rajat ylittävät linkitetyt tekstialueet jätettiin pois @msg type that changes writing direction inside a text object opens in the direction of its first character tekstiobjektin sisällä kirjoitussuuntaa vaihtava teksti avautuu ensimmäisen merkkinsä suuntaan @@ -4982,6 +4983,7 @@ imageTrace Outline Ääriviiva @msg object ids are non-negative integers, not {v} objektitunnukset ovat ei-negatiivisia kokonaislukuja, ei {v} @msg {key} must be an array of non-negative integer object ids {key} on oltava taulukko objektitunnuksia, jotka ovat ei-negatiivisia kokonaislukuja @msg invalid object id {value}: expected a non-negative integer virheellinen objektitunnus {value}: odotettiin ei-negatiivista kokonaislukua +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`-luettelon kohde {index} on {value}, ei objektitunnus (ei-negatiivinen kokonaisluku) @msg no such object id {value} objektia tunnuksella {value} ei ole @msg id {value} is a layer, not an object tunnus {value} on taso, ei objekti @msg id {value} is a layer, not an object; move layers with layer.move tunnus {value} on taso, ei objekti; siirrä tasoja komennolla layer.move @@ -5891,6 +5893,9 @@ axis Y Axis Y-akseli Install “{name}”, which asks for these permissions? Asennetaanko ”{name}”, joka pyytää näitä käyttöoikeuksia? ID: {id} Tunnus: {id} Version: {version} Versio: {version} + Install “{name}” in place of the plug-in with the same ID? Asennetaanko ”{name}” samaa tunnusta käyttävän laajennuksen tilalle? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tämä tiedosto ei ole laajennus, joka oli aiemmin asennettu tällä tunnuksella. Se saa tälle tunnukselle säilytetyt asetukset ja välimuistin. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” on eri moduuli kuin laajennus, jonka tunnus on {_1}, ja se saisi sille säilytetyt asetukset ja välimuistin: asenna se asetuksella `replace: true`, kun käyttäjä hyväksyy File SHA-256: {hash} Tiedoston SHA-256: {hash} Read the objects it is given Lukea sille annetut objektit Change, add and delete objects in the document Muuttaa, lisätä ja poistaa asiakirjan objekteja @@ -5898,10 +5903,13 @@ axis Y Axis Y-akseli Change how the objects its effect is applied to are drawn Muuttaa sitä, miten objektit, joihin sen tehostetta on käytetty, piirretään Write the document as a file in its own format Kirjoittaa asiakirjan tiedostoksi omassa muodossaan Show its progress, see Cancel and write log lines Näyttää edistymisensä, havaita peruutuksen ja kirjoittaa lokirivejä -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} käyttöoikeus `progress` vaatii laajennus-API:n tason {PROGRESS_API}: anna `api` {PROGRESS_API} + Keep settings of its own Säilyttää omat asetuksensa + Keep a cache of its own, which you can clear Säilyttää oman välimuistin, jonka voit tyhjentää + Clear Plug-in Cache Tyhjennä laajennuksen välimuisti +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} käyttöoikeus `{permission}` vaatii laajennus-API:n tason {PROGRESS_API}: anna `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} moduuli tuo kohteen `env.{name}` eri tyyppisenä kuin isäntä: isännän tyyppi on {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` moduuli tuo kohteen `env.{name}`, jota laajennus saa käyttää vain laajennus-API:n tasolla {PROGRESS_API} (`api` {PROGRESS_API}) ja käyttöoikeudella `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network moduuli tuo kohteen `{from}.{name}`, jota isäntä ei tarjoa: laajennus saa tuoda vain kohteet `env.vc_progress`, `env.vc_cancelled` ja `env.vc_log` (laajennus-API:n taso 2) eikä mitään WASIa, tiedostoja tai verkkoa +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` moduuli tuo kohteen `env.{name}`, jota laajennus saa käyttää vain laajennus-API:n tasolla {PROGRESS_API} (`api` {PROGRESS_API}) ja käyttöoikeudella `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network moduuli tuo kohteen `{from}.{name}`, jota isäntä ei tarjoa: laajennus saa tuoda vain docs/plugins.md-tiedostossa luetellut `env.vc_*`-funktiot (laajennus-API:n taso 2) eikä mitään WASIa, tiedostoja tai verkkoa @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` on {_1}; tämä isäntä tukee laajennus-API:n tasoja 1 ja {API_LEVEL} välillä @msg `grant` must be a list of permission names `grant` on oltava luettelo käyttöoikeuksien nimistä @msg `grant` names an unknown permission `{shown}` `grant` nimeää tuntemattoman käyttöoikeuden `{shown}` diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f53054251..faec51ca8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4724,6 +4724,7 @@ axis Both Les deux @msg an image VectorCraft can't read was left out une image que VectorCraft ne peut pas lire a été ignorée @msg graphs come in as plain art, without their data or settings les graphes sont importés comme illustration ordinaire, sans leurs données ni leurs réglages @msg an image whose data is cut short was left out une image aux données tronquées a été ignorée +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers une image incorporée de {_1} × {_2} pixels a été réduite à {_3} × {_4} pixels pour conserver les calques @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel les images liées affichent l’aperçu basse résolution enregistré dans le fichier tant que leurs fichiers ne sont pas trouvés : liez-les à nouveau dans le panneau Liens @msg linked type areas past VectorCraft's limits were left out les zones de texte liées au-delà des limites de VectorCraft ont été omises @msg type that changes writing direction inside a text object opens in the direction of its first character le texte qui change de sens d’écriture dans un objet texte s’ouvre dans le sens de son premier caractère @@ -4805,6 +4806,7 @@ axis Both Les deux @msg object ids are non-negative integers, not {v} les identifiants d’objet sont des entiers positifs ou nuls, pas {v} @msg {key} must be an array of non-negative integer object ids {key} doit être un tableau d’identifiants d’objet, entiers positifs ou nuls @msg invalid object id {value}: expected a non-negative integer identifiant d’objet {value} non valide (un entier positif ou nul est attendu) +@msg `{key}` entry {index} is {value}, not a non-negative integer object id l’entrée {index} de `{key}` vaut {value}, pas un identifiant d’objet (entier positif ou nul) @msg no such object id {value} aucun objet ne porte l’identifiant {value} @msg id {value} is a layer, not an object l’identifiant {value} désigne un calque, pas un objet @msg id {value} is a layer, not an object; move layers with layer.move l’identifiant {value} désigne un calque, pas un objet ; déplacez les calques avec layer.move @@ -5856,6 +5858,9 @@ axis Y Axis Axe Y Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? ID: {id} ID : {id} Version: {version} Version : {version} + Install “{name}” in place of the plug-in with the same ID? Installer « {name} » à la place du module externe qui a le même ID ? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ce fichier n’est pas le module externe installé auparavant avec cet ID. Il reçoit les réglages et le cache conservés pour cet ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees « {name} » est un autre module que le module externe d’ID {_1} et recevrait les réglages et le cache conservés pour celui-ci : installez-le avec `replace: true` une fois que l’utilisateur l’accepte File SHA-256: {hash} SHA-256 du fichier : {hash} Read the objects it is given Lire les objets qui lui sont transmis Change, add and delete objects in the document Modifier, ajouter et supprimer des objets du document @@ -5863,10 +5868,13 @@ axis Y Axis Axe Y Change how the objects its effect is applied to are drawn Modifier le rendu des objets auxquels son effet est appliqué Write the document as a file in its own format Écrire le document dans un fichier à son propre format Show its progress, see Cancel and write log lines Afficher sa progression, détecter l’annulation et écrire des lignes de journal -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorisation `progress` nécessite le niveau d’API de module externe {PROGRESS_API} : indiquez `api` {PROGRESS_API} + Keep settings of its own Conserver ses propres réglages + Keep a cache of its own, which you can clear Conserver son propre cache, que vous pouvez vider + Clear Plug-in Cache Vider le cache du module externe +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorisation `{permission}` nécessite le niveau d’API de module externe {PROGRESS_API} : indiquez `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} le module importe `env.{name}` avec un autre type que celui de l’hôte : celui de l’hôte est {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` le module importe `env.{name}`, qu’un module externe ne peut utiliser qu’au niveau d’API de module externe {PROGRESS_API} (`api` {PROGRESS_API}) et avec l’autorisation `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network le module importe `{from}.{name}`, que l’hôte ne propose pas : un module externe ne peut importer que `env.vc_progress`, `env.vc_cancelled` et `env.vc_log` (niveau d’API de module externe 2), et aucun WASI, fichier ni réseau +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` le module importe `env.{name}`, qu’un module externe ne peut utiliser qu’au niveau d’API de module externe {PROGRESS_API} (`api` {PROGRESS_API}) et avec l’autorisation `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network le module importe `{from}.{name}`, que l’hôte ne propose pas : un module externe ne peut importer que les fonctions `env.vc_*` que liste docs/plugins.md (niveau d’API de module externe 2), et aucun WASI, fichier ni réseau @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` vaut {_1} ; cet hôte prend en charge les niveaux d’API de module externe 1 à {API_LEVEL} @msg `grant` must be a list of permission names `grant` doit être une liste de noms d’autorisations @msg `grant` names an unknown permission `{shown}` `grant` nomme une autorisation inconnue `{shown}` diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 930de2e80..c3a4295fd 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5230,6 +5230,9 @@ Install “{name}”, which asks for these permissions? "{name}" स्थापित करें, जो ये अनुमतियां मांगता है? ID: {id} ID: {id} Version: {version} संस्करण: {version} + Install “{name}” in place of the plug-in with the same ID? समान ID वाले प्लग-इन की जगह “{name}” इंस्टॉल करें? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. यह फ़ाइल इस ID के साथ पहले इंस्टॉल किया गया प्लग-इन नहीं है। इसे उस ID के लिए रखी गई सेटिंग्स और कैश मिलते हैं। +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” आईडी {_1} वाले प्लग-इन से अलग मॉड्यूल है और उसके लिए रखी गई सेटिंग्स और कैश पा लेगा: उपयोगकर्ता की सहमति मिलने पर इसे `replace: true` से इंस्टॉल करें File SHA-256: {hash} फ़ाइल SHA-256: {hash} Read the objects it is given उसे दिए गए ऑब्जेक्ट्स पढ़ना Change, add and delete objects in the document डॉक्यूमेंट में ऑब्जेक्ट्स बदलना, जोड़ना और हटाना @@ -5237,10 +5240,13 @@ Change how the objects its effect is applied to are drawn जिन ऑब्जेक्ट्स पर उसका इफ़ेक्ट लगा है, उनके बनाए जाने का तरीका बदलना Write the document as a file in its own format डॉक्यूमेंट को अपने फ़ॉर्मेट की फ़ाइल के रूप में लिखना Show its progress, see Cancel and write log lines अपनी प्रगति दिखाना, रद्द करने का अनुरोध देखना और लॉग पंक्तियाँ लिखना -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} अनुमति `progress` के लिए प्लग-इन API स्तर {PROGRESS_API} चाहिए: `api` {PROGRESS_API} दें + Keep settings of its own अपनी सेटिंग्स रखना + Keep a cache of its own, which you can clear अपना कैश रखना, जिसे आप साफ़ कर सकते हैं + Clear Plug-in Cache प्लग-इन कैश साफ़ करें +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} अनुमति `{permission}` के लिए प्लग-इन API स्तर {PROGRESS_API} चाहिए: `api` {PROGRESS_API} दें @msg the module imports `env.{name}` with another type than the host's: it is {signature} मॉड्यूल `env.{name}` को होस्ट से अलग प्रकार के साथ आयात करता है: होस्ट का प्रकार {signature} है -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` मॉड्यूल `env.{name}` आयात करता है, जिसे प्लग-इन केवल प्लग-इन API स्तर {PROGRESS_API} (`api` {PROGRESS_API}) पर और अनुमति `progress` के साथ उपयोग कर सकता है -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network मॉड्यूल `{from}.{name}` आयात करता है, जिसे होस्ट उपलब्ध नहीं कराता: प्लग-इन केवल `env.vc_progress`, `env.vc_cancelled` और `env.vc_log` (प्लग-इन API स्तर 2) आयात कर सकता है, और कोई WASI, फ़ाइल या नेटवर्क नहीं +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` मॉड्यूल `env.{name}` आयात करता है, जिसे प्लग-इन केवल प्लग-इन API स्तर {PROGRESS_API} (`api` {PROGRESS_API}) पर और अनुमति `{permission}` के साथ उपयोग कर सकता है +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network मॉड्यूल `{from}.{name}` आयात करता है, जिसे होस्ट उपलब्ध नहीं कराता: प्लग-इन केवल वे `env.vc_*` फ़ंक्शन आयात कर सकता है जो docs/plugins.md में सूचीबद्ध हैं (प्लग-इन API स्तर 2), और कोई WASI, फ़ाइल या नेटवर्क नहीं @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` {_1} है; यह होस्ट प्लग-इन API स्तर 1 से {API_LEVEL} तक का समर्थन करता है @msg `grant` must be a list of permission names `grant` अनुमति नामों की सूची होनी चाहिए @msg `grant` names an unknown permission `{shown}` `grant` एक अज्ञात अनुमति `{shown}` बताता है @@ -6379,6 +6385,7 @@ @msg invalid image data for `{0}` `{0}` के लिए अमान्य इमेज डेटा @msg invalid native panel set अमान्य मूल पैनल सेट @msg invalid object id {value}: expected a non-negative integer अमान्य ऑब्जेक्ट id {value}: एक गैर-नकारात्मक पूर्णांक की उम्मीद +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` की प्रविष्टि {index} {value} है, गैर-नकारात्मक पूर्णांक ऑब्जेक्ट id नहीं @msg Invalid panel layout; restored the default dock अमान्य पैनल लेआउट; डिफ़ॉल्ट डॉक पुनर्स्थापित किया गया @msg invalid parameter name {k} अमान्य पैरामीटर नाम {k} @msg invalid parameters for `{cmd}`: {msg} `{cmd}` के लिए अमान्य पैरामीटर: {msg} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 10999536d..55c65acb0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4748,6 +4748,7 @@ imageTrace Outline Körvonal @msg an image VectorCraft can't read was left out kimaradt egy kép, amelyet a VectorCraft nem tud olvasni @msg graphs come in as plain art, without their data or settings a diagramok egyszerű grafikaként kerülnek be, adataik és beállításaik nélkül @msg an image whose data is cut short was left out kimaradt egy csonka adatú kép +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers egy {_1} × {_2} képpontos beágyazott kép {_3} × {_4} képpontosra lett kicsinyítve, hogy a rétegek megmaradjanak @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel a csatolt képek a fájlban mentett kis felbontású előnézetet mutatják, amíg a fájljaik elő nem kerülnek: csatold őket újra a Csatolások panelen @msg linked type areas past VectorCraft's limits were left out a VectorCraft korlátain túli csatolt szövegterületek kimaradtak @msg type that changes writing direction inside a text object opens in the direction of its first character a szövegobjektumon belül írásirányt váltó szöveg az első karakterének irányában nyílik meg @@ -5118,6 +5119,7 @@ imageTrace Outline Körvonal @msg object ids are non-negative integers, not {v} az objektumazonosító nemnegatív egész szám, ez nem az: {v} @msg {key} must be an array of non-negative integer object ids {key}: objektumazonosítók tömbje kell, mindegyik nemnegatív egész szám @msg invalid object id {value}: expected a non-negative integer érvénytelen objektumazonosító: {value} (nemnegatív egész szám kell) +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` {index} indexű eleme {value}, nem objektumazonosító (nemnegatív egész szám kell) @msg no such object id {value} nincs ilyen objektumazonosító: {value} @msg id {value} is a layer, not an object a(z) {value} azonosító egy réteg, nem objektum @msg id {value} is a layer, not an object; move layers with layer.move a(z) {value} azonosító egy réteg, nem objektum; a rétegeket a layer.move paranccsal helyezheti át @@ -5844,6 +5846,9 @@ axis Y Axis Y tengely Install “{name}”, which asks for these permissions? Telepíted a(z) „{name}” bővítményt, amely ezeket az engedélyeket kéri? ID: {id} Azonosító: {id} Version: {version} Verzió: {version} + Install “{name}” in place of the plug-in with the same ID? Telepíted a(z) „{name}” bővítményt az azonos azonosítójú bővítmény helyére? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ez a fájl nem az a bővítmény, amely korábban ezzel az azonosítóval volt telepítve. Megkapja az ehhez az azonosítóhoz tárolt beállításokat és gyorsítótárat. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees a(z) „{name}” más modul, mint a(z) {_1} azonosítójú bővítmény, és megkapná az ahhoz tárolt beállításokat és gyorsítótárat: telepítsd a `replace: true` megadásával, miután a felhasználó beleegyezett File SHA-256: {hash} A fájl SHA-256 értéke: {hash} Read the objects it is given Olvashatja a kapott objektumokat Change, add and delete objects in the document Módosíthat, hozzáadhat és törölhet objektumokat a dokumentumban @@ -5851,10 +5856,13 @@ axis Y Axis Y tengely Change how the objects its effect is applied to are drawn Módosíthatja, hogyan rajzolódnak ki az objektumok, amelyekre az effektusa alkalmazva van Write the document as a file in its own format Saját formátumú fájlba írhatja a dokumentumot Show its progress, see Cancel and write log lines Megjelenítheti az előrehaladását, észlelheti a megszakítást és naplósorokat írhat -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a `progress` engedélyhez {PROGRESS_API}. szintű bővítmény-API kell: adja meg az `api` {PROGRESS_API} értéket + Keep settings of its own Saját beállításokat tárolhat + Keep a cache of its own, which you can clear Saját gyorsítótárat tárolhat, amelyet Ön kiüríthet + Clear Plug-in Cache Bővítmény gyorsítótárának kiürítése +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a(z) `{permission}` engedélyhez {PROGRESS_API}. szintű bővítmény-API kell: adja meg az `api` {PROGRESS_API} értéket @msg the module imports `env.{name}` with another type than the host's: it is {signature} a modul az `env.{name}` elemet a gazdaprogramétól eltérő típussal importálja: a gazdaprogram típusa {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` a modul importálja az `env.{name}` elemet, amelyet bővítmény csak {PROGRESS_API}. szintű bővítmény-API-val (`api` {PROGRESS_API}) és a `progress` engedéllyel használhat -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network a modul importálja a(z) `{from}.{name}` elemet, amelyet a gazdaprogram nem kínál: bővítmény csak az `env.vc_progress`, `env.vc_cancelled` és `env.vc_log` elemet importálhatja (2. szintű bővítmény-API), WASI-t, fájlokat és hálózatot nem +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` a modul importálja az `env.{name}` elemet, amelyet bővítmény csak {PROGRESS_API}. szintű bővítmény-API-val (`api` {PROGRESS_API}) és a(z) `{permission}` engedéllyel használhat +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network a modul importálja a(z) `{from}.{name}` elemet, amelyet a gazdaprogram nem kínál: bővítmény csak a docs/plugins.md által felsorolt `env.vc_*` függvényeket importálhatja (2. szintű bővítmény-API), WASI-t, fájlokat és hálózatot nem @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} az `api` értéke {_1}; ez a gazdaprogram az 1 és {API_LEVEL} közötti bővítmény-API-szinteket támogatja @msg `grant` must be a list of permission names a `grant` értékének engedélynevek listájának kell lennie @msg `grant` names an unknown permission `{shown}` a `grant` ismeretlen engedélyt nevez meg: `{shown}` diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7dd6e6120..5f3cd5930 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4668,6 +4668,7 @@ imageTrace Outline Kontur @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradien “{name}” tidak didefinisikan dalam berkas: warna polos dipakai sebagai pengganti @msg an image VectorCraft can't read was left out gambar yang tidak dapat dibaca VectorCraft tidak disertakan @msg an image whose data is cut short was left out gambar yang datanya terpotong tidak disertakan +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers gambar tersemat berukuran {_1} × {_2} piksel diperkecil menjadi {_3} × {_4} piksel agar lapisannya tetap terjaga @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel gambar tertaut menampilkan pratinjau resolusi rendah yang tersimpan dalam berkas sampai berkasnya ditemukan: tautkan ulang di panel Tautan @msg linked type areas past VectorCraft's limits were left out area teks tertaut di luar batas VectorCraft dihilangkan @msg type that changes writing direction inside a text object opens in the direction of its first character teks yang mengubah arah penulisan di dalam objek teks dibuka dengan arah karakter pertamanya @@ -4703,6 +4704,7 @@ imageTrace Outline Kontur @msg object ids are non-negative integers, not {v} id objek adalah bilangan bulat non-negatif, bukan {v} @msg {key} must be an array of non-negative integer object ids {key} harus berupa larik id objek yang berupa bilangan bulat non-negatif @msg invalid object id {value}: expected a non-negative integer id objek {value} tidak valid: harus berupa bilangan bulat non-negatif +@msg `{key}` entry {index} is {value}, not a non-negative integer object id entri {index} dari `{key}` adalah {value}, bukan id objek (bilangan bulat non-negatif) @msg no such object id {value} tidak ada objek dengan id {value} @msg automation filesystem access is not granted: read authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas baca tidak ada: {path} @msg automation filesystem access is not granted: write authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas tulis tidak ada: {path} @@ -5744,6 +5746,8 @@ axis Y Axis Sumbu Y Install “{name}”, which asks for these permissions? Instal “{name}”, yang meminta izin berikut? ID: {id} ID: {id} Version: {version} Versi: {version} + Install “{name}” in place of the plug-in with the same ID? Instal “{name}” sebagai pengganti plug-in dengan ID yang sama? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. File ini bukan plug-in yang sebelumnya diinstal dengan ID ini. File ini mendapatkan pengaturan dan cache yang disimpan untuk ID tersebut. File SHA-256: {hash} SHA-256 file: {hash} Read the objects it is given Membaca objek yang diberikan kepadanya Change, add and delete objects in the document Mengubah, menambah, dan menghapus objek dalam dokumen @@ -5751,6 +5755,9 @@ axis Y Axis Sumbu Y Change how the objects its effect is applied to are drawn Mengubah cara objek yang diberi efeknya digambar Write the document as a file in its own format Menulis dokumen sebagai file dalam formatnya sendiri Show its progress, see Cancel and write log lines Menampilkan kemajuannya, mengenali pembatalan, dan menulis baris log + Keep settings of its own Menyimpan pengaturannya sendiri + Keep a cache of its own, which you can clear Menyimpan cache-nya sendiri, yang dapat Anda kosongkan + Clear Plug-in Cache Kosongkan cache plug-in # Actions panel: action sets (#3712). New Set… Set baru… New Set Set baru diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index df556f3d9..e03516a8d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4778,6 +4778,7 @@ axis Both Entrambi @msg an image VectorCraft can't read was left out un'immagine che VectorCraft non può leggere è stata omessa @msg graphs come in as plain art, without their data or settings i grafici vengono importati come grafica semplice, senza i loro dati e le loro impostazioni @msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers un'immagine incorporata di {_1} × {_2} pixel è stata ridotta a {_3} × {_4} pixel per mantenere i livelli @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel le immagini collegate mostrano l'anteprima a bassa risoluzione salvata nel file finché i loro file non vengono trovati: ricollegale nel pannello Collegamenti @msg linked type areas past VectorCraft's limits were left out le aree di testo concatenate oltre i limiti di VectorCraft sono state omesse @msg type that changes writing direction inside a text object opens in the direction of its first character il testo che cambia direzione di scrittura all’interno di un oggetto di testo si apre nella direzione del suo primo carattere @@ -4839,6 +4840,7 @@ axis Both Entrambi @msg object ids are non-negative integers, not {v} gli ID oggetto sono interi non negativi, non {v} @msg {key} must be an array of non-negative integer object ids {key} deve essere un array di ID oggetto interi non negativi @msg invalid object id {value}: expected a non-negative integer ID oggetto {value} non valido: è richiesto un intero non negativo +@msg `{key}` entry {index} is {value}, not a non-negative integer object id la voce {index} di `{key}` è {value}, non un ID oggetto (intero non negativo) @msg no such object id {value} nessun oggetto con ID {value} @msg id {value} is a layer, not an object l’ID {value} è un livello, non un oggetto @msg id {value} is a layer, not an object; move layers with layer.move l’ID {value} è un livello, non un oggetto; sposta i livelli con layer.move @@ -5477,6 +5479,9 @@ axis Y Axis Asse Y Install “{name}”, which asks for these permissions? Installare “{name}”, che chiede queste autorizzazioni? ID: {id} ID: {id} Version: {version} Versione: {version} + Install “{name}” in place of the plug-in with the same ID? Installare “{name}” al posto del plug-in con lo stesso ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Questo file non è il plug-in installato prima con questo ID. Riceve le impostazioni e la cache conservate per quell’ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” è un modulo diverso dal plug-in con ID {_1} e riceverebbe le impostazioni e la cache conservate per esso: installalo con `replace: true` quando l’utente acconsente File SHA-256: {hash} SHA-256 del file: {hash} Read the objects it is given Leggere gli oggetti che riceve Change, add and delete objects in the document Modificare, aggiungere ed eliminare oggetti nel documento @@ -5484,10 +5489,13 @@ axis Y Axis Asse Y Change how the objects its effect is applied to are drawn Cambiare il modo in cui vengono disegnati gli oggetti a cui è applicato il suo effetto Write the document as a file in its own format Scrivere il documento come file in un formato proprio Show its progress, see Cancel and write log lines Mostrare il proprio avanzamento, rilevare l’annullamento e scrivere righe di registro -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorizzazione `progress` richiede il livello API dei plug-in {PROGRESS_API}: indicare `api` {PROGRESS_API} + Keep settings of its own Conservare impostazioni proprie + Keep a cache of its own, which you can clear Conservare una cache propria, che è possibile svuotare + Clear Plug-in Cache Svuota cache del plug-in +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorizzazione `{permission}` richiede il livello API dei plug-in {PROGRESS_API}: indicare `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} il modulo importa `env.{name}` con un tipo diverso da quello dell’host: quello dell’host è {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` il modulo importa `env.{name}`, che un plug-in può usare solo al livello API dei plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e con l’autorizzazione `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network il modulo importa `{from}.{name}`, che l’host non offre: un plug-in può importare solo `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (livello API dei plug-in 2), e nessun WASI, file o rete +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` il modulo importa `env.{name}`, che un plug-in può usare solo al livello API dei plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e con l’autorizzazione `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network il modulo importa `{from}.{name}`, che l’host non offre: un plug-in può importare solo le funzioni `env.vc_*` elencate in docs/plugins.md (livello API dei plug-in 2), e nessun WASI, file o rete @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` è {_1}; questo host supporta i livelli API dei plug-in da 1 a {API_LEVEL} @msg `grant` must be a list of permission names `grant` deve essere un elenco di nomi di autorizzazioni @msg `grant` names an unknown permission `{shown}` `grant` indica un’autorizzazione sconosciuta `{shown}` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f23f35026..e94efc4b2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4462,6 +4462,7 @@ swatch Bright Violet ビビッドバイオレット @msg an image VectorCraft can't read was left out VectorCraft で読み込めない画像は除外されました @msg graphs come in as plain art, without their data or settings グラフは通常のアートとして読み込まれ、データと設定は保持されません @msg an image whose data is cut short was left out データが途中で切れている画像は除外されました +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers レイヤーを保持するため、{_1} × {_2} ピクセルの埋め込み画像を {_3} × {_4} ピクセルに縮小しました @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel リンク画像は、元のファイルが見つかるまで、ファイルに保存された低解像度のプレビューで表示されます:リンクパネルで再リンクしてください @msg linked type areas past VectorCraft's limits were left out VectorCraft の上限を超えるリンクされたテキストエリアは省略されました @msg type that changes writing direction inside a text object opens in the direction of its first character テキストオブジェクト内で書字方向が変わる文字は、最初の文字の方向で開きます @@ -4880,6 +4881,7 @@ swatch Bright Violet ビビッドバイオレット @msg object ids are non-negative integers, not {v} オブジェクト ID は 0 以上の整数です ({v} は使えません) @msg {key} must be an array of non-negative integer object ids {key} にはオブジェクト ID (0 以上の整数) の配列を指定してください @msg invalid object id {value}: expected a non-negative integer オブジェクト ID {value} は正しくありません: 0 以上の整数を指定してください +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` の要素 {index} は {value} で、オブジェクト ID (0 以上の整数) ではありません @msg no such object id {value} ID が {value} のオブジェクトは見つかりません @msg id {value} is a layer, not an object ID {value} はオブジェクトではなくレイヤーです @msg id {value} is a layer, not an object; move layers with layer.move ID {value} はオブジェクトではなくレイヤーです。レイヤーは layer.move で移動してください @@ -5542,6 +5544,9 @@ axis Y Axis Y 軸 Install “{name}”, which asks for these permissions? これらの権限を求める「{name}」をインストールしますか? ID: {id} ID: {id} Version: {version} バージョン: {version} + Install “{name}” in place of the plug-in with the same ID? 同じ ID のプラグインの代わりに「{name}」をインストールしますか? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. このファイルは、以前にこの ID でインストールされていたプラグインではありません。この ID 用に保持されている設定とキャッシュを引き継ぎます。 +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」は ID {_1} のプラグインとは別のモジュールで、そのプラグイン用に保持されている設定とキャッシュを引き継ぐことになります。ユーザーが同意したら `replace: true` を指定してインストールしてください File SHA-256: {hash} ファイルの SHA-256: {hash} Read the objects it is given 渡されたオブジェクトの読み取り Change, add and delete objects in the document ドキュメント内のオブジェクトの変更、追加、削除 @@ -5549,10 +5554,13 @@ axis Y Axis Y 軸 Change how the objects its effect is applied to are drawn 効果が適用されたオブジェクトの描画方法の変更 Write the document as a file in its own format ドキュメントを独自形式のファイルとして書き出し Show its progress, see Cancel and write log lines 進行状況の表示、キャンセルの検知、ログ行の書き込み -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 権限 `progress` にはプラグイン API レベル {PROGRESS_API} が必要です。`api` {PROGRESS_API} を指定してください + Keep settings of its own 独自の設定の保持 + Keep a cache of its own, which you can clear 消去できる独自のキャッシュの保持 + Clear Plug-in Cache プラグインのキャッシュを消去 +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 権限 `{permission}` にはプラグイン API レベル {PROGRESS_API} が必要です。`api` {PROGRESS_API} を指定してください @msg the module imports `env.{name}` with another type than the host's: it is {signature} モジュールが `env.{name}` をホストと異なる型でインポートしています。ホストの型は {signature} です -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` モジュールが `env.{name}` をインポートしていますが、プラグインがこれを使用できるのはプラグイン API レベル {PROGRESS_API} (`api` {PROGRESS_API}) で権限 `progress` がある場合だけです -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network モジュールがホストの提供しない `{from}.{name}` をインポートしています。プラグインがインポートできるのは `env.vc_progress`、`env.vc_cancelled`、`env.vc_log` (プラグイン API レベル 2) だけで、WASI、ファイル、ネットワークは使用できません +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` モジュールが `env.{name}` をインポートしていますが、プラグインがこれを使用できるのはプラグイン API レベル {PROGRESS_API} (`api` {PROGRESS_API}) で権限 `{permission}` がある場合だけです +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network モジュールがホストの提供しない `{from}.{name}` をインポートしています。プラグインがインポートできるのは docs/plugins.md に記載された `env.vc_*` 関数 (プラグイン API レベル 2) だけで、WASI、ファイル、ネットワークは使用できません @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` は {_1} です。このホストが対応するプラグイン API レベルは 1 から {API_LEVEL} です @msg `grant` must be a list of permission names `grant` は権限名のリストにしてください @msg `grant` names an unknown permission `{shown}` `grant` に不明な権限 `{shown}` が含まれています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index b2c16ab4b..76bff4127 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4472,6 +4472,7 @@ font-technology Variable ცვლადი @msg an image VectorCraft can't read was left out გამოსახულება, რომელსაც VectorCraft ვერ კითხულობს, გამოტოვებულია @msg graphs come in as plain art, without their data or settings დიაგრამები შემოდის როგორც ჩვეულებრივი გრაფიკა, მათი მონაცემებისა და პარამეტრების გარეშე @msg an image whose data is cut short was left out გამოსახულება, რომლის მონაცემებიც შეკვეცილია, გამოტოვებულია +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers ჩაშენებული გამოსახულება ზომით {_1} × {_2} პიქსელი შემცირდა {_3} × {_4} პიქსელამდე, რომ შრეები შენარჩუნდეს @msg its editing data doesn't say where its art is მისი რედაქტირების მონაცემები არ მიუთითებს, სად არის მისი ნახატი @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) ეს ფაილი PDF-თავსებადობის გარეშეა შენახული და მისი რედაქტირების მონაცემების წაკითხვა ვერ ხერხდება ({why}) @msg the PDF part couldn't be read ({_1}): the file opened from its editing data PDF ნაწილის წაკითხვა ვერ მოხერხდა ({_1}): ფაილი რედაქტირების მონაცემებიდან გაიხსნა @@ -4499,6 +4500,7 @@ font-technology Variable ცვლადი @msg object ids are non-negative integers, not {v} ობიექტების id არაუარყოფითი მთელი რიცხვებია და არა {v} @msg {key} must be an array of non-negative integer object ids {key} უნდა იყოს ობიექტების id-ების მასივი, სადაც ყოველი id არაუარყოფითი მთელი რიცხვია @msg invalid object id {value}: expected a non-negative integer ობიექტის არასწორი id {value}: მოსალოდნელია არაუარყოფითი მთელი რიცხვი +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`-ის ელემენტი {index} არის {value} და არა ობიექტის id (არაუარყოფითი მთელი რიცხვი) @msg no such object id {value} ობიექტი id-ით {value} არ არსებობს @msg id {value} is a layer, not an object id {value} შრეა და არა ობიექტი @msg id {value} is a layer, not an object; move layers with layer.move id {value} შრეა და არა ობიექტი; შრეების გადასატანად გამოიყენეთ layer.move @@ -5785,6 +5787,9 @@ axis Y Axis Y ღერძი Install “{name}”, which asks for these permissions? დავაყენოთ „{name}“, რომელიც ამ ნებართვებს ითხოვს? ID: {id} ID: {id} Version: {version} ვერსია: {version} + Install “{name}” in place of the plug-in with the same ID? დავაყენოთ „{name}“ იმავე ID-ის მქონე პლაგინის ნაცვლად? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. ეს ფაილი არ არის ამ ID-ით ადრე დაყენებული პლაგინი. ის მიიღებს ამ ID-სთვის შენახულ პარამეტრებს და ქეშს. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ სხვა მოდულია, ვიდრე პლაგინი ID-ით {_1}, და მიიღებდა მისთვის შენახულ პარამეტრებს და ქეშს: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `replace: true` პარამეტრით File SHA-256: {hash} ფაილის SHA-256: {hash} Read the objects it is given მიღებული ობიექტების წაკითხვა Change, add and delete objects in the document დოკუმენტში ობიექტების შეცვლა, დამატება და წაშლა @@ -5792,10 +5797,13 @@ axis Y Axis Y ღერძი Change how the objects its effect is applied to are drawn იმის შეცვლა, თუ როგორ იხატება ობიექტები, რომლებზეც მისი ეფექტია გამოყენებული Write the document as a file in its own format დოკუმენტის ჩაწერა ფაილად საკუთარ ფორმატში Show its progress, see Cancel and write log lines პროგრესის ჩვენება, გაუქმების შემჩნევა და ჟურნალის სტრიქონების ჩაწერა -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `progress` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} + Keep settings of its own საკუთარი პარამეტრების შენახვა + Keep a cache of its own, which you can clear საკუთარი ქეშის შენახვა, რომლის გასუფთავებაც შეგიძლიათ + Clear Plug-in Cache პლაგინის ქეშის გასუფთავება +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `{permission}` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} მოდულს შემოაქვს `env.{name}` ჰოსტისგან განსხვავებული ტიპით: ჰოსტის ტიპია {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` მოდულს შემოაქვს `env.{name}`, რომლის გამოყენებაც პლაგინს მხოლოდ პლაგინის API-ის დონეზე {PROGRESS_API} (`api` {PROGRESS_API}) და ნებართვით `progress` შეუძლია -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network მოდულს შემოაქვს `{from}.{name}`, რომელსაც ჰოსტი არ გვთავაზობს: პლაგინს შეუძლია შემოიტანოს მხოლოდ `env.vc_progress`, `env.vc_cancelled` და `env.vc_log` (პლაგინის API-ის დონე 2) და არა WASI, ფაილები ან ქსელი +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` მოდულს შემოაქვს `env.{name}`, რომლის გამოყენებაც პლაგინს მხოლოდ პლაგინის API-ის დონეზე {PROGRESS_API} (`api` {PROGRESS_API}) და ნებართვით `{permission}` შეუძლია +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network მოდულს შემოაქვს `{from}.{name}`, რომელსაც ჰოსტი არ გვთავაზობს: პლაგინს შეუძლია შემოიტანოს მხოლოდ docs/plugins.md-ში ჩამოთვლილი `env.vc_*` ფუნქციები (პლაგინის API-ის დონე 2) და არა WASI, ფაილები ან ქსელი @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` არის {_1}; ეს ჰოსტი პლაგინის API-ის დონეებს 1-დან {API_LEVEL}-მდე უჭერს მხარს @msg `grant` must be a list of permission names `grant` ნებართვების სახელების სია უნდა იყოს @msg `grant` names an unknown permission `{shown}` `grant` უცნობ ნებართვას ასახელებს: `{shown}` diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c4dd6d1db..f9ab1763c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4389,6 +4389,7 @@ theme Appearance 외관 @msg an image VectorCraft can't read was left out VectorCraft가 읽을 수 없는 이미지는 제외했습니다 @msg graphs come in as plain art, without their data or settings 그래프는 데이터와 설정 없이 일반 아트로 가져왔습니다 @msg an image whose data is cut short was left out 데이터가 잘린 이미지는 제외했습니다 +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers 레이어를 유지하기 위해 {_1} × {_2} 픽셀의 포함된 이미지를 {_3} × {_4} 픽셀로 줄였습니다 @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 연결된 그림은 해당 파일을 찾을 때까지 파일에 저장된 저해상도 미리 보기로 표시됩니다: 링크 패널에서 다시 연결하세요 @msg linked type areas past VectorCraft's limits were left out VectorCraft 한도를 넘는 연결된 문자 영역은 제외되었습니다 @msg type that changes writing direction inside a text object opens in the direction of its first character 문자 개체 안에서 쓰기 방향이 바뀌는 문자는 첫 글자의 방향으로 열립니다 @@ -4807,6 +4808,7 @@ theme Appearance 외관 @msg object ids are non-negative integers, not {v} 개체 id는 음이 아닌 정수여야 하며 {v}은(는) 안 됩니다 @msg {key} must be an array of non-negative integer object ids {key}은(는) 음이 아닌 정수인 개체 id의 배열이어야 합니다 @msg invalid object id {value}: expected a non-negative integer 개체 id {value}이(가) 올바르지 않습니다: 음이 아닌 정수여야 합니다 +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`의 항목 {index}은(는) {value}이며, 음이 아닌 정수인 개체 id가 아닙니다 @msg no such object id {value} id가 {value}인 개체가 없습니다 @msg id {value} is a layer, not an object id {value}은(는) 개체가 아닌 레이어입니다 @msg id {value} is a layer, not an object; move layers with layer.move id {value}은(는) 개체가 아닌 레이어입니다. 레이어는 layer.move로 이동하세요 @@ -5438,6 +5440,9 @@ axis Y Axis Y축 Install “{name}”, which asks for these permissions? 이 권한을 요청하는 “{name}”을(를) 설치하시겠습니까? ID: {id} ID: {id} Version: {version} 버전: {version} + Install “{name}” in place of the plug-in with the same ID? 같은 ID의 플러그인 대신 “{name}”을(를) 설치하시겠습니까? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 이 파일은 이전에 이 ID로 설치된 플러그인이 아닙니다. 이 ID에 보관된 설정과 캐시를 받습니다. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”은(는) ID {_1}의 플러그인과 다른 모듈이며 그 플러그인에 보관된 설정과 캐시를 받게 됩니다. 사용자가 동의하면 `replace: true`로 설치하세요 File SHA-256: {hash} 파일 SHA-256: {hash} Read the objects it is given 전달받은 개체 읽기 Change, add and delete objects in the document 문서의 개체 변경, 추가 및 삭제 @@ -5445,10 +5450,13 @@ axis Y Axis Y축 Change how the objects its effect is applied to are drawn 효과가 적용된 개체가 그려지는 방식 변경 Write the document as a file in its own format 문서를 자체 형식의 파일로 쓰기 Show its progress, see Cancel and write log lines 진행률 표시, 취소 감지 및 로그 줄 쓰기 -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` 권한에는 플러그인 API 수준 {PROGRESS_API}이(가) 필요합니다. `api` {PROGRESS_API}을(를) 지정하십시오 + Keep settings of its own 자체 설정 보관 + Keep a cache of its own, which you can clear 지울 수 있는 자체 캐시 보관 + Clear Plug-in Cache 플러그인 캐시 지우기 +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `{permission}` 권한에는 플러그인 API 수준 {PROGRESS_API}이(가) 필요합니다. `api` {PROGRESS_API}을(를) 지정하십시오 @msg the module imports `env.{name}` with another type than the host's: it is {signature} 모듈이 `env.{name}`을(를) 호스트와 다른 형식으로 가져옵니다. 호스트의 형식은 {signature}입니다 -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 모듈이 `env.{name}`을(를) 가져오지만, 플러그인은 이를 플러그인 API 수준 {PROGRESS_API}(`api` {PROGRESS_API})에서 `progress` 권한이 있을 때만 사용할 수 있습니다 -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 모듈이 호스트가 제공하지 않는 `{from}.{name}`을(를) 가져옵니다. 플러그인은 `env.vc_progress`, `env.vc_cancelled`, `env.vc_log`(플러그인 API 수준 2)만 가져올 수 있으며 WASI, 파일, 네트워크는 사용할 수 없습니다 +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` 모듈이 `env.{name}`을(를) 가져오지만, 플러그인은 이를 플러그인 API 수준 {PROGRESS_API}(`api` {PROGRESS_API})에서 `{permission}` 권한이 있을 때만 사용할 수 있습니다 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network 모듈이 호스트가 제공하지 않는 `{from}.{name}`을(를) 가져옵니다. 플러그인은 docs/plugins.md에 나열된 `env.vc_*` 함수(플러그인 API 수준 2)만 가져올 수 있으며 WASI, 파일, 네트워크는 사용할 수 없습니다 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api`가 {_1}입니다. 이 호스트는 플러그인 API 수준 1부터 {API_LEVEL}까지 지원합니다 @msg `grant` must be a list of permission names `grant`는 권한 이름 목록이어야 합니다 @msg `grant` names an unknown permission `{shown}` `grant`에 알 수 없는 권한 `{shown}`이(가) 있습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 5d9d7157e..15aa4078e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4680,6 +4680,7 @@ imageTrace Outline Contour @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it verloop „{name}” is niet gedefinieerd in het bestand: er is een effen kleur voor gebruikt @msg an image VectorCraft can't read was left out een afbeelding die VectorCraft niet kan lezen, is weggelaten @msg an image whose data is cut short was left out een afbeelding waarvan de gegevens zijn afgekapt, is weggelaten +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers een ingesloten afbeelding van {_1} × {_2} pixels is verkleind tot {_3} × {_4} pixels om de lagen te behouden @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel gekoppelde afbeeldingen tonen het in het bestand opgeslagen voorbeeld met lage resolutie totdat hun bestanden zijn gevonden: koppel ze opnieuw in het paneel Koppelingen @msg linked type areas past VectorCraft's limits were left out gekoppelde tekstvlakken buiten de limieten van VectorCraft zijn weggelaten @msg type that changes writing direction inside a text object opens in the direction of its first character tekst die binnen een tekstobject van schrijfrichting verandert, wordt geopend in de richting van het eerste teken @@ -4715,6 +4716,7 @@ imageTrace Outline Contour @msg object ids are non-negative integers, not {v} object-id's zijn niet-negatieve gehele getallen, niet {v} @msg {key} must be an array of non-negative integer object ids {key} moet een array van object-id's zijn die niet-negatieve gehele getallen zijn @msg invalid object id {value}: expected a non-negative integer ongeldige object-id {value}: een niet-negatief geheel getal verwacht +@msg `{key}` entry {index} is {value}, not a non-negative integer object id item {index} van `{key}` is {value}, geen object-id (een niet-negatief geheel getal) @msg no such object id {value} er is geen object met id {value} @msg automation filesystem access is not granted: read authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de leesbevoegdheid ontbreekt: {path} @msg automation filesystem access is not granted: write authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de schrijfbevoegdheid ontbreekt: {path} @@ -5790,6 +5792,8 @@ axis Y Axis Y-as Install “{name}”, which asks for these permissions? „{name}” installeren, dat om deze machtigingen vraagt? ID: {id} ID: {id} Version: {version} Versie: {version} + Install “{name}” in place of the plug-in with the same ID? „{name}” installeren in plaats van de plug-in met dezelfde ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Dit bestand is niet de plug-in die eerder met deze ID was geïnstalleerd. Het krijgt de instellingen en cache die voor die ID bewaard zijn. File SHA-256: {hash} SHA-256 van het bestand: {hash} Read the objects it is given De objecten lezen die het krijgt Change, add and delete objects in the document Objecten in het document wijzigen, toevoegen en verwijderen @@ -5797,6 +5801,9 @@ axis Y Axis Y-as Change how the objects its effect is applied to are drawn Wijzigen hoe de objecten waarop het effect is toegepast worden getekend Write the document as a file in its own format Het document als bestand in een eigen indeling schrijven Show its progress, see Cancel and write log lines De voortgang tonen, een annulering zien en logregels schrijven + Keep settings of its own Eigen instellingen bewaren + Keep a cache of its own, which you can clear Een eigen cache bewaren, die u kunt legen + Clear Plug-in Cache Plug-incache legen # Actions panel: action sets (#3712). New Set… Nieuwe set… New Set Nieuwe set diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 618001d10..3764c9657 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4870,6 +4870,7 @@ imageTrace Outline Kontur @msg an image VectorCraft can't read was left out pominięto obraz, którego VectorCraft nie potrafi odczytać @msg graphs come in as plain art, without their data or settings wykresy są wczytywane jako zwykła grafika, bez ich danych i ustawień @msg an image whose data is cut short was left out pominięto obraz z uciętymi danymi +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers osadzony obraz o wymiarach {_1} × {_2} pikseli zmniejszono do {_3} × {_4} pikseli, aby zachować warstwy @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel połączone obrazy pokazują zapisany w pliku podgląd niskiej rozdzielczości, dopóki ich pliki nie zostaną znalezione: połącz je ponownie w panelu Łącza @msg linked type areas past VectorCraft's limits were left out połączone obszary tekstu poza limitami VectorCraft zostały pominięte @msg type that changes writing direction inside a text object opens in the direction of its first character tekst zmieniający kierunek pisania wewnątrz obiektu tekstowego otwiera się w kierunku swojego pierwszego znaku @@ -4905,6 +4906,7 @@ imageTrace Outline Kontur @msg object ids are non-negative integers, not {v} identyfikatory obiektów to nieujemne liczby całkowite, nie {v} @msg {key} must be an array of non-negative integer object ids {key} musi być tablicą identyfikatorów obiektów, które są nieujemnymi liczbami całkowitymi @msg invalid object id {value}: expected a non-negative integer nieprawidłowy identyfikator obiektu {value}: oczekiwano nieujemnej liczby całkowitej +@msg `{key}` entry {index} is {value}, not a non-negative integer object id element {index} w `{key}` to {value}, a nie identyfikator obiektu (nieujemna liczba całkowita) @msg no such object id {value} brak obiektu o identyfikatorze {value} @msg id {value} is a layer, not an object identyfikator {value} oznacza warstwę, a nie obiekt @msg id {value} is a layer, not an object; move layers with layer.move identyfikator {value} oznacza warstwę, a nie obiekt; warstwy przenosi się za pomocą layer.move @@ -5714,6 +5716,9 @@ axis Y Axis Oś Y Install “{name}”, which asks for these permissions? Zainstalować „{name}”, wtyczkę, która prosi o te uprawnienia? ID: {id} ID: {id} Version: {version} Wersja: {version} + Install “{name}” in place of the plug-in with the same ID? Zainstalować „{name}” zamiast wtyczki o tym samym ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ten plik nie jest wtyczką zainstalowaną wcześniej z tym ID. Otrzyma ustawienia i pamięć podręczną przechowywane dla tego ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” to inny moduł niż wtyczka o ID {_1} i otrzymałby przechowywane dla niej ustawienia i pamięć podręczną: zainstaluj go z `replace: true`, gdy użytkownik się zgodzi File SHA-256: {hash} SHA-256 pliku: {hash} Read the objects it is given Odczytywać obiekty, które otrzymuje Change, add and delete objects in the document Zmieniać, dodawać i usuwać obiekty w dokumencie @@ -5721,10 +5726,13 @@ axis Y Axis Oś Y Change how the objects its effect is applied to are drawn Zmieniać sposób rysowania obiektów, do których zastosowano jej efekt Write the document as a file in its own format Zapisywać dokument jako plik we własnym formacie Show its progress, see Cancel and write log lines Pokazywać swój postęp, wykrywać anulowanie i zapisywać wiersze dziennika -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} uprawnienie `progress` wymaga poziomu API wtyczek {PROGRESS_API}: podaj `api` {PROGRESS_API} + Keep settings of its own Przechowywać własne ustawienia + Keep a cache of its own, which you can clear Przechowywać własną pamięć podręczną, którą możesz wyczyścić + Clear Plug-in Cache Wyczyść pamięć podręczną wtyczki +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} uprawnienie `{permission}` wymaga poziomu API wtyczek {PROGRESS_API}: podaj `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} moduł importuje `env.{name}` z innym typem niż typ hosta: typ hosta to {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` moduł importuje `env.{name}`, którego wtyczka może używać tylko na poziomie API wtyczek {PROGRESS_API} (`api` {PROGRESS_API}) i z uprawnieniem `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network moduł importuje `{from}.{name}`, którego host nie udostępnia: wtyczka może importować tylko `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (poziom API wtyczek 2), bez WASI, plików i sieci +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` moduł importuje `env.{name}`, którego wtyczka może używać tylko na poziomie API wtyczek {PROGRESS_API} (`api` {PROGRESS_API}) i z uprawnieniem `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network moduł importuje `{from}.{name}`, którego host nie udostępnia: wtyczka może importować tylko funkcje `env.vc_*` wymienione w docs/plugins.md (poziom API wtyczek 2), bez WASI, plików i sieci @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ma wartość {_1}; ten host obsługuje poziomy API wtyczek od 1 do {API_LEVEL} @msg `grant` must be a list of permission names `grant` musi być listą nazw uprawnień @msg `grant` names an unknown permission `{shown}` `grant` wymienia nieznane uprawnienie `{shown}` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 07d31d60d..db09fa622 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4315,6 +4315,7 @@ axis Both Ambos @msg an image VectorCraft can't read was left out uma imagem que o VectorCraft não consegue ler foi omitida @msg graphs come in as plain art, without their data or settings os gráficos são importados como arte comum, sem seus dados nem configurações @msg an image whose data is cut short was left out uma imagem com dados incompletos foi omitida +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers uma imagem incorporada de {_1} × {_2} pixels foi reduzida para {_3} × {_4} pixels para manter as camadas @msg its editing data doesn't say where its art is seus dados de edição não indicam onde está a arte @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) este arquivo foi salvo sem compatibilidade com PDF e seus dados de edição não podem ser lidos ({why}) @msg the PDF part couldn't be read ({_1}): the file opened from its editing data não foi possível ler a parte PDF ({_1}): o arquivo foi aberto a partir dos seus dados de edição @@ -4343,6 +4344,7 @@ axis Both Ambos @msg object ids are non-negative integers, not {v} os ids de objeto são inteiros não negativos, não {v} @msg {key} must be an array of non-negative integer object ids {key} deve ser uma matriz de ids de objeto que sejam inteiros não negativos @msg invalid object id {value}: expected a non-negative integer id de objeto inválido {value}: era esperado um inteiro não negativo +@msg `{key}` entry {index} is {value}, not a non-negative integer object id a entrada {index} de `{key}` é {value}, não um id de objeto (inteiro não negativo) @msg no such object id {value} não existe objeto com o id {value} @msg id {value} is a layer, not an object o id {value} é uma camada, não um objeto @msg id {value} is a layer, not an object; move layers with layer.move o id {value} é uma camada, não um objeto; mova camadas com layer.move @@ -5776,6 +5778,9 @@ axis Y Axis Eixo Y Install “{name}”, which asks for these permissions? Instalar “{name}”, que pede estas permissões? ID: {id} ID: {id} Version: {version} Versão: {version} + Install “{name}” in place of the plug-in with the same ID? Instalar “{name}” no lugar do plug-in com o mesmo ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este arquivo não é o plug-in instalado antes com este ID. Ele recebe as configurações e o cache guardados para esse ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” é um módulo diferente do plug-in com ID {_1} e receberia as configurações e o cache guardados para ele: instale-o com `replace: true` depois que o usuário concordar File SHA-256: {hash} SHA-256 do arquivo: {hash} Read the objects it is given Ler os objetos que recebe Change, add and delete objects in the document Alterar, adicionar e excluir objetos no documento @@ -5783,10 +5788,13 @@ axis Y Axis Eixo Y Change how the objects its effect is applied to are drawn Alterar como são desenhados os objetos aos quais seu efeito é aplicado Write the document as a file in its own format Gravar o documento como um arquivo em formato próprio Show its progress, see Cancel and write log lines Mostrar seu progresso, detectar o cancelamento e gravar linhas de log -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a permissão `progress` exige o nível de API de plug-in {PROGRESS_API}: informe `api` {PROGRESS_API} + Keep settings of its own Guardar configurações próprias + Keep a cache of its own, which you can clear Guardar um cache próprio, que você pode limpar + Clear Plug-in Cache Limpar cache do plug-in +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a permissão `{permission}` exige o nível de API de plug-in {PROGRESS_API}: informe `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} o módulo importa `env.{name}` com um tipo diferente do tipo do host: o do host é {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` o módulo importa `env.{name}`, que um plug-in só pode usar no nível de API de plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e com a permissão `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network o módulo importa `{from}.{name}`, que o host não oferece: um plug-in só pode importar `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (nível de API de plug-in 2), e nenhum WASI, arquivo ou rede +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` o módulo importa `env.{name}`, que um plug-in só pode usar no nível de API de plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e com a permissão `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network o módulo importa `{from}.{name}`, que o host não oferece: um plug-in só pode importar as funções `env.vc_*` listadas em docs/plugins.md (nível de API de plug-in 2), e nenhum WASI, arquivo ou rede @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` é {_1}; este host aceita os níveis de API de plug-in de 1 a {API_LEVEL} @msg `grant` must be a list of permission names `grant` deve ser uma lista de nomes de permissões @msg `grant` names an unknown permission `{shown}` `grant` cita uma permissão desconhecida `{shown}` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8023f0a9d..43d042781 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4773,6 +4773,7 @@ axis Both Оба @msg an image VectorCraft can't read was left out изображение, которое VectorCraft не может прочитать, пропущено @msg graphs come in as plain art, without their data or settings диаграммы импортируются как обычная графика, без их данных и настроек @msg an image whose data is cut short was left out изображение с обрезанными данными пропущено +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers встроенное изображение {_1} × {_2} пикселей уменьшено до {_3} × {_4} пикселей, чтобы сохранить слои @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel связанные изображения показываются как сохранённый в файле просмотр низкого разрешения, пока их файлы не найдены: восстановите связи на панели «Связи» @msg linked type areas past VectorCraft's limits were left out связанные текстовые области за пределами ограничений VectorCraft пропущены @msg type that changes writing direction inside a text object opens in the direction of its first character текст, меняющий направление письма внутри текстового объекта, открывается в направлении своего первого символа @@ -4836,6 +4837,7 @@ axis Both Оба @msg object ids are non-negative integers, not {v} идентификаторы объектов — неотрицательные целые числа, а не {v} @msg {key} must be an array of non-negative integer object ids {key} должно быть массивом идентификаторов объектов, которые являются неотрицательными целыми числами @msg invalid object id {value}: expected a non-negative integer недопустимый идентификатор объекта {value}: ожидается неотрицательное целое число +@msg `{key}` entry {index} is {value}, not a non-negative integer object id элемент {index} в `{key}` равен {value}, а не идентификатору объекта (неотрицательному целому числу) @msg no such object id {value} объект с идентификатором {value} не существует @msg id {value} is a layer, not an object идентификатор {value} относится к слою, а не к объекту @msg id {value} is a layer, not an object; move layers with layer.move идентификатор {value} относится к слою, а не к объекту; перемещайте слои с помощью layer.move @@ -5472,6 +5474,9 @@ axis Y Axis Ось Y Install “{name}”, which asks for these permissions? Установить «{name}», которому нужны эти разрешения? ID: {id} ID: {id} Version: {version} Версия: {version} + Install “{name}” in place of the plug-in with the same ID? Установить «{name}» вместо модуля с тем же ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Этот файл не является модулем, установленным ранее с этим ID. Он получит настройки и кэш, сохранённые для этого ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» отличается от модуля с ID {_1} и получил бы сохранённые для него настройки и кэш: установите его с `replace: true`, когда пользователь согласится File SHA-256: {hash} SHA-256 файла: {hash} Read the objects it is given Читать переданные ему объекты Change, add and delete objects in the document Изменять, добавлять и удалять объекты в документе @@ -5479,10 +5484,13 @@ axis Y Axis Ось Y Change how the objects its effect is applied to are drawn Изменять то, как рисуются объекты, к которым применён его эффект Write the document as a file in its own format Записывать документ в файл собственного формата Show its progress, see Cancel and write log lines Показывать свой ход выполнения, замечать отмену и записывать строки журнала -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} разрешению `progress` нужен уровень API модулей {PROGRESS_API}: укажите `api` {PROGRESS_API} + Keep settings of its own Хранить собственные настройки + Keep a cache of its own, which you can clear Хранить собственный кэш, который можно очистить + Clear Plug-in Cache Очистить кэш модуля +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} разрешению `{permission}` нужен уровень API модулей {PROGRESS_API}: укажите `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль импортирует `env.{name}` с другим типом, чем у хоста: у хоста тип {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` модуль импортирует `env.{name}`, который модуль может использовать только на уровне API модулей {PROGRESS_API} (`api` {PROGRESS_API}) и с разрешением `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network модуль импортирует `{from}.{name}`, который хост не предоставляет: модуль может импортировать только `env.vc_progress`, `env.vc_cancelled` и `env.vc_log` (уровень API модулей 2), без WASI, файлов и сети +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` модуль импортирует `env.{name}`, который модуль может использовать только на уровне API модулей {PROGRESS_API} (`api` {PROGRESS_API}) и с разрешением `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network модуль импортирует `{from}.{name}`, который хост не предоставляет: модуль может импортировать только функции `env.vc_*`, перечисленные в docs/plugins.md (уровень API модулей 2), без WASI, файлов и сети @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` равно {_1}; этот хост поддерживает уровни API модулей от 1 до {API_LEVEL} @msg `grant` must be a list of permission names `grant` должен быть списком имён разрешений @msg `grant` names an unknown permission `{shown}` `grant` называет неизвестное разрешение `{shown}` diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index dddc1145b..2ad7a484d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4635,6 +4635,7 @@ axis Both Båda @msg an image VectorCraft can't read was left out en bild som VectorCraft inte kan läsa utelämnades @msg graphs come in as plain art, without their data or settings diagram läses in som vanlig grafik, utan sina data och inställningar @msg an image whose data is cut short was left out en bild med avkortade data utelämnades +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers en inbäddad bild på {_1} × {_2} pixlar förminskades till {_3} × {_4} pixlar för att behålla lagren @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel länkade bilder visar förhandsgranskningen med låg upplösning som sparats i filen tills deras filer hittas: länka om dem i panelen Länkar @msg linked type areas past VectorCraft's limits were left out länkade textområden utöver VectorCrafts gränser utelämnades @msg type that changes writing direction inside a text object opens in the direction of its first character text som byter skrivriktning inuti ett textobjekt öppnas i riktningen för sitt första tecken @@ -4714,6 +4715,7 @@ axis Both Båda @msg object ids are non-negative integers, not {v} objekt-id:n är icke-negativa heltal, inte {v} @msg {key} must be an array of non-negative integer object ids {key} måste vara en array med objekt-id:n som är icke-negativa heltal @msg invalid object id {value}: expected a non-negative integer ogiltigt objekt-id {value}: ett icke-negativt heltal förväntades +@msg `{key}` entry {index} is {value}, not a non-negative integer object id post {index} i `{key}` är {value}, inte ett objekt-id (ett icke-negativt heltal) @msg no such object id {value} det finns inget objekt med id {value} @msg id {value} is a layer, not an object id {value} är ett lager, inte ett objekt @msg id {value} is a layer, not an object; move layers with layer.move id {value} är ett lager, inte ett objekt; flytta lager med layer.move @@ -5884,6 +5886,9 @@ axis Y Axis Y-axel Install “{name}”, which asks for these permissions? Installera ”{name}”, som begär dessa behörigheter? ID: {id} ID: {id} Version: {version} Version: {version} + Install “{name}” in place of the plug-in with the same ID? Installera ”{name}” i stället för det plugin som har samma id? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Den här filen är inte det plugin som tidigare installerades med detta id. Den får de inställningar och den cache som sparats för detta id. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” är en annan modul än pluginet med id {_1} och skulle få de inställningar och den cache som sparats för det: installera det med `replace: true` när användaren har godkänt File SHA-256: {hash} Filens SHA-256: {hash} Read the objects it is given Läsa objekten den får Change, add and delete objects in the document Ändra, lägga till och ta bort objekt i dokumentet @@ -5891,10 +5896,13 @@ axis Y Axis Y-axel Change how the objects its effect is applied to are drawn Ändra hur objekten som dess effekt används på ritas Write the document as a file in its own format Skriva dokumentet som en fil i sitt eget format Show its progress, see Cancel and write log lines Visa sitt förlopp, se när det avbryts och skriva loggrader -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} behörigheten `progress` kräver plugin-API-nivå {PROGRESS_API}: ange `api` {PROGRESS_API} + Keep settings of its own Spara egna inställningar + Keep a cache of its own, which you can clear Spara en egen cache, som du kan tömma + Clear Plug-in Cache Töm pluginets cache +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} behörigheten `{permission}` kräver plugin-API-nivå {PROGRESS_API}: ange `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modulen importerar `env.{name}` med en annan typ än värdens: värdens typ är {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modulen importerar `env.{name}`, som ett plugin bara får använda på plugin-API-nivå {PROGRESS_API} (`api` {PROGRESS_API}) och med behörigheten `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modulen importerar `{from}.{name}`, som värden inte erbjuder: ett plugin får bara importera `env.vc_progress`, `env.vc_cancelled` och `env.vc_log` (plugin-API-nivå 2), och ingen WASI, inga filer eller nätverk +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modulen importerar `env.{name}`, som ett plugin bara får använda på plugin-API-nivå {PROGRESS_API} (`api` {PROGRESS_API}) och med behörigheten `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modulen importerar `{from}.{name}`, som värden inte erbjuder: ett plugin får bara importera de `env.vc_*`-funktioner som docs/plugins.md listar (plugin-API-nivå 2), och ingen WASI, inga filer eller nätverk @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` är {_1}; den här värden stöder plugin-API-nivå 1 till {API_LEVEL} @msg `grant` must be a list of permission names `grant` måste vara en lista med behörighetsnamn @msg `grant` names an unknown permission `{shown}` `grant` anger en okänd behörighet `{shown}` diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 40dab2ea0..d8a34883a 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2062,9 +2062,9 @@ fn dataset_import_cell_errors_are_translated() { /// The errors of the parameter checks that many commands share (a number given as text, a missing /// number or index, a fraction for a whole number, `ids` that aren't a list of object ids, an -/// object id that is negative or names no object, a date that isn't Unix seconds) fill their -/// templates, and each language shows the value in the message, such as a quoted `"60 mm"`, as -/// given: in its row's translation, or in English where it has none. +/// `ids` entry or an `id` that is negative, an object id that names no object, a date that isn't +/// Unix seconds) fill their templates, and each language shows the value in the message, such as a +/// quoted `"60 mm"`, as given: in its row's translation, or in English where it has none. #[test] fn shared_param_check_errors_show_their_values_as_given() { use serde_json::json; @@ -2075,7 +2075,7 @@ fn shared_param_check_errors_show_their_values_as_given() { s.execute("select.all", &json!({})).unwrap(); // A command, its params, the English template of its error and the template's values. type Case = (&'static str, serde_json::Value, &'static str, &'static [(&'static str, &'static str)]); - let cases: [Case; 10] = [ + let cases: [Case; 11] = [ ("object.move", json!({"dx": "60 mm"}), "`{key}` must be a number, not {v}", &[("key", "dx"), ("v", r#""60 mm""#)]), ("object.rotate", json!({"angle": true}), "`{key}` must be a number, not {v}", &[("key", "angle"), ("v", "true")]), ("object.setBounds", json!({"width": [1, 2]}), "`{key}` must be a number, not {v}", &[("key", "width"), ("v", "[1,2]")]), @@ -2083,7 +2083,13 @@ fn shared_param_check_errors_show_their_values_as_given() { ("appearance.setItem", json!({}), "missing integer `{key}`", &[("key", "index")]), ("object.transformEach", json!({"reference": 2.5}), "`{key}` must be a whole number", &[("key", "reference")]), ("edit.clear", json!({"ids": "all"}), "{key} must be an array of non-negative integer object ids", &[("key", "ids")]), - ("edit.clear", json!({"ids": [-1]}), "invalid object id {value}: expected a non-negative integer", &[("value", "-1")]), + ( + "edit.clear", + json!({"ids": [-1]}), + "`{key}` entry {index} is {value}, not a non-negative integer object id", + &[("key", "ids"), ("index", "0"), ("value", "-1")], + ), + ("paint.invert", json!({"id": -1}), "invalid object id {value}: expected a non-negative integer", &[("value", "-1")]), ("edit.clear", json!({"ids": [99999]}), "no such object id {value}", &[("value", "99999")]), ("file.new", json!({"created": "yesterday"}), "{key} must be Unix seconds (an integer) or null", &[("key", "created")]), ]; diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 87da1b099..7ceb6b3f8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4473,6 +4473,7 @@ axis Both Her İkisi @msg an image VectorCraft can't read was left out VectorCraft'ın okuyamadığı bir resim dışarıda bırakıldı @msg graphs come in as plain art, without their data or settings grafikler, verileri ve ayarları olmadan düz çizim olarak içe aktarılır @msg an image whose data is cut short was left out verileri kısa kesilen bir resim dışarıda bırakıldı +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers katmanları korumak için {_1} × {_2} piksellik gömülü bir resim {_3} × {_4} piksele küçültüldü @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel bağlantılı resimler, dosyaları bulunana kadar dosyaya kaydedilen düşük çözünürlüklü önizlemeyi gösterir: bunları Bağlantılar panelinde yeniden bağlayın @msg linked type areas past VectorCraft's limits were left out VectorCraft sınırlarını aşan bağlantılı metin alanları dışarıda bırakıldı @msg type that changes writing direction inside a text object opens in the direction of its first character bir metin nesnesi içinde yazı yönünü değiştiren metin, ilk karakterinin yönünde açılır @@ -5531,6 +5532,7 @@ theme Appearance Görünüm @msg missing integer `{key}` `{key}` tam sayısı eksik @msg {key} must be an array of non-negative integer object ids {key}, negatif olmayan tam sayı nesne kimlikleri dizisi olmalıdır @msg invalid object id {value}: expected a non-negative integer geçersiz nesne kimliği {value}: negatif olmayan bir tam sayı bekleniyordu +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` içindeki {index} dizinli öğe {value}, negatif olmayan tam sayı bir nesne kimliği değil @msg no such object id {value} böyle bir nesne kimliği yok: {value} @msg id {value} is a layer, not an object {value} kimliği bir katmana ait, bir nesneye değil @msg id {value} is a layer, not an object; move layers with layer.move {value} kimliği bir katmana ait, bir nesneye değil; katmanları layer.move ile taşıyın @@ -5818,6 +5820,9 @@ axis Y Axis Y ekseni Install “{name}”, which asks for these permissions? Bu izinleri isteyen “{name}” yüklensin mi? ID: {id} Kimlik: {id} Version: {version} Sürüm: {version} + Install “{name}” in place of the plug-in with the same ID? Aynı kimliğe sahip eklentinin yerine “{name}” yüklensin mi? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Bu dosya, daha önce bu kimlikle yüklenen eklenti değil. O kimlik için saklanan ayarları ve önbelleği alır. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”, {_1} kimlikli eklentiden farklı bir modül ve onun için saklanan ayarları ve önbelleği alacak: kullanıcı onayladıktan sonra `replace: true` ile yükleyin File SHA-256: {hash} Dosya SHA-256: {hash} Read the objects it is given Kendisine verilen nesneleri okuma Change, add and delete objects in the document Belgedeki nesneleri değiştirme, ekleme ve silme @@ -5825,10 +5830,13 @@ axis Y Axis Y ekseni Change how the objects its effect is applied to are drawn Efektinin uygulandığı nesnelerin nasıl çizildiğini değiştirme Write the document as a file in its own format Belgeyi kendi biçiminde bir dosya olarak yazma Show its progress, see Cancel and write log lines İlerlemesini gösterme, iptali algılama ve günlük satırları yazma -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` izni için eklenti API düzeyi {PROGRESS_API} gerekir: `api` {PROGRESS_API} verin + Keep settings of its own Kendi ayarlarını saklama + Keep a cache of its own, which you can clear Temizleyebileceğiniz kendi önbelleğini saklama + Clear Plug-in Cache Eklenti Önbelleğini Temizle +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `{permission}` izni için eklenti API düzeyi {PROGRESS_API} gerekir: `api` {PROGRESS_API} verin @msg the module imports `env.{name}` with another type than the host's: it is {signature} modül `env.{name}` öğesini ana bilgisayarınkinden farklı bir türle içe aktarıyor: ana bilgisayarın türü {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modül `env.{name}` öğesini içe aktarıyor; bir eklenti bunu yalnızca eklenti API düzeyi {PROGRESS_API} (`api` {PROGRESS_API}) ile ve `progress` izniyle kullanabilir -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modül, ana bilgisayarın sunmadığı `{from}.{name}` öğesini içe aktarıyor: bir eklenti yalnızca `env.vc_progress`, `env.vc_cancelled` ve `env.vc_log` öğelerini (eklenti API düzeyi 2) içe aktarabilir; WASI, dosya veya ağ kullanamaz +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modül `env.{name}` öğesini içe aktarıyor; bir eklenti bunu yalnızca eklenti API düzeyi {PROGRESS_API} (`api` {PROGRESS_API}) ile ve `{permission}` izniyle kullanabilir +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modül, ana bilgisayarın sunmadığı `{from}.{name}` öğesini içe aktarıyor: bir eklenti yalnızca docs/plugins.md dosyasında listelenen `env.vc_*` işlevlerini (eklenti API düzeyi 2) içe aktarabilir; WASI, dosya veya ağ kullanamaz @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` değeri {_1}; bu ana bilgisayar 1 ile {API_LEVEL} arasındaki eklenti API düzeylerini destekler @msg `grant` must be a list of permission names `grant` izin adlarından oluşan bir liste olmalıdır @msg `grant` names an unknown permission `{shown}` `grant` bilinmeyen bir izin adlandırıyor: `{shown}` diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0c406257f..2cd5d1560 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4762,6 +4762,7 @@ axis Both Обидві @msg an image VectorCraft can't read was left out зображення, яке VectorCraft не може прочитати, пропущено @msg graphs come in as plain art, without their data or settings діаграми імпортуються як звичайна графіка, без їхніх даних і налаштувань @msg an image whose data is cut short was left out зображення з обрізаними даними пропущено +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers вбудоване зображення {_1} × {_2} пікселів зменшено до {_3} × {_4} пікселів, щоб зберегти шари @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel пов’язані зображення показуються як збережений у файлі перегляд низької роздільності, доки їхні файли не знайдено: відновіть зв’язки на панелі «Зв’язки» @msg linked type areas past VectorCraft's limits were left out пов’язані текстові області понад обмеження VectorCraft пропущено @msg type that changes writing direction inside a text object opens in the direction of its first character текст, що змінює напрямок письма всередині текстового об’єкта, відкривається в напрямку свого першого символу @@ -4823,6 +4824,7 @@ axis Both Обидві @msg object ids are non-negative integers, not {v} ідентифікатори об’єктів — невід’ємні цілі числа, а не {v} @msg {key} must be an array of non-negative integer object ids {key} має бути масивом ідентифікаторів об’єктів, які є невід’ємними цілими числами @msg invalid object id {value}: expected a non-negative integer неприпустимий ідентифікатор об’єкта {value}: очікується невід’ємне ціле число +@msg `{key}` entry {index} is {value}, not a non-negative integer object id елемент {index} у `{key}` дорівнює {value}, а не ідентифікатору об’єкта (невід’ємному цілому числу) @msg no such object id {value} об’єкт з ідентифікатором {value} не існує @msg id {value} is a layer, not an object ідентифікатор {value} належить шару, а не об’єкту @msg id {value} is a layer, not an object; move layers with layer.move ідентифікатор {value} належить шару, а не об’єкту; переміщуйте шари за допомогою layer.move @@ -5457,6 +5459,9 @@ axis Y Axis Вісь Y Install “{name}”, which asks for these permissions? Встановити «{name}», якому потрібні ці дозволи? ID: {id} ID: {id} Version: {version} Версія: {version} + Install “{name}” in place of the plug-in with the same ID? Встановити «{name}» замість модуля з тим самим ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Цей файл не є модулем, установленим раніше з цим ID. Він отримає налаштування й кеш, збережені для цього ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» відрізняється від модуля з ID {_1} і отримав би збережені для нього налаштування й кеш: встановіть його з `replace: true`, коли користувач погодиться File SHA-256: {hash} SHA-256 файлу: {hash} Read the objects it is given Читати передані йому об’єкти Change, add and delete objects in the document Змінювати, додавати й видаляти об’єкти в документі @@ -5464,10 +5469,13 @@ axis Y Axis Вісь Y Change how the objects its effect is applied to are drawn Змінювати те, як малюються об’єкти, до яких застосовано його ефект Write the document as a file in its own format Записувати документ у файл власного формату Show its progress, see Cancel and write log lines Показувати свій поступ, помічати скасування і записувати рядки журналу -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} дозволу `progress` потрібен рівень API модулів {PROGRESS_API}: укажіть `api` {PROGRESS_API} + Keep settings of its own Зберігати власні налаштування + Keep a cache of its own, which you can clear Зберігати власний кеш, який можна очистити + Clear Plug-in Cache Очистити кеш модуля +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} дозволу `{permission}` потрібен рівень API модулів {PROGRESS_API}: укажіть `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль імпортує `env.{name}` з іншим типом, ніж у хоста: у хоста тип {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` модуль імпортує `env.{name}`, який модуль може використовувати лише на рівні API модулів {PROGRESS_API} (`api` {PROGRESS_API}) і з дозволом `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network модуль імпортує `{from}.{name}`, який хост не надає: модуль може імпортувати лише `env.vc_progress`, `env.vc_cancelled` і `env.vc_log` (рівень API модулів 2), без WASI, файлів і мережі +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` модуль імпортує `env.{name}`, який модуль може використовувати лише на рівні API модулів {PROGRESS_API} (`api` {PROGRESS_API}) і з дозволом `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network модуль імпортує `{from}.{name}`, який хост не надає: модуль може імпортувати лише функції `env.vc_*`, перелічені в docs/plugins.md (рівень API модулів 2), без WASI, файлів і мережі @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` дорівнює {_1}; цей хост підтримує рівні API модулів від 1 до {API_LEVEL} @msg `grant` must be a list of permission names `grant` має бути списком назв дозволів @msg `grant` names an unknown permission `{shown}` `grant` називає невідомий дозвіл `{shown}` diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 43ec40e90..4a9f0c3b4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4400,6 +4400,7 @@ theme Appearance Giao diện @msg an image VectorCraft can't read was left out một ảnh VectorCraft không đọc được đã bị bỏ qua @msg graphs come in as plain art, without their data or settings biểu đồ được nhập dưới dạng hình vẽ thường, không kèm dữ liệu và thiết lập của chúng @msg an image whose data is cut short was left out một ảnh bị cắt ngắn dữ liệu đã bị bỏ qua +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers một ảnh nhúng {_1} × {_2} điểm ảnh đã được thu nhỏ còn {_3} × {_4} điểm ảnh để giữ các lớp @msg an object can't be read không thể đọc một đối tượng @msg an object in perspective would leave the edited grid đối tượng trong phối cảnh sẽ rời khỏi lưới đang chỉnh sửa @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Một đối tượng chỉ nhận một hiệu ứng 3D. Hãy chỉnh sửa tùy chọn của hiệu ứng hiện có hoặc xóa nó trước. @@ -4772,6 +4773,7 @@ theme Appearance Giao diện @msg object ids are non-negative integers, not {v} id đối tượng là số nguyên không âm, không phải {v} @msg {key} must be an array of non-negative integer object ids {key} phải là một mảng id đối tượng là số nguyên không âm @msg invalid object id {value}: expected a non-negative integer id đối tượng {value} không hợp lệ: cần một số nguyên không âm +@msg `{key}` entry {index} is {value}, not a non-negative integer object id mục {index} của `{key}` là {value}, không phải id đối tượng (số nguyên không âm) @msg no such object id {value} không có đối tượng nào có id {value} @msg id {value} is a layer, not an object id {value} là một lớp, không phải đối tượng @msg id {value} is a layer, not an object; move layers with layer.move id {value} là một lớp, không phải đối tượng; hãy di chuyển lớp bằng layer.move @@ -5458,6 +5460,9 @@ axis Y Axis Trục Y Install “{name}”, which asks for these permissions? Cài đặt “{name}”, plug-in yêu cầu các quyền này? ID: {id} ID: {id} Version: {version} Phiên bản: {version} + Install “{name}” in place of the plug-in with the same ID? Cài đặt “{name}” thay cho plug-in có cùng ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tệp này không phải là plug-in đã cài đặt trước đây với ID này. Tệp sẽ nhận cài đặt và bộ nhớ đệm được lưu cho ID đó. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” là mô-đun khác với plug-in có ID {_1} và sẽ nhận cài đặt và bộ nhớ đệm được lưu cho plug-in đó: hãy cài đặt với `replace: true` khi người dùng đồng ý File SHA-256: {hash} SHA-256 của tệp: {hash} Read the objects it is given Đọc các đối tượng được trao cho nó Change, add and delete objects in the document Thay đổi, thêm và xóa đối tượng trong tài liệu @@ -5465,10 +5470,13 @@ axis Y Axis Trục Y Change how the objects its effect is applied to are drawn Thay đổi cách vẽ các đối tượng được áp dụng hiệu ứng của nó Write the document as a file in its own format Ghi tài liệu thành tệp theo định dạng riêng Show its progress, see Cancel and write log lines Hiển thị tiến độ, nhận biết yêu cầu hủy và ghi các dòng nhật ký -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} quyền `progress` cần API plug-in cấp {PROGRESS_API}: hãy đặt `api` {PROGRESS_API} + Keep settings of its own Lưu cài đặt riêng + Keep a cache of its own, which you can clear Lưu bộ nhớ đệm riêng mà bạn có thể xóa + Clear Plug-in Cache Xóa bộ nhớ đệm của plug-in +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} quyền `{permission}` cần API plug-in cấp {PROGRESS_API}: hãy đặt `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} mô-đun nhập `env.{name}` với kiểu khác với kiểu của máy chủ: kiểu của máy chủ là {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` mô-đun nhập `env.{name}`, thứ mà plug-in chỉ được dùng ở API plug-in cấp {PROGRESS_API} (`api` {PROGRESS_API}) và với quyền `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network mô-đun nhập `{from}.{name}`, thứ mà máy chủ không cung cấp: plug-in chỉ được nhập `env.vc_progress`, `env.vc_cancelled` và `env.vc_log` (API plug-in cấp 2), không có WASI, tệp hay mạng +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` mô-đun nhập `env.{name}`, thứ mà plug-in chỉ được dùng ở API plug-in cấp {PROGRESS_API} (`api` {PROGRESS_API}) và với quyền `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network mô-đun nhập `{from}.{name}`, thứ mà máy chủ không cung cấp: plug-in chỉ được nhập các hàm `env.vc_*` mà docs/plugins.md liệt kê (API plug-in cấp 2), không có WASI, tệp hay mạng @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` là {_1}; máy chủ này hỗ trợ các mức API plug-in từ 1 đến {API_LEVEL} @msg `grant` must be a list of permission names `grant` phải là danh sách tên quyền @msg `grant` names an unknown permission `{shown}` `grant` nêu một quyền không xác định `{shown}` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 79576169f..d194bb74c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4186,6 +4186,7 @@ imageTrace Outline 轮廓 @msg an image VectorCraft can't read was left out 已略去 VectorCraft 无法读取的图像 @msg graphs come in as plain art, without their data or settings 图表作为普通图稿导入,不保留其数据和设置 @msg an image whose data is cut short was left out 已略去数据不完整的图像 +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers 为保留图层,{_1} × {_2} 像素的嵌入图像已缩小为 {_3} × {_4} 像素 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 文件中未定义渐变“{name}”:已改用纯色代替 @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) VectorCraft 尚无法读取的画笔未加入“画笔”面板 ({_1}) @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 带有多个填色或描边、效果或画笔的对象已按其绘制外观导入 ({_1}):其外观尚不可编辑 @@ -4589,6 +4590,7 @@ imageTrace Outline 轮廓 @msg object ids are non-negative integers, not {v} 对象 id 是非负整数,而不是 {v} @msg {key} must be an array of non-negative integer object ids {key} 必须是由非负整数对象 id 组成的数组 @msg invalid object id {value}: expected a non-negative integer 无效的对象 id {value}:应为非负整数 +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` 中索引 {index} 处的项为 {value},不是非负整数对象 id @msg no such object id {value} 没有 id 为 {value} 的对象 @msg id {value} is a layer, not an object id {value} 是图层,不是对象 @msg id {value} is a layer, not an object; move layers with layer.move id {value} 是图层,不是对象;请使用 layer.move 移动图层 @@ -5699,6 +5701,9 @@ axis Y Axis Y 轴 Install “{name}”, which asks for these permissions? 要安装需要这些权限的“{name}”吗? ID: {id} ID:{id} Version: {version} 版本:{version} + Install “{name}” in place of the plug-in with the same ID? 要安装“{name}”来替换具有相同 ID 的插件吗? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此文件不是之前以此 ID 安装的插件。它将获得为该 ID 保存的设置和缓存。 +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”与 ID 为 {_1} 的插件是不同的模块,并会获得为其保存的设置和缓存:请在用户同意后使用 `replace: true` 安装 File SHA-256: {hash} 文件 SHA-256:{hash} Read the objects it is given 读取交给它的对象 Change, add and delete objects in the document 更改、添加和删除文档中的对象 @@ -5706,10 +5711,13 @@ axis Y Axis Y 轴 Change how the objects its effect is applied to are drawn 更改应用了其效果的对象的绘制方式 Write the document as a file in its own format 将文档写为其自有格式的文件 Show its progress, see Cancel and write log lines 显示进度、检测取消请求并写入日志行 -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 权限 `progress` 需要增效工具 API 级别 {PROGRESS_API}:请设置 `api` {PROGRESS_API} + Keep settings of its own 保存自己的设置 + Keep a cache of its own, which you can clear 保存自己的缓存,您可以清除它 + Clear Plug-in Cache 清除增效工具缓存 +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 权限 `{permission}` 需要增效工具 API 级别 {PROGRESS_API}:请设置 `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} 模块以与宿主不同的类型导入 `env.{name}`:宿主的类型为 {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 模块导入了 `env.{name}`,增效工具只有在增效工具 API 级别 {PROGRESS_API}(`api` {PROGRESS_API})且具有权限 `progress` 时才能使用它 -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 模块导入了宿主不提供的 `{from}.{name}`:增效工具只能导入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效工具 API 级别 2),不能使用 WASI、文件或网络 +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` 模块导入了 `env.{name}`,增效工具只有在增效工具 API 级别 {PROGRESS_API}(`api` {PROGRESS_API})且具有权限 `{permission}` 时才能使用它 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network 模块导入了宿主不提供的 `{from}.{name}`:增效工具只能导入 docs/plugins.md 列出的 `env.vc_*` 函数(增效工具 API 级别 2),不能使用 WASI、文件或网络 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 为 {_1};此主机支持的增效工具 API 级别为 1 到 {API_LEVEL} @msg `grant` must be a list of permission names `grant` 必须是权限名称的列表 @msg `grant` names an unknown permission `{shown}` `grant` 包含未知权限 `{shown}` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f671627b6..a799dd0b1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2037,6 +2037,7 @@ file-filter Templates 範本 @msg an image VectorCraft can't read was left out 已略過 VectorCraft 無法讀取的影像 @msg graphs come in as plain art, without their data or settings 圖表以一般圖稿匯入,不保留其資料與設定 @msg an image whose data is cut short was left out 已略過資料不完整的影像 +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers 為保留圖層,{_1} × {_2} 像素的內嵌影像已縮小為 {_3} × {_4} 像素 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 檔案中未定義漸層「{name}」:已改用單色替代 @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) VectorCraft 尚無法讀取的筆刷未加入「筆刷」面板 ({_1}) @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 具有多個填色或筆畫、效果或筆刷的物件已依其繪製外觀匯入 ({_1}):其外觀目前尚無法編輯 @@ -5410,6 +5411,7 @@ lighting Light 光源 @msg {key} must be Unix seconds (an integer) or null {key} 必須是以秒為單位的 Unix 時間 (整數) 或 null @msg {key} must be an array of non-negative integer object ids {key} 必須是由非負整數物件 id 組成的陣列 @msg invalid object id {value}: expected a non-negative integer 無效的物件 id {value}:應為非負整數 +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` 中索引 {index} 處的項目為 {value},不是非負整數物件 id @msg no such object id {value} 沒有 id 為 {value} 的物件 @msg id {value} is a layer, not an object id {value} 是圖層,不是物件 @msg id {value} is a layer, not an object; move layers with layer.move id {value} 是圖層,不是物件;請使用 layer.move 移動圖層 @@ -5495,6 +5497,9 @@ axis Y Axis Y 軸 Install “{name}”, which asks for these permissions? 要安裝需要這些權限的「{name}」嗎? ID: {id} ID:{id} Version: {version} 版本:{version} + Install “{name}” in place of the plug-in with the same ID? 要安裝「{name}」來取代具有相同 ID 的增效模組嗎? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此檔案不是先前以此 ID 安裝的增效模組。它會取得為該 ID 保存的設定和快取。 +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」與 ID 為 {_1} 的增效模組是不同的模組,並會取得為其保存的設定和快取:請在使用者同意後以 `replace: true` 安裝 File SHA-256: {hash} 檔案 SHA-256:{hash} Read the objects it is given 讀取交給它的物件 Change, add and delete objects in the document 變更、新增和刪除文件中的物件 @@ -5502,10 +5507,13 @@ axis Y Axis Y 軸 Change how the objects its effect is applied to are drawn 變更套用其效果的物件的繪製方式 Write the document as a file in its own format 將文件寫成其自有格式的檔案 Show its progress, see Cancel and write log lines 顯示進度、偵測取消要求並寫入記錄行 -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 權限 `progress` 需要增效模組 API 層級 {PROGRESS_API}:請設定 `api` {PROGRESS_API} + Keep settings of its own 保存自己的設定 + Keep a cache of its own, which you can clear 保存自己的快取,您可以清除它 + Clear Plug-in Cache 清除增效模組快取 +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 權限 `{permission}` 需要增效模組 API 層級 {PROGRESS_API}:請設定 `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} 模組以與主機不同的類型匯入 `env.{name}`:主機的類型為 {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 模組匯入了 `env.{name}`,增效模組只有在增效模組 API 層級 {PROGRESS_API}(`api` {PROGRESS_API})且具有權限 `progress` 時才能使用它 -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 模組匯入了主機未提供的 `{from}.{name}`:增效模組只能匯入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效模組 API 層級 2),不能使用 WASI、檔案或網路 +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` 模組匯入了 `env.{name}`,增效模組只有在增效模組 API 層級 {PROGRESS_API}(`api` {PROGRESS_API})且具有權限 `{permission}` 時才能使用它 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network 模組匯入了主機未提供的 `{from}.{name}`:增效模組只能匯入 docs/plugins.md 列出的 `env.vc_*` 函式(增效模組 API 層級 2),不能使用 WASI、檔案或網路 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 為 {_1};此主機支援的增效模組 API 層級為 1 到 {API_LEVEL} @msg `grant` must be a list of permission names `grant` 必須是權限名稱的清單 @msg `grant` names an unknown permission `{shown}` `grant` 包含未知權限 `{shown}` diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 5373ce9d6..f74a0da01 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -176,6 +176,8 @@ fn permission_words(name: &str) -> String { Some(permissions::Permission::ExportWrite) => tl!("Write the document as a file in its own format"), Some(permissions::Permission::ImportRead) => tl!("Read files you open in its format and make documents from them"), Some(permissions::Permission::Progress) => tl!("Show its progress, see Cancel and write log lines"), + Some(permissions::Permission::Settings) => tl!("Keep settings of its own"), + Some(permissions::Permission::Cache) => tl!("Keep a cache of its own, which you can clear"), None => return permissions::display_text(name, permissions::MAX_SHOWN_FIELD), }; words.to_string() @@ -184,15 +186,18 @@ fn permission_words(name: &str) -> String { /// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` /// with `name`) → `plugin.install`'s result. A plug-in that lists permissions the user hasn't /// granted it yet (`grant`) asks them first, showing its name, id, version, file hash and what each -/// permission lets it do, and so does a filter that asks for a heavy budget the user hasn't allowed -/// yet (`allowHeavy`); OK installs it if the module still has the hash the prompt showed, Cancel -/// installs nothing → `{pending}` while it asks. Every +/// permission lets it do, so does a filter that asks for a heavy budget the user hasn't allowed +/// yet (`allowHeavy`), and so does a module that would take the place of another one with its id +/// and get its settings and cache (`replace`); OK installs it if the module still has the hash the +/// prompt showed, Cancel installs nothing → `{pending}` while it asks. Every /// text from the manifest is shown with its hidden characters escaped and its length capped /// ([`permissions::display_text`]). pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result { let heavy_allowed = module.get("allowHeavy").and_then(Value::as_bool) == Some(true); let granted = module.get("grant").is_some_and(|g| !g.is_null()); - if !heavy_allowed || !granted { + // `replace: false` asks for no replacing, so only a module without `replace` asks. + let replace_given = module.get("replace").is_some_and(|r| !r.is_null()); + if !heavy_allowed || !granted || !replace_given { let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); // What OK installs: the same module, with what the user has agreed to so far, pinned to @@ -235,6 +240,19 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result Vec { /// Installing a plug-in that lists permissions asks first, showing its name, id, version, file hash /// and each permission in plain words, with hidden characters from the manifest escaped. Cancel -/// installs nothing, OK installs it; installing it again with the same permissions doesn't ask, and -/// one that asks for another does. +/// installs nothing, OK installs it; another module with its id and the same permissions asks only +/// whether to take the installed one's place, and one that asks for another permission asks again. #[test] fn installing_a_plugin_that_lists_permissions_asks_first() { let mut app = app(); @@ -165,9 +165,24 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { assert!(app.ui.dialog.is_none()); assert!(vectorcraft_plugins::registry::get(id).is_some()); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(hash)); - // Again with the same permissions: installed without asking. + // Another module with the same permissions asks for none of them again, but asks before it + // takes the installed one's place, showing its id and file hash. let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#); + let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.str("message"), "Install “Tidy 2” in place of the plug-in with the same ID?"); + let again_hash = vectorcraft_plugins::permissions::sha256_hex(&again); + assert_eq!( + d.str("detail"), + format!( + "ID: {id}\nFile SHA-256: {again_hash}\n\nThis file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID." + ) + ); + dialogs::cancel(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed"); app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); + dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none(), "the same permissions don't ask again"); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into())); // One that asks for another permission asks again. diff --git a/docs/mcp.md b/docs/mcp.md index 0051f95c8..90ba11e28 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -403,7 +403,10 @@ counterparts, `image.info`, `links.info`, `links.goTo`, `links.unembed`, `shaper `transparency.editOpacityMask`, `transparency.viewOpacityMask`, `symbol.edit`, the `object.blend.spine.*` commands and `path.curvature`) refuse an `id` that is not a non-negative integer (`"abc"`, `"5"`, `true`) with an error and change nothing; an omitted or null `id` keeps the default. A command that leaves out an object selected inside a -selected group also leaves it out when `ids` list it together with a group that holds it. +selected group also leaves it out when `ids` list it together with a group that holds it. Every command that takes +`ids` (or `slices`, for the slice commands) refuses a list with an entry that is not a non-negative integer, or a value +that is not a list, and changes nothing; it does not act on the other entries. The error for an entry names the key, +the entry's index (from 0) and its value: "`ids` entry 1 is true, not a non-negative integer object id". Paint values (`fill`, `stroke`) accept `"#rrggbb"`, `"none"`, `[r,g,b]` (0..1), `{"c","m","y","k"}`, `{"gray"}`, or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"}`). @@ -4937,7 +4940,8 @@ The Slice tool (`slice`, Shift+K) and the Slice Selection tool (`sliceSelection` `object.slice.create {x, y, width, height}` (the Slice tool's drag; it snaps to ruler guides), `object.slice.select {slices?, toggle?}`, `object.slice.move {slices?, dx, dy}` (an object slice moves its object), `object.slice.setRect {id, x, y, width, height}` (user slices) and `object.slice.delete {slices?}`. Slice ids are user -slice ids and the ids of objects with an object slice. +slice ids and the ids of objects with an object slice. `object.slice.select` with `slices` left out or null deselects +the slices. ```json {"name":"run_command","arguments":{"command":"object.slice.create","params":{"x":0,"y":0,"width":200,"height":80}}} @@ -6377,13 +6381,25 @@ runs it where it is called, so the call may take minutes; `notifications/cancell A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns -an error naming the permissions needed. The grant is kept with the module's hash; a module -with the same id that lists no new permission installs without `grant`, and `plugin.remove` forgets the grant. A +an error naming the permissions needed. The grant is kept with the module's hash; without a `Plug-ins` folder, a +module with the same id that lists no new permission installs without `grant`. A grant kept in a `Plug-ins` folder +covers only the module with that hash, so another module under the id needs `grant` again. `plugin.remove` forgets the +grant. A filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log -under its id. A module that imports anything else, or imports these without `api` 2 and `progress`, is refused by -`plugin.install` and `plugin.inspect` with an error naming the import. +under its id. One that lists `settings` or `cache` keeps settings or a cache of its own in the `Plug-ins` folder +(VectorCraft's own caps: 256 settings and 1 MiB, 512 MiB of cache): `plugin.list` and `plugin.info` give the cache's +size as `cacheBytes`, `plugin.clearCache {id?}` empties the cache of one plug-in or of all of them and returns +`{cleared: [id], bytesFreed}`, replacing a plug-in keeps both, and `plugin.remove` deletes both (also for an id that +isn't installed but still has them). A module with other bytes than the plug-in installed with its id, or, with none +installed, one whose id has settings or a cache in the +`Plug-ins` folder (`needsReplace: true` in its `plugin.inspect` entry), installs only with `replace: true`: ask the +user first, since it takes that plug-in's place and gets its settings and cache. Without it, `plugin.install` returns +an error naming the plug-in. The same module installs again without `replace`, and `replace: false` refuses a module +whose id is installed. A module that imports +anything else, or imports one of these without `api` 2 and the import's permission, is refused by `plugin.install` and +`plugin.inspect` with an error naming the import and the permission. `plugin.install` rejects a `replace` or `allowHeavy` value that is not a boolean, a `grant` that is not a list of permission names, and a `hash` that is not the module's SHA-256. `prefs.set` returns an error and changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty @@ -6425,6 +6441,8 @@ Disable and Continue can't save the plug-in it turns off, this launch skips it a {"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.vectorcraft.example.desaturate","params":{"amount":60}}}} {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"plugin.org.example.wobble","params":{"size":4}}}} {"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/plot.hpgl","importer":"plugin.org.example.hpgl"}}} +{"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/tiles-2.wasm","replace":true}}} +{"name":"run_command","arguments":{"command":"plugin.clearCache","params":{"id":"org.example.tiles"}}} ``` ## Colour adjustments diff --git a/docs/plugins.md b/docs/plugins.md index acad53603..93d95d026 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -26,12 +26,13 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash, cacheBytes}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256; `cacheBytes` is the size of its [cache](#settings-and-cache)) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | -| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | -| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`). | -| `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | +| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`) and `needsReplace` (true when `plugin.install` needs `replace: true`). | +| `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder with its [settings and cache](#settings-and-cache). | +| `plugin.clearCache` | `{"id"?}` | Deletes every entry in the [cache](#settings-and-cache) of plug-in `id`, or of every installed plug-in; settings stay. Returns `{"cleared": [id], "bytesFreed"}`. | | `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). | Live effects are applied like any effect: `effect.apply {"effect": "plugin.", "params": {…}}`, edited with @@ -94,7 +95,7 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem | Limit | Default | On violation | |---|---|---| -| Host imports | none at API level 1; at level 2 only the three [host imports](#host-imports-api-level-2): no WASI, file system, network, clock or randomness | module rejected at install, naming the import | +| Host imports | none at API level 1; at level 2 only the [host imports](#host-imports-api-level-2): no WASI, file system, network, clock or randomness | module rejected at install, naming the import | | Module size | 32 MiB | rejected | | Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | | Output | 64 MiB of JSON | `Limit("output size")` | @@ -146,6 +147,8 @@ capability model proposed for PhotoCraft plug-ins | `export.write` | exporter | Write the document as a file in its own format. | | `import.read` | importer | Read files you open in its format and make documents from them. | | `progress` | all | Show its progress, see Cancel and write log lines through the [host imports](#host-imports-api-level-2). Needs `api` 2. | +| `settings` | all | Keep settings of its own between runs and sessions ([settings and cache](#settings-and-cache)). Needs `api` 2. | +| `cache` | all | Keep a cache of its own, which the user can clear ([settings and cache](#settings-and-cache)). Needs `api` 2. | - A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and @@ -153,8 +156,9 @@ capability model proposed for PhotoCraft plug-ins A filter whose list is empty may do none of these, and also installs without a prompt. - An effect, an exporter or an importer that lists permissions lists all of its implied ones. A filter may list any of its three. - Any kind may add `progress`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply - to the kind, more than 16 names, `progress` with an `api` below 2, or one of the names kept for a later plug-in API + Any kind may add `progress`, `settings` and `cache`, which no kind implies. An unknown name, a name listed twice, a + name that doesn't apply to the kind, more than 16 names, `progress`, `settings` or `cache` with an `api` below 2, or + one of the names kept for a later plug-in API (`ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. - **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of the file and each permission in plain words, and install it once the user clicks OK, if the file still has the @@ -163,14 +167,22 @@ capability model proposed for PhotoCraft plug-ins `plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. +- **Replacing asks first.** When the file is another module than the plug-in installed with its id, or, with none + installed, its id has settings and a cache in the `Plug-ins` folder, Install Plug-in… and File › Open show its id + and SHA-256 and ask whether to install it in place of that plug-in, which gives it the settings and cache kept for + the id. `plugin.install` refuses such a module, naming it, unless `replace` is `true`, which an agent passes once + the user agreed (`plugin.inspect`'s `needsReplace` tells it beforehand). The Additional Plug-ins Folder and + `plugin.reload` do not ask. - The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the - `Plug-ins` folder. Installing a module with the same id that lists no new permission needs no new approval; one that - lists more asks again. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a + `Plug-ins` folder. Without a `Plug-ins` folder, installing a module with the same id that lists no new permission + needs no new approval; one that lists more asks again. A grant kept in a `Plug-ins` folder covers only the module + with the recorded hash: another module under the id needs `grant` again, even when it lists the same permissions, + and does not get the id's settings and cache until then. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, - a stored plug-in whose permissions are not granted is skipped. The Additional Plug-ins Folder and `plugin.reload` + a stored plug-in whose permissions are not granted to that module is skipped. The Additional Plug-ins Folder and `plugin.reload` install only plug-ins whose permissions were granted, and report the others. - The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the @@ -179,7 +191,8 @@ capability model proposed for PhotoCraft plug-ins imports](#host-imports-api-level-2). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`. - Parameter declarations are unchanged by permissions (`params`, below). -Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed +Every run gets a **fresh instance**, so no state survives between calls except what a plug-in writes to its +[settings and cache](#settings-and-cache). Traps, exhausted budgets, malformed modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that fails draws the object's geometry unchanged; `plugin.info` reports why (`lastError`). @@ -213,9 +226,10 @@ module's memory. ### Host imports (API level 2) -A plug-in whose manifest gives `"api": 2` and lists the `progress` [permission](#permissions) may import these +A plug-in whose manifest gives `"api": 2` and lists the [permission](#permissions) an import needs may import these functions from module `env` (the module a Rust or C toolchain gives `extern` functions by default). `vc_abi_version` -still returns `1`. +still returns `1`. `vc_progress`, `vc_cancelled` and `vc_log` need `progress`; the `vc_settings_*` imports need +`settings` and the `vc_cache_*` imports need `cache` ([settings and cache](#settings-and-cache)). | Import | Signature | Meaning | |---|---|---| @@ -224,8 +238,8 @@ still returns `1`. | `vc_log` | `(level, ptr, len) -> i32` | Writes the UTF-8 text at `ptr` as a log line of `level` (0 error, 1 warning, 2 information, 3 debug). Returns `0` when written, `1` when the run's log budget is spent and the line was dropped, `-1` when the text doesn't lie inside the module's memory, `-2` when it is not UTF-8, `-3` for another level. | - The host checks the imports when the module is installed: an import it doesn't offer, one with another signature, - and one the manifest doesn't allow (an `api` below 2, or no `progress` permission) refuse the module, and the error - names the import. + and one the manifest doesn't allow (an `api` below 2, or without the import's permission) refuse the module, and the + error names the import and the permission. - A filter's or an exporter's progress is shown as a share: a heavy filter's progress window and the export's progress window draw a bar with a percentage, and Cancel there makes `vc_cancelled` return `1`. A plug-in that checks it can stop early, and the host discards what it returns after the Cancel or after its time budget ran out; one that @@ -239,6 +253,53 @@ still returns `1`. text, and the host reads at most 1 MiB of log text in a run; past any of these, one "log truncated" line is written and the rest are dropped. +### Settings and cache + +A plug-in with the `settings` permission keeps named values between runs and sessions, and one with `cache` keeps +named entries that the user can clear (#2191). It names each value by a key and never by a path. Each import takes +the key as `(key, key_len)`, the UTF-8 bytes at `key` in its memory: + +| Import | Signature | Meaning | +|---|---|---| +| `vc_settings_get` | `(key, key_len, out, out_cap) -> i32` | The setting's length in bytes. When it fits in the `out_cap` bytes at `out`, its bytes are copied there; otherwise nothing is copied. | +| `vc_settings_set` | `(key, key_len, value, value_len) -> i32` | Sets the setting to the `value_len` bytes at `value`. Returns `0`. | +| `vc_settings_delete` | `(key, key_len) -> i32` | Removes the setting. Returns `0`. | +| `vc_cache_read` | `(key, key_len, out, out_cap) -> i32` | As `vc_settings_get`, for the cache entry. | +| `vc_cache_write` | `(key, key_len, value, value_len) -> i32` | As `vc_settings_set`, for the cache entry. | +| `vc_cache_clear` | `(key, key_len) -> i32` | Removes the cache entry; with `key_len` 0, every entry. Returns `0`. | + +A call that fails returns `-1` when the key has no value, `-2` when a key, value or buffer doesn't lie inside the +module's memory, `-3` when the key is not a valid key, `-4` when the write would pass a cap (nothing changes), `-5` +when there is nowhere to keep anything, `-6` when reading or writing the file failed, and `-7` when the run has used +its 10,000 storage calls or its 1 GiB of storage reads and writes. Each call counts the bytes it reads and writes: a +settings call the whole settings file it reads, and a set or delete also the whole file it writes; a cache call the +entry it reads or writes. A read counts them even when the value doesn't fit and nothing is copied. A call that would +pass the 1 GiB returns `-7` before it reads or writes anything. + +- A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`, + `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, in any case). Keys differ in case: `tile` and `Tile` are two. +- These caps are VectorCraft's own values: at most 256 settings of at most 64 KiB each, in a settings file of at most + 1 MiB; cache entries of at most 64 MiB each, at most 4,096 entries and 512 MiB per plug-in. The cache has no + automatic eviction: a write past a cap fails with `-4`, and the plug-in or the user clears entries. A cache write + also fails with `-4` when the `cache` folder holds more than 8,192 items; clearing the cache removes every file in + it. +- The desktop app and `vectorcraft-cli` keep them in a folder named from the id under `storage/` in the `Plug-ins` + folder (ids that differ only in case get two folders): `settings.json` and a + `cache` folder with one file per entry, named by the host. A settings file that is too large or doesn't parse is + moved to `settings.unreadable.json`, replacing an earlier one there, and the settings start empty. A link in that + folder is refused (`-5`). Writes go to a temporary file that then replaces the old one; a temporary file a failed + write leaves in the `cache` folder does not count toward the caps, and clearing the cache removes it. +- A plug-in reaches only the storage of its own id. Replacing it with another module of the same id + (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a run of the plug-in still going gets `-5` from every storage call after that. + `plugin.remove` first renames the folder (adding `.removed` to its name). When that fails, `plugin.remove` fails + and the plug-in stays installed with its storage as it was. Files that can't be deleted after the rename stay in the + `.removed` folder, where no plug-in reaches them, and the next removal of a plug-in with that id deletes them. + `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept for that id, when there is + any. + `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and + `plugin.info` give the cache's size as `cacheBytes`. +- Without a `Plug-ins` folder (the web build, and embedders that attach none) the storage calls return `-5`. + ### Manifest ```json diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index feee6299a..998c156cb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -729,6 +729,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). + - Commands that read their `ids` (or `slices`) list themselves refuse a list with an entry that is not a non-negative integer, and a value that is not a list, and change nothing; they no longer drop the entry and act on the rest (Copy Appearance From, the Edit Colors commands and Overprint Black, Find and Replace, `layer.highlight`, `layer.move`, `library.add`, the Links panel commands, `object.transform`, Make Pattern, the Repeat commands, the slice commands, Join, Average and Join, `plugin.run`, `symbol.edit`, Make Opacity Mask; `tests::ids_without_selection::an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing`). The error for an entry of an object id list names the key, the entry and its value ("`ids` entry 1 is true, not a non-negative integer object id"), with a row in every catalog that has the other object id errors. - The errors of Enter Isolation Mode (`object.isolate`) for a type object, for hidden or locked art and for a call with no `id` while nothing is selected ("missing id", which other commands that take an `id` also give), and the error of the Layers panel's target circle (`layer.target`) for an object outside the isolated group, show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch. - An EPS or PostScript file that stops on a PostScript error gives the error's name as PostScript spells it (`typecheck`, `rangecheck` and the other names the reader raises, except `undefined`, whose message names the unknown name) followed by what it means, in the warning after opening and in the error when the file can't be opened. The status bar and the open-error dialog show the meaning in the interface language: each meaning has a row in every catalog (`postscript_error_meanings_are_translated`). - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. @@ -1323,6 +1324,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `document.open`, `file.place` and `document.pdfInfo` reject a `cropTo`, `crop`, `password`, `colorMode` or `textAs` that is not a string, such as `"textAs": true`, and `document.pdfInfo` rejects a `thumbnailSize` that is not a number; the error names the param, and nothing opens or is placed. A param left out or given as null keeps its default (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`). When the PDF content of a `.ai` has a page for each artboard, the layers are compared with every page on its artboard (until the pages compared take 8,388,608 pixels at the comparison's scale, about 67 letter-size pages, or number 128): art that only the layers or only a later page draws is handled as art that differs on the first page is, and a text object that a later page doesn't draw is left out unless its layer doesn't print (`crates/eps/src/import/tests_native.rs`). When the editing data of a `.ai` or EPS is for a page of another size than the file's, the warning that its layers weren't read gives both sizes in points, and the scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion (#2034; `crates/eps/src/import/tests_native.rs`). + A `.ai` or EPS opened from its editing data keeps its layers when it embeds an image of more than 2^25 pixels (part of #1479). The image comes in at full size up to a target size (2^26 to 2^28 pixels on the desktop, by installed memory; 2^27 on the web), a 1-bit gray image as a 1-bit PNG. A larger image is averaged down by the smallest whole step that fits, a 1-bit one into shades of gray, with a note giving both sizes. An image of more than 2^31 pixels or 512 MiB of samples is an object the reader can't read, so its box comes in as the page's art; without page art, it is left out with a note (`crates/eps/src/import/tests_native.rs`). A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). @@ -1535,6 +1537,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. +- **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.