mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
Merge pull request #728 from storytold/community/batch-10
Community batch 10: .fig import and file handling (12 PRs)
This commit is contained in:
@@ -50,7 +50,7 @@ pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) {
|
||||
/// Open `paths` as File › Open does; a file that fails shows its error and the others still open.
|
||||
pub fn open_documents(fig: &mut FigApp, paths: Vec<PathBuf>) {
|
||||
for p in paths {
|
||||
if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() {
|
||||
if fig.run_user("file.open", json!({"path": p.to_string_lossy()})).is_some() {
|
||||
fig.ui.home = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,12 +223,25 @@ fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> Dial
|
||||
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
|
||||
}
|
||||
|
||||
/// Native Save as filters; other callers keep their existing combined filter.
|
||||
fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
|
||||
if exts == ["figcraft", "fig"] {
|
||||
vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])]
|
||||
} else {
|
||||
vec![("File", exts)]
|
||||
}
|
||||
}
|
||||
|
||||
impl Services for Desktop {
|
||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
|
||||
picked(dialog(title).add_filter("Supported files", exts).pick_file())
|
||||
}
|
||||
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
|
||||
picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file())
|
||||
let mut dialog = dialog(title).set_file_name(name);
|
||||
for (label, extensions) in save_filters(exts) {
|
||||
dialog = dialog.add_filter(label, extensions);
|
||||
}
|
||||
picked(dialog.save_file())
|
||||
}
|
||||
fn pick_folder(&mut self, title: &str) -> DialogFuture {
|
||||
picked(dialog(title).pick_folder())
|
||||
@@ -241,6 +254,9 @@ impl Services for Desktop {
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
self.clipboard.as_mut()?.get_text().ok()
|
||||
}
|
||||
fn clipboard_get_html(&mut self) -> Option<String> {
|
||||
self.clipboard.as_mut()?.get().html().ok()
|
||||
}
|
||||
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
|
||||
if let Some(c) = self.clipboard.as_mut() {
|
||||
let _ = c.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(rgba) });
|
||||
@@ -297,3 +313,24 @@ pub fn window_icon() -> Option<egui::IconData> {
|
||||
let (w, h) = img.dimensions();
|
||||
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod save_filter_tests {
|
||||
use super::save_filters;
|
||||
|
||||
#[test]
|
||||
fn native_and_fig_save_filters_are_separate_with_native_first() {
|
||||
let extensions = ["figcraft", "fig"];
|
||||
assert_eq!(
|
||||
save_filters(&extensions),
|
||||
vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_save_filters_keep_one_combined_group() {
|
||||
for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] {
|
||||
assert_eq!(save_filters(extensions), vec![("File", extensions)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
-8
@@ -215,6 +215,10 @@ pub struct ParaList {
|
||||
pub list: ListStyle,
|
||||
/// Nesting level (0 = outermost; plain paragraphs are always 0).
|
||||
pub indent: u8,
|
||||
/// A numbered list that starts counting at this paragraph counts from `start_offset + 1`
|
||||
/// (items that go on counting ignore it; other paragraphs are always 0).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub start_offset: u32,
|
||||
}
|
||||
|
||||
/// Character style. In runs, every field is an override; `None` means "inherit the base".
|
||||
@@ -637,13 +641,13 @@ impl TextData {
|
||||
|
||||
/// Paragraph `p`'s list settings.
|
||||
pub fn para_list(&self, p: usize) -> ParaList {
|
||||
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 })
|
||||
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() })
|
||||
}
|
||||
|
||||
/// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries).
|
||||
fn fill_paragraphs(&mut self) {
|
||||
let n = self.paragraph_count();
|
||||
let fill = ParaList { list: self.list, indent: 0 };
|
||||
let fill = ParaList { list: self.list, ..ParaList::default() };
|
||||
self.paragraphs.resize(n, fill);
|
||||
}
|
||||
|
||||
@@ -657,17 +661,23 @@ impl TextData {
|
||||
}
|
||||
|
||||
/// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain
|
||||
/// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with
|
||||
/// no nesting folds back into `list`.
|
||||
/// paragraphs stay at level 0, only numbered ones keep a start, levels stop at
|
||||
/// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`.
|
||||
pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) {
|
||||
let (a, b) = self.para_span(start, end);
|
||||
self.fill_paragraphs();
|
||||
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
|
||||
f(pl);
|
||||
pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) };
|
||||
if pl.list == ListStyle::None {
|
||||
pl.indent = 0;
|
||||
}
|
||||
if pl.list != ListStyle::Numbered {
|
||||
pl.start_offset = 0;
|
||||
}
|
||||
pl.indent = pl.indent.min(MAX_LIST_INDENT);
|
||||
}
|
||||
if let Some(first) = self.paragraphs.first().copied()
|
||||
&& first.indent == 0
|
||||
&& first == (ParaList { list: first.list, ..ParaList::default() })
|
||||
&& self.paragraphs.iter().all(|p| *p == first)
|
||||
{
|
||||
self.list = first.list;
|
||||
@@ -675,11 +685,12 @@ impl TextData {
|
||||
}
|
||||
}
|
||||
|
||||
/// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ.
|
||||
/// The list style and level shared by the paragraphs chars `start..end` touch (the first
|
||||
/// one's settings), or None when they differ. Start numbers don't count.
|
||||
pub fn common_para_list(&self, start: usize, end: usize) -> Option<ParaList> {
|
||||
let (a, b) = self.para_span(start, end);
|
||||
let first = self.para_list(a);
|
||||
(a..=b).all(|p| self.para_list(p) == first).then_some(first)
|
||||
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
|
||||
}
|
||||
|
||||
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
|
||||
@@ -782,4 +793,29 @@ mod case_tests {
|
||||
assert_eq!(st.variations.keys().collect::<Vec<_>>(), ["wdth"]);
|
||||
assert!(valid_axis_tag("wght") && !valid_axis_tag("weight"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_starts_load_from_old_files_and_follow_edits() {
|
||||
use ListStyle::{Bulleted as B, None as P, Numbered as N};
|
||||
// Files written before start numbers load with none, and write the same bytes.
|
||||
let old = r#"{"list":"numbered","indent":1}"#;
|
||||
let pl: ParaList = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 });
|
||||
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
|
||||
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
|
||||
let mut t = TextData { characters: "Next".into(), ..Default::default() };
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 });
|
||||
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 });
|
||||
// Enter copies it to the new item; different starts don't make the list mixed.
|
||||
t.replace_range(4, 4, "\nMore");
|
||||
assert_eq!(t.para_list(1).start_offset, 1);
|
||||
t.set_para_lists(5, 5, |p| p.start_offset = 0);
|
||||
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
|
||||
// Bullets have no number, so no start.
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 });
|
||||
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 });
|
||||
// A plain paragraph has none.
|
||||
t.set_para_lists(0, 0, |p| p.list = P);
|
||||
assert_eq!(t.para_list(0), ParaList::default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,9 +468,9 @@ impl VectorNetwork {
|
||||
start_dir(&self.outward(seg, at_a)?)
|
||||
}
|
||||
|
||||
/// Whether any point has a stroke cap or join of its own.
|
||||
pub fn has_point_styles(&self) -> bool {
|
||||
self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some())
|
||||
/// Whether any point has a stroke cap of its own or a join other than the layer's `layer_join`.
|
||||
pub fn has_point_styles(&self, layer_join: StrokeJoin) -> bool {
|
||||
self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|j| j != layer_join))
|
||||
}
|
||||
|
||||
/// The fill path: every region's loops.
|
||||
|
||||
@@ -25,6 +25,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("edit.copy", "Copy", "{ids?}", false, copy),
|
||||
spec!("edit.cut", "Cut", "{ids?}", true, cut),
|
||||
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
|
||||
spec!(
|
||||
"edit.pasteDesignLayers",
|
||||
"Paste design layers",
|
||||
"{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes",
|
||||
true,
|
||||
paste_design_layers
|
||||
),
|
||||
spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over),
|
||||
spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace),
|
||||
spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props),
|
||||
@@ -498,7 +505,20 @@ fn bump_name(n: &str) -> String {
|
||||
/// The copied form of the selection.
|
||||
pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> {
|
||||
let ds = s.ds()?;
|
||||
let doc = &ds.doc;
|
||||
let (nodes, images, bounds) = copied(&ds.doc, ids);
|
||||
let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid);
|
||||
s.clipboard.source = Some(source);
|
||||
s.clipboard.source_path = source_path;
|
||||
s.clipboard.source_doc = uid;
|
||||
s.clipboard.nodes = nodes;
|
||||
s.clipboard.images = images;
|
||||
s.clipboard.bounds = bounds;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type Copied = (Vec<(NodeId, Vec<Node>)>, BTreeMap<String, ImageData>, Option<figcraft_geom::Rect>);
|
||||
|
||||
fn copied(doc: &Document, ids: &[NodeId]) -> Copied {
|
||||
let ids = z_sorted(doc, &roots(doc, ids));
|
||||
let mut nodes = Vec::new();
|
||||
let mut images = BTreeMap::new();
|
||||
@@ -521,16 +541,59 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> {
|
||||
nodes.push((*i, sub));
|
||||
}
|
||||
let bounds = doc.bounds_of(&ids);
|
||||
let uid = ds.uid;
|
||||
let source = Some(ds.doc.clone());
|
||||
let source_path = ds.path.clone();
|
||||
s.clipboard.source = source;
|
||||
s.clipboard.source_path = source_path;
|
||||
s.clipboard.nodes = nodes;
|
||||
s.clipboard.images = images;
|
||||
s.clipboard.bounds = bounds;
|
||||
s.clipboard.source_doc = uid;
|
||||
Ok(())
|
||||
(nodes, images, bounds)
|
||||
}
|
||||
|
||||
/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them.
|
||||
pub const CLIP_OPEN: &str = "(figma)";
|
||||
pub const CLIP_CLOSE: &str = "(/figma)";
|
||||
/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its
|
||||
/// base64 is checked first, so neither the text nor the decoded bytes can pass it.
|
||||
const MAX_CLIP_DATA: usize = 256 * 1024 * 1024;
|
||||
const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4;
|
||||
|
||||
/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`].
|
||||
fn clip_base64(text: &str) -> Option<Vec<u8>> {
|
||||
use base64::Engine as _;
|
||||
if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 {
|
||||
return None;
|
||||
}
|
||||
let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect();
|
||||
base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok()
|
||||
}
|
||||
|
||||
/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in
|
||||
/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML
|
||||
/// holds no readable design data (it may just mention the marker).
|
||||
pub fn design_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
|
||||
let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len();
|
||||
let len = html.get(start..)?.find(CLIP_CLOSE)?;
|
||||
let bytes = clip_base64(html.get(start..start + len)?)?;
|
||||
figcraft_format::fig::is_fig(&bytes).then_some(bytes)
|
||||
}
|
||||
|
||||
fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "edit.pasteDesignLayers";
|
||||
let bytes = match (string(p, "html"), string(p, "data")) {
|
||||
(Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?,
|
||||
(None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?,
|
||||
(None, None) => return Err(bad(CMD, "pass `html` or `data`")),
|
||||
};
|
||||
let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??;
|
||||
let doc = std::sync::Arc::new(other.doc);
|
||||
let tops: Vec<NodeId> = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect();
|
||||
if tops.is_empty() {
|
||||
return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into()));
|
||||
}
|
||||
let (nodes, images, bounds) = copied(&doc, &tops);
|
||||
let props = s.clipboard.props.take();
|
||||
// No open file has id 0. So paste treats this copy as a copy from a different file.
|
||||
s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None };
|
||||
let mut out = paste(s, p)?;
|
||||
if !other.issues.is_empty() {
|
||||
out["warnings"] = json!(other.issues);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.open",
|
||||
"Open",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
|
||||
false,
|
||||
open
|
||||
),
|
||||
@@ -41,8 +41,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
false,
|
||||
open_recent
|
||||
),
|
||||
spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save),
|
||||
spec!("file.saveAs", "Save as", "{path}", false, save_as),
|
||||
spec!(
|
||||
"file.save",
|
||||
"Save",
|
||||
"{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
|
||||
false,
|
||||
save
|
||||
),
|
||||
spec!(
|
||||
"file.saveAs",
|
||||
"Save as",
|
||||
"{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
|
||||
false,
|
||||
save_as
|
||||
),
|
||||
spec!(
|
||||
"file.export",
|
||||
"Export",
|
||||
@@ -74,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.import",
|
||||
"Import",
|
||||
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`)",
|
||||
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
|
||||
true,
|
||||
import
|
||||
),
|
||||
@@ -461,6 +473,11 @@ fn file_stem(path: &str) -> String {
|
||||
std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string()
|
||||
}
|
||||
|
||||
/// The counts the import report shows for a freshly loaded document.
|
||||
fn import_summary(doc: &Document) -> Value {
|
||||
json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()})
|
||||
}
|
||||
|
||||
/// Load a document from any supported file (REST API file JSON images come from an `images`
|
||||
/// folder next to the file).
|
||||
pub fn load(path: &str) -> Result<(Document, Vec<String>)> {
|
||||
@@ -506,10 +523,8 @@ fn load_with(
|
||||
Ok((doc.into(), false))
|
||||
}
|
||||
_ => {
|
||||
if let Some(r) = figcraft_format::fig::try_import(bytes, &file_stem(name_hint)) {
|
||||
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
link_instances(&mut doc);
|
||||
return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
|
||||
if let Some(r) = load_fig(bytes, &file_stem(name_hint)) {
|
||||
return Ok((r?, true));
|
||||
}
|
||||
if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) {
|
||||
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
@@ -525,6 +540,15 @@ fn load_with(
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try.
|
||||
pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
|
||||
let r = figcraft_format::fig::try_import(bytes, name)?;
|
||||
Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| {
|
||||
link_instances(&mut doc);
|
||||
figcraft_format::Loaded { issues: warnings, ..doc.into() }
|
||||
}))
|
||||
}
|
||||
|
||||
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
|
||||
/// innermost first so outer instances settle last.
|
||||
fn link_instances(doc: &mut Document) {
|
||||
@@ -678,9 +702,10 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]),
|
||||
});
|
||||
}
|
||||
let summary = import_summary(&loaded.doc);
|
||||
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
s.active = s.docs.len() - 1;
|
||||
let mut out = json!({"index": s.active});
|
||||
let mut out = json!({"index": s.active, "summary": summary});
|
||||
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
|
||||
Ok(out)
|
||||
}
|
||||
@@ -693,9 +718,10 @@ fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>)
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?;
|
||||
let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?;
|
||||
let warnings = rest || ext(&name) == "svg";
|
||||
let summary = import_summary(&loaded.doc);
|
||||
s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
s.active = s.docs.len() - 1;
|
||||
let mut out = json!({"index": s.active});
|
||||
let mut out = json!({"index": s.active, "summary": summary});
|
||||
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
|
||||
Ok(out)
|
||||
}
|
||||
@@ -796,8 +822,20 @@ fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"profile": profile.id(), "changed": changed}))
|
||||
}
|
||||
|
||||
/// Save the file of the active tab (the main file for a branch tab, with its branches).
|
||||
/// Native saves act on the current document; a `.fig` copy exports the version shown, and a
|
||||
/// pending Save As choice must leave it shown until its destination is known.
|
||||
pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool {
|
||||
let path =
|
||||
string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None });
|
||||
path.is_some_and(|path| ext(&path) != "fig")
|
||||
}
|
||||
|
||||
/// Save the file of the active tab (the main file for a branch tab, with its branches), or export
|
||||
/// the active document as an experimental `.fig` copy without changing its native save state.
|
||||
fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
||||
if ext(&path) == "fig" {
|
||||
return export_fig(s, &json!({"path": path}));
|
||||
}
|
||||
let i = crate::branches::file_tab(s);
|
||||
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
|
||||
let bytes = file_bytes(ds)?;
|
||||
@@ -816,6 +854,11 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return save_to(s, path);
|
||||
}
|
||||
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
|
||||
// Earlier builds wrote native data under a .fig name: never overwrite that file with an export.
|
||||
Some(path) if ext(&path) == "fig" => {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))
|
||||
}
|
||||
Some(path) => save_to(s, path),
|
||||
None => {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
@@ -1236,8 +1279,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None };
|
||||
let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?;
|
||||
let summary = import_summary(&other.doc);
|
||||
let pages = append_pages(s.doc_mut()?, &other.doc)?;
|
||||
let mut out = json!({"pages": pages});
|
||||
let mut out = json!({"pages": pages, "summary": summary});
|
||||
if rest {
|
||||
out["warnings"] = json!(other.issues);
|
||||
}
|
||||
|
||||
@@ -610,6 +610,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for i in req_targets("vector.offset", s, p)? {
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get(i)?.clone();
|
||||
// A vector can't hold children: leave containers (frames, groups, instances) alone.
|
||||
if !n.children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Paste of layers copied from another design app: the clipboard's design data (a `.fig` canvas in
|
||||
//! its HTML) read through the `.fig` import and pasted as Paste pastes our own layers.
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer};
|
||||
|
||||
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
params::id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn copied_file() -> Vec<u8> {
|
||||
let mut s = Session::new();
|
||||
let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap());
|
||||
s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 40, "height": 20, "parent": frame, "name": "Badge"})).unwrap();
|
||||
s.execute("node.create", &json!({"type": "ellipse", "x": 300, "y": 0, "width": 50, "height": 50, "name": "Dot"})).unwrap();
|
||||
let r = s.execute("file.export", &json!({"format": "fig"})).unwrap();
|
||||
B64.decode(r["data"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn clipboard_html(buffer: &[u8]) -> String {
|
||||
let mut wrapped = B64.encode(buffer);
|
||||
wrapped.insert(8, '\n');
|
||||
format!(r#"<meta charset="utf-8"><span data-buffer="<!--{CLIP_OPEN}{wrapped}{CLIP_CLOSE}-->"></span>"#)
|
||||
}
|
||||
|
||||
fn names(s: &Session, ids: &[NodeId]) -> Vec<String> {
|
||||
let doc = s.doc().unwrap();
|
||||
ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_is_found_in_the_html() {
|
||||
let file = copied_file();
|
||||
assert_eq!(design_clipboard_buffer(&clipboard_html(&file)), Some(file));
|
||||
assert_eq!(design_clipboard_buffer("<p>plain</p>"), None);
|
||||
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}not base64!{CLIP_CLOSE}")), None);
|
||||
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{}{CLIP_CLOSE}", B64.encode(b"hello"))), None);
|
||||
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}ZmlnLQ")), None);
|
||||
// Web text that only mentions the markers holds no design data.
|
||||
assert_eq!(design_clipboard_buffer(&format!("<p>see {CLIP_OPEN} and {CLIP_CLOSE} in the docs</p>")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_design_data_is_refused_before_decoding() {
|
||||
// Just past the cap (256 MB of data, as base64), made of valid base64 characters.
|
||||
let big = "A".repeat((256 * 1024 * 1024usize).div_ceil(3) * 4 + 4);
|
||||
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{big}{CLIP_CLOSE}")), None);
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("edit.pasteDesignLayers", &json!({ "data": big })).unwrap_err();
|
||||
assert!(e.to_string().contains("256 MB"), "{e}");
|
||||
assert!(s.clipboard.nodes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_paste_into_the_page_and_undo() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap();
|
||||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect();
|
||||
assert_eq!(names(&s, &ids), ["Card", "Dot"]);
|
||||
assert_eq!(s.selection(), ids);
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(ids.iter().all(|i| doc.node(*i).unwrap().parent == Some(page)));
|
||||
let card = doc.node(ids[0]).unwrap();
|
||||
assert_eq!(names(&s, &card.children), ["Badge"]);
|
||||
assert_eq!((card.width, card.height), (200.0, 100.0));
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_paste_repeats_the_copied_layers() {
|
||||
let mut s = Session::new();
|
||||
// `data` may be wrapped and unpadded, like the HTML's base64.
|
||||
let mut data = B64.encode(copied_file()).trim_end_matches('=').to_string();
|
||||
data.insert(8, '\n');
|
||||
s.execute("edit.pasteDesignLayers", &json!({ "data": data })).unwrap();
|
||||
let r = s.execute("edit.paste", &json!({})).unwrap();
|
||||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect();
|
||||
assert_eq!(names(&s, &ids), ["Card", "Dot"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pastes_into_the_selected_frame() {
|
||||
let mut s = Session::new();
|
||||
let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [host]})).unwrap();
|
||||
let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap();
|
||||
let first = params::id_of(&r["ids"][0]).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_content_is_refused_without_changes() {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().nodes().count();
|
||||
assert!(s.execute("edit.pasteDesignLayers", &json!({})).is_err());
|
||||
assert!(s.execute("edit.pasteDesignLayers", &json!({"html": "<b>hi</b>"})).is_err());
|
||||
assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"PK not a zip")})).is_err());
|
||||
assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err());
|
||||
assert_eq!(s.doc().unwrap().nodes().count(), before);
|
||||
assert!(s.clipboard.nodes.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! An absurd stroke weight must not take the renderer down with it: saving (thumbnail) and exporting still work.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_stroke_weight_with_round_caps_still_saves_and_exports() {
|
||||
for weight in [1e6, 1e7, 1e10, 1e30] {
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
let v = id(s.execute("vector.create", &json!({"parent": f, "path": "M0 0.8333 L7.5 6.25 L15 0.8333"})).unwrap());
|
||||
s.execute("node.set", &json!({"id": v, "props": {"stroke": "#ff0000", "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round"}}))
|
||||
.unwrap();
|
||||
let path = std::env::temp_dir().join(format!("figcraft-huge-stroke-{}-{weight}.figcraft", std::process::id()));
|
||||
let saved = s.execute("file.saveAs", &json!({"path": path.to_string_lossy()}));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(saved.is_ok(), "saveAs at weight {weight}: {:?}", saved.err());
|
||||
let exported = s.execute("file.export", &json!({"ids": [f], "format": "png"}));
|
||||
assert!(exported.is_ok(), "export at weight {weight}: {:?}", exported.err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Imported file bytes already live in the document: the command log keeps no second copy.
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#3366cc"/></svg>"##;
|
||||
|
||||
fn payload() -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(SVG)
|
||||
}
|
||||
|
||||
fn holds_payload(journal: &[(String, Value)]) -> bool {
|
||||
let payload = payload();
|
||||
journal.iter().any(|(_, params)| params.to_string().contains(&payload))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_imports_keep_no_payload_in_the_journal() {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().node_count();
|
||||
s.execute("file.import", &json!({"data": payload(), "name": "pasted.svg"})).unwrap();
|
||||
assert!(!holds_payload(&s.journal), "the journal must not retain the Base64 file");
|
||||
// Not journaling is no change to the edit itself: still one undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node_count(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_wrappers_do_not_retain_import_payloads() {
|
||||
let mut s = Session::new();
|
||||
let nested =
|
||||
json!({"command": "command.batch", "params": {"commands": [{"command": "file.import", "params": {"data": payload(), "name": "a.svg"}}]}});
|
||||
s.execute("command.batch", &json!({"commands": [nested]})).unwrap();
|
||||
assert!(!holds_payload(&s.journal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_imports_and_ordinary_edits_are_still_journaled() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-import-journal-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("kit.svg");
|
||||
std::fs::write(&path, SVG).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let ids: Vec<_> = s.journal.iter().map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(ids, ["file.import", "shape.rectangle"]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -191,7 +191,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
|
||||
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
|
||||
}
|
||||
// Lists: one style for the layer, or each paragraph's type and nesting level.
|
||||
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
|
||||
if t.paragraphs.is_empty() {
|
||||
if t.list != figcraft_doc::ListStyle::None {
|
||||
o.insert("listStyle".into(), json!(t.list));
|
||||
@@ -199,6 +199,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
} else {
|
||||
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
|
||||
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
|
||||
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
o.insert("listSpacing".into(), json!(t.list_spacing));
|
||||
|
||||
@@ -390,7 +390,8 @@ pub struct VersionPreview {
|
||||
/// Why edits are refused while a version is shown.
|
||||
pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore it or go back to the current version to edit";
|
||||
|
||||
/// Commands that first end a version preview: they act on the document itself.
|
||||
/// Commands that first end a version preview: they act on the document itself. Saving does so
|
||||
/// only with a native destination; export copies and pending format choices keep the preview.
|
||||
const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"];
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -723,7 +724,8 @@ pub struct Session {
|
||||
/// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no
|
||||
/// theme, so what scripts make is the same everywhere.
|
||||
pub ui_dark: Option<bool>,
|
||||
/// Command log (id + params) for actions/automation.
|
||||
/// Command log (id + params) for actions/automation. File imports that carry the file bytes
|
||||
/// (`file.import` with `data`) are not logged, so the log never holds a copy of a pasted file.
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// Recently used colours.
|
||||
pub recent_colors: Vec<figcraft_color::Color>,
|
||||
@@ -748,6 +750,19 @@ impl Default for Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`.
|
||||
fn command_carries_import_bytes(id: &str, params: &Value) -> bool {
|
||||
match id {
|
||||
"file.import" => params::get(params, "data").is_some(),
|
||||
"command.batch" => params.get("commands").and_then(Value::as_array).is_some_and(|commands| {
|
||||
commands.iter().any(|c| {
|
||||
c.get("command").and_then(Value::as_str).zip(c.get("params")).is_some_and(|(id, params)| command_carries_import_bytes(id, params))
|
||||
})
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// A session with one new document.
|
||||
pub fn new() -> Self {
|
||||
@@ -872,6 +887,7 @@ impl Session {
|
||||
// Every other command acts on the document itself, not on a live text preview.
|
||||
textpreview::end(self);
|
||||
if ENDS_PREVIEW.contains(&id)
|
||||
&& (!matches!(id, "file.save" | "file.saveAs") || cmd::file::save_ends_preview(self, id, params))
|
||||
&& let Ok(ds) = self.ds_mut()
|
||||
{
|
||||
ds.end_preview();
|
||||
@@ -887,9 +903,13 @@ impl Session {
|
||||
if spec.history && id != "edit.repeat" {
|
||||
self.last_command = Some((id.to_string(), params.clone()));
|
||||
}
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
if self.journal.len() > 5000 {
|
||||
self.journal.drain(..1000);
|
||||
// Imported bytes already live in the document: a Base64 copy in the log would stay
|
||||
// for the whole session.
|
||||
if !command_carries_import_bytes(id, params) {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
if self.journal.len() > 5000 {
|
||||
self.journal.drain(..1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -1502,6 +1522,8 @@ mod cursor_tests;
|
||||
#[cfg(test)]
|
||||
mod dark_page_text_tests;
|
||||
#[cfg(test)]
|
||||
mod design_paste_tests;
|
||||
#[cfg(test)]
|
||||
mod dev_resources_tests;
|
||||
#[cfg(test)]
|
||||
mod devcompare_tests;
|
||||
@@ -1550,8 +1572,12 @@ mod history_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod html_codegen_tests;
|
||||
#[cfg(test)]
|
||||
mod huge_stroke_tests;
|
||||
#[cfg(test)]
|
||||
mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod import_journal_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod interaction_rollback_tests;
|
||||
@@ -1644,6 +1670,8 @@ mod rotated_select_tests;
|
||||
#[cfg(test)]
|
||||
mod rotation_origin_tests;
|
||||
#[cfg(test)]
|
||||
mod save_format_tests;
|
||||
#[cfg(test)]
|
||||
mod scale_tool_tests;
|
||||
#[cfg(test)]
|
||||
mod section_contents_tests;
|
||||
@@ -1712,6 +1740,8 @@ mod vector_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_multi_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_offset_tests;
|
||||
#[cfg(test)]
|
||||
mod version_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod version_tests;
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Save As can write an experimental `.fig` copy without replacing native save state.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_format::Version;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{DocState, EngineError, Session};
|
||||
|
||||
struct SaveFiles {
|
||||
dir: PathBuf,
|
||||
files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl SaveFiles {
|
||||
fn new() -> Self {
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self { dir, files: Vec::new() }
|
||||
}
|
||||
|
||||
fn path(&mut self, name: &str) -> PathBuf {
|
||||
let path = self.dir.join(name);
|
||||
self.files.push(path.clone());
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SaveFiles {
|
||||
fn drop(&mut self) {
|
||||
for path in &self.files {
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_dir(path);
|
||||
}
|
||||
let _ = std::fs::remove_dir(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, name: &str) {
|
||||
s.execute("shape.rectangle", &json!({"name": name, "x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
|
||||
}
|
||||
|
||||
fn assert_versions_unchanged(after: &[Version], before: &[Version]) {
|
||||
assert_eq!(after.len(), before.len());
|
||||
for (after, before) in after.iter().zip(before) {
|
||||
assert_eq!(
|
||||
(&after.id, &after.name, &after.description, after.time, after.auto),
|
||||
(&before.id, &before.name, &before.description, before.time, before.auto)
|
||||
);
|
||||
assert_eq!(*after.document().unwrap(), *before.document().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String]) {
|
||||
let after = s.ds().unwrap();
|
||||
assert_eq!(after.path, before.path);
|
||||
assert_eq!(after.is_dirty(), before.is_dirty());
|
||||
assert_eq!(after.versions_changed, before.versions_changed);
|
||||
assert!(Arc::ptr_eq(&after.doc, &before.doc));
|
||||
assert!(Arc::ptr_eq(&after.saved_doc, &before.saved_doc));
|
||||
assert_eq!(
|
||||
after.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)),
|
||||
before.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page))
|
||||
);
|
||||
if let (Some(after), Some(before)) = (&after.preview, &before.preview) {
|
||||
assert!(Arc::ptr_eq(&after.doc, &before.doc));
|
||||
}
|
||||
assert_eq!((&after.selection, after.page), (&before.selection, before.page));
|
||||
assert_versions_unchanged(&after.versions, &before.versions);
|
||||
assert_eq!(after.branches.len(), before.branches.len());
|
||||
for (after, before) in after.branches.iter().zip(&before.branches) {
|
||||
assert_eq!(
|
||||
(&after.id, &after.name, after.time, after.client, after.state),
|
||||
(&before.id, &before.name, before.time, before.client, before.state)
|
||||
);
|
||||
assert_eq!(*after.base, *before.base);
|
||||
assert_eq!(*after.head, *before.head);
|
||||
assert_versions_unchanged(&after.versions, &before.versions);
|
||||
}
|
||||
for (after, before) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] {
|
||||
assert_eq!(after.len(), before.len());
|
||||
for (after, before) in after.iter().zip(before) {
|
||||
assert_eq!((&after.label, &after.selection, after.page), (&before.label, &before.selection, before.page));
|
||||
assert!(Arc::ptr_eq(&after.doc, &before.doc));
|
||||
}
|
||||
}
|
||||
assert_eq!(s.prefs.recent_files, recent);
|
||||
}
|
||||
|
||||
fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
assert_eq!(result["format"], "fig");
|
||||
assert_eq!(result["bytes"], bytes.len());
|
||||
assert!(result["warnings"].is_array());
|
||||
assert!(figcraft_format::load_bytes(&bytes).is_err(), "a .fig copy must not contain native FigCraft data");
|
||||
figcraft_format::fig::import(&bytes, "Copy").unwrap().0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_save_as_keeps_native_path_dirty_state_and_history() {
|
||||
let mut files = SaveFiles::new();
|
||||
let native = files.path("design.figcraft");
|
||||
let copy = files.path("copy.fig");
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "Saved rectangle");
|
||||
s.execute("file.saveAs", &json!({"path": native.to_string_lossy()})).unwrap();
|
||||
let saved = std::fs::read(&native).unwrap();
|
||||
rect(&mut s, "New rectangle");
|
||||
s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Copy warning"})).unwrap();
|
||||
s.execute("file.saveVersion", &json!({"name": "Before copy"})).unwrap();
|
||||
rect(&mut s, "Undone rectangle");
|
||||
s.undo().unwrap();
|
||||
let before = s.ds().unwrap().clone();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
assert!(before.is_dirty());
|
||||
assert!(!before.history.redo.is_empty());
|
||||
|
||||
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
let imported = read_fig(©, &result);
|
||||
assert!(imported.nodes().any(|n| n.name == "New rectangle"));
|
||||
assert!(!imported.nodes().any(|n| n.name == "Undone rectangle"));
|
||||
assert!(result["warnings"].as_array().unwrap().iter().any(|w| w == "comments were left out"));
|
||||
assert_save_state_unchanged(&s, &before, &recent);
|
||||
assert_eq!(std::fs::read(&native).unwrap(), saved);
|
||||
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
|
||||
let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert!(persisted.doc.nodes().any(|n| n.name == "New rectangle"));
|
||||
assert_eq!(persisted.versions[0].name, "Before copy");
|
||||
assert_eq!(std::fs::read(©).unwrap().len(), result["bytes"].as_u64().unwrap() as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsaved_fig_copy_remains_unsaved() {
|
||||
let mut files = SaveFiles::new();
|
||||
let copy = files.path("unsaved.fig");
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "Unsaved rectangle");
|
||||
let before = s.ds().unwrap().clone();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
assert!(read_fig(©, &result).nodes().any(|n| n.name == "Unsaved rectangle"));
|
||||
assert_save_state_unchanged(&s, &before, &recent);
|
||||
assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..))));
|
||||
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_document_fig_copy_keeps_clean_state() {
|
||||
let mut files = SaveFiles::new();
|
||||
let copy = files.path("empty.fig");
|
||||
let mut s = Session::new();
|
||||
let before = s.ds().unwrap().clone();
|
||||
assert!(!before.is_dirty());
|
||||
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
let imported = read_fig(©, &result);
|
||||
assert_eq!(imported.pages.len(), 1);
|
||||
assert_eq!(imported.node_count(), 1);
|
||||
assert_save_state_unchanged(&s, &before, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_save_commands_accept_uppercase_extension() {
|
||||
let mut files = SaveFiles::new();
|
||||
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
|
||||
let copy = files.path(&format!("copy-{index}.FIG"));
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, command);
|
||||
let before = s.ds().unwrap().clone();
|
||||
let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
assert!(read_fig(©, &result).nodes().any(|n| n.name == command));
|
||||
assert_save_state_unchanged(&s, &before, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_copy_from_branch_exports_active_document_without_saving_main() {
|
||||
let mut files = SaveFiles::new();
|
||||
let native = files.path("main.figcraft");
|
||||
let copy = files.path("branch.fig");
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "Main rectangle");
|
||||
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
|
||||
let saved = std::fs::read(&native).unwrap();
|
||||
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
|
||||
rect(&mut s, "Branch rectangle");
|
||||
s.execute("file.saveVersion", &json!({"name": "Branch version"})).unwrap();
|
||||
// Branch edits normally sync to main before commands; capture that state before exporting.
|
||||
s.execute("branch.list", &json!({})).unwrap();
|
||||
let branch = s.ds().unwrap().clone();
|
||||
let main = s.docs[0].clone();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
|
||||
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
assert!(read_fig(©, &result).nodes().any(|n| n.name == "Branch rectangle"));
|
||||
assert_save_state_unchanged(&s, &branch, &recent);
|
||||
assert_eq!(s.docs[0].path, main.path);
|
||||
assert_eq!(s.docs[0].is_dirty(), main.is_dirty());
|
||||
assert!(Arc::ptr_eq(&s.docs[0].saved_doc, &main.saved_doc));
|
||||
assert!(Arc::ptr_eq(&s.docs[0].doc, &main.doc));
|
||||
assert_eq!(std::fs::read(&native).unwrap(), saved);
|
||||
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle"));
|
||||
assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle"));
|
||||
assert_eq!(persisted.branches[0].versions[0].name, "Branch version");
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert!(!s.docs[0].is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_copy_write_failure_keeps_save_state() {
|
||||
let mut files = SaveFiles::new();
|
||||
let blocked = files.path("blocked.fig");
|
||||
std::fs::create_dir(&blocked).unwrap();
|
||||
let missing = files.path("missing/copy.fig");
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "Kept rectangle");
|
||||
s.execute("file.saveVersion", &json!({"name": "Kept version"})).unwrap();
|
||||
rect(&mut s, "Later rectangle");
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
let before = s.ds().unwrap().clone();
|
||||
for command in ["file.save", "file.saveAs"] {
|
||||
for path in [&blocked, &missing] {
|
||||
assert!(s.execute(command, &json!({"path": path.to_string_lossy()})).is_err());
|
||||
assert_save_state_unchanged(&s, &before, &[]);
|
||||
assert!(!PathBuf::from(format!("{}.tmp-figcraft", path.display())).exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_without_extension_stays_native() {
|
||||
let mut files = SaveFiles::new();
|
||||
let requested = files.dir.join("native");
|
||||
let native = files.path("native.figcraft");
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "Native rectangle");
|
||||
let result = s.execute("file.saveAs", &json!({"path": requested.to_string_lossy()})).unwrap();
|
||||
assert_eq!(result["path"], native.to_string_lossy().as_ref());
|
||||
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert!(!requested.exists());
|
||||
let loaded = figcraft_format::load_bytes(&std::fs::read(native).unwrap()).unwrap();
|
||||
assert!(loaded.0.nodes().any(|n| n.name == "Native rectangle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_copy_of_version_preview_keeps_visible_version_and_save_state() {
|
||||
let mut files = SaveFiles::new();
|
||||
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
|
||||
let native = files.path(&format!("current-{index}.figcraft"));
|
||||
let copy = files.path(&format!("version-{index}.FIG"));
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "A");
|
||||
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
|
||||
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
|
||||
let saved = std::fs::read(&native).unwrap();
|
||||
rect(&mut s, "B");
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
let before = s.ds().unwrap().clone();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
assert!(before.preview.is_some());
|
||||
assert!(before.is_dirty());
|
||||
|
||||
let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
let imported = read_fig(©, &result);
|
||||
assert!(imported.nodes().any(|n| n.name == "A"));
|
||||
assert!(!imported.nodes().any(|n| n.name == "B"), "the copy must export the version shown");
|
||||
assert_save_state_unchanged(&s, &before, &recent);
|
||||
assert_eq!(std::fs::read(&native).unwrap(), saved);
|
||||
s.execute("version.exitPreview", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().nodes().any(|n| n.name == "B"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_save_choice_keeps_version_preview() {
|
||||
let mut files = SaveFiles::new();
|
||||
for (index, (command, native_path)) in [("file.saveAs", false), ("file.saveAs", true), ("file.save", false)].into_iter().enumerate() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "A");
|
||||
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
|
||||
if native_path {
|
||||
let native = files.path(&format!("saved-{index}.figcraft"));
|
||||
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
|
||||
}
|
||||
rect(&mut s, "B");
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
let before = s.ds().unwrap().clone();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
assert!(matches!(s.execute(command, &json!({})), Err(EngineError::Disabled(..))));
|
||||
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
|
||||
assert_save_state_unchanged(&s, &before, &recent);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_save_from_version_preview_writes_current_document() {
|
||||
let mut files = SaveFiles::new();
|
||||
for (index, (command, explicit)) in [("file.save", false), ("file.save", true), ("file.saveAs", true)].into_iter().enumerate() {
|
||||
let native = files.path(&format!("native-version-{index}.figcraft"));
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "A");
|
||||
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
|
||||
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
|
||||
rect(&mut s, "B");
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
let params = if explicit { json!({"path": native.to_string_lossy()}) } else { json!({}) };
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
assert!(s.ds().unwrap().preview.is_none());
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
|
||||
let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert!(loaded.doc.nodes().any(|n| n.name == "A"));
|
||||
assert!(loaded.doc.nodes().any(|n| n.name == "B"));
|
||||
assert!(!loaded.versions[0].document().unwrap().nodes().any(|n| n.name == "B"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_save_protects_legacy_fig_destination() {
|
||||
let mut files = SaveFiles::new();
|
||||
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
|
||||
let legacy = files.path(&format!("legacy-{index}.FIG"));
|
||||
let native = files.path(&format!("corrected-{index}.figcraft"));
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "A");
|
||||
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
|
||||
// Earlier builds could write native data under .fig; keep those bytes safe on plain Save.
|
||||
let sentinel = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap();
|
||||
std::fs::write(&legacy, &sentinel).unwrap();
|
||||
s.ds_mut().unwrap().path = Some(legacy.to_string_lossy().into_owned());
|
||||
s.ds_mut().unwrap().mark_saved();
|
||||
rect(&mut s, "B");
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
let before = s.ds().unwrap().clone();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
|
||||
assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..))));
|
||||
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
|
||||
assert_save_state_unchanged(&s, &before, &recent);
|
||||
assert_eq!(std::fs::read(&legacy).unwrap(), sentinel);
|
||||
|
||||
s.execute(command, &json!({"path": native.to_string_lossy()})).unwrap();
|
||||
assert!(s.ds().unwrap().preview.is_none());
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
|
||||
let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert!(loaded.doc.nodes().any(|n| n.name == "A"));
|
||||
assert!(loaded.doc.nodes().any(|n| n.name == "B"));
|
||||
assert_eq!(loaded.versions[0].name, "A only");
|
||||
rect(&mut s, "C");
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
let loaded = figcraft_format::load_bytes(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert!(loaded.0.nodes().any(|n| n.name == "C"));
|
||||
assert_eq!(std::fs::read(&legacy).unwrap(), sentinel);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,24 @@ fn list_buttons_act_on_the_selected_paragraphs() {
|
||||
assert_eq!(data(&s, t).list, P, "toggled off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_with_a_start_number_toggles_off() {
|
||||
// An imported list that starts at 2 has the start on its first item only; it is still one
|
||||
// numbered list, so the button turns it back to plain (#686).
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "two\nthree\nfour");
|
||||
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
|
||||
let n = |start_offset| ParaList { list: N, indent: 0, start_offset };
|
||||
td.paragraphs = vec![n(1), n(0), n(0)];
|
||||
}
|
||||
let j = crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
assert_eq!(j["listStartOffsets"], json!([1, 0, 0]), "inspect reports the start");
|
||||
s.execute("text.numberList", &json!({"ids": [t]})).unwrap();
|
||||
assert_eq!(lists(&s, t), [(P, 0); 3]);
|
||||
assert_eq!(data(&s, t).para_list(0).start_offset, 0, "a plain paragraph has no start");
|
||||
assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("listStartOffsets").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_list_starts() {
|
||||
// Typing "- " or "* " at the start of a paragraph starts a bulleted list, "1. " a numbered one
|
||||
@@ -117,7 +135,7 @@ fn list_keys_at_list_items() {
|
||||
for (chars, before, caret, k, shift, after, want, why) in cases {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, chars);
|
||||
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent }).collect();
|
||||
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect();
|
||||
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
|
||||
td.paragraphs = ps;
|
||||
}
|
||||
@@ -141,7 +159,7 @@ fn paragraph_lists_follow_edits() {
|
||||
// A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy
|
||||
// the one they split from; joined paragraphs keep the first one's.
|
||||
let mut t = TextData::new("ab\ncd\nef");
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 1 }, ParaList::default(), ParaList { list: N, indent: 0 }];
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }];
|
||||
let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::<Vec<_>>();
|
||||
t.replace_range(1, 1, "x\ny");
|
||||
assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it");
|
||||
@@ -156,7 +174,7 @@ fn paragraph_lists_follow_edits() {
|
||||
(seed >> 33) as usize % n.max(1)
|
||||
};
|
||||
let mut t = TextData::new("a\nb\nc\nd");
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 2 }; 4];
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4];
|
||||
for _ in 0..500 {
|
||||
let n = t.char_count();
|
||||
let (a, b) = (rnd(n + 2), rnd(n + 2));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! `vector.offset` on layers with children: containers are left alone.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// No node that holds children may be a non-container.
|
||||
fn assert_children_only_in_containers(s: &Session, ids: &[NodeId]) {
|
||||
let doc = s.doc().unwrap();
|
||||
for &i in ids {
|
||||
let n = doc.node(i).unwrap();
|
||||
assert!(n.kind.is_container() || n.kind.is_frame_like() || n.children.is_empty(), "a non-container layer now holds children");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_path_leaves_containers_with_children_alone() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
let inner = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap());
|
||||
let a = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let b = id(&s.execute("shape.rectangle", &json!({"x": 220, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap());
|
||||
for target in [f, g] {
|
||||
s.execute("vector.offset", &json!({"ids": [target], "offset": 5})).unwrap();
|
||||
assert_children_only_in_containers(&s, &[f, g, inner, a, b]);
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(f).unwrap().kind.is_container());
|
||||
assert_eq!(doc.node(f).unwrap().children, vec![inner]);
|
||||
assert!(doc.node(g).unwrap().kind.is_container());
|
||||
assert_eq!(doc.node(g).unwrap().children.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_path_still_converts_a_plain_rectangle() {
|
||||
let mut s = Session::new();
|
||||
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("vector.offset", &json!({"ids": [r], "offset": 5})).unwrap();
|
||||
let n = s.doc().unwrap().node(r).unwrap().clone();
|
||||
assert!(matches!(n.kind, NodeKind::Vector { .. }));
|
||||
assert!(n.width > 10.0);
|
||||
}
|
||||
@@ -102,7 +102,8 @@ fn synthetic_connector(version: f32, include_text: bool, text_x: f32) -> Kv<'sta
|
||||
}
|
||||
|
||||
fn map_synthetic(version: f32, include_text: bool, text_x: f32) -> (serde_json::Value, Vec<String>) {
|
||||
map::to_rest(&synthetic_connector(version, include_text, text_x), "synthetic")
|
||||
let (rest, warnings, _) = map::to_rest(&synthetic_connector(version, include_text, text_x), "synthetic", crate::rest::MAX_NODES);
|
||||
(rest, warnings)
|
||||
}
|
||||
|
||||
fn find_node<'a>(doc: &'a figcraft_doc::Document, name: &str) -> &'a figcraft_doc::Node {
|
||||
@@ -176,7 +177,7 @@ fn synthetic_connector_unsupported_version_uses_legacy_warning() {
|
||||
fn synthetic_connector_malformed_label_bounds_or_transform_keep_vector() {
|
||||
let mut malformed_bounds = synthetic_connector(1.0, true, 8.5);
|
||||
set_override_field(&mut malformed_bounds, "derivedImmutableFrameData", 1, "size", point(f32::NAN, 24.0));
|
||||
let (rest, warnings) = map::to_rest(&malformed_bounds, "synthetic");
|
||||
let (rest, warnings, _) = map::to_rest(&malformed_bounds, "synthetic", crate::rest::MAX_NODES);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["children"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(connector["children"][0]["type"], "VECTOR");
|
||||
@@ -184,7 +185,7 @@ fn synthetic_connector_malformed_label_bounds_or_transform_keep_vector() {
|
||||
|
||||
let mut malformed_transform = synthetic_connector(1.0, true, 8.5);
|
||||
set_override_field(&mut malformed_transform, "derivedImmutableFrameData", 1, "transform", transform(f32::NAN, 3.0));
|
||||
let (rest, warnings) = map::to_rest(&malformed_transform, "synthetic");
|
||||
let (rest, warnings, _) = map::to_rest(&malformed_transform, "synthetic", crate::rest::MAX_NODES);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["children"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(connector["children"][0]["type"], "VECTOR");
|
||||
@@ -195,7 +196,7 @@ fn synthetic_connector_malformed_label_bounds_or_transform_keep_vector() {
|
||||
fn synthetic_connector_authored_hidden_background_is_retained() {
|
||||
let mut message = synthetic_connector(1.0, true, 8.5);
|
||||
set_override_field(&mut message, "nodeGenerationData", 2, "fillPaints", Kv::Array(vec![paint()]));
|
||||
let (rest, _) = map::to_rest(&message, "synthetic");
|
||||
let (rest, _, _) = map::to_rest(&message, "synthetic", crate::rest::MAX_NODES);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
let background = connector["children"].as_array().unwrap().iter().find(|child| child["type"] == "RECTANGLE").expect("authored background");
|
||||
assert_eq!(background["visible"], false);
|
||||
|
||||
@@ -1564,6 +1564,20 @@ impl<'d> Exporter<'d> {
|
||||
data.extend([("characterStyleIDs", arr(ids)), ("styleOverrideTable", arr(entries))]);
|
||||
}
|
||||
let lists: Vec<ListStyle> = (0..td.paragraph_count()).map(|p| td.para_list(p).list).collect();
|
||||
let first = |i: usize, l: &ListStyle| *l != ListStyle::None && (i == 0 || lists.get(i - 1) != Some(l));
|
||||
// Where each line's numbered list starts counting, less one: its first item's start, on
|
||||
// every line of the list.
|
||||
let mut run = 0;
|
||||
let starts: Vec<u32> = lists
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
if first(i, l) {
|
||||
run = td.para_list(i).start_offset;
|
||||
}
|
||||
if *l == ListStyle::Numbered { run } else { 0 }
|
||||
})
|
||||
.collect();
|
||||
// One entry per paragraph, each with every field a reader expects.
|
||||
let line = |(i, l): (usize, &ListStyle)| {
|
||||
let ty = match l {
|
||||
@@ -1572,14 +1586,15 @@ impl<'d> Exporter<'d> {
|
||||
ListStyle::Bulleted => "UNORDERED_LIST",
|
||||
};
|
||||
let listed = *l != ListStyle::None;
|
||||
let first = listed && (i == 0 || lists.get(i - 1) != Some(l));
|
||||
let begins = first(i, l);
|
||||
let start = starts.get(i).copied().unwrap_or(0);
|
||||
o(vec![
|
||||
("lineType", e(ty)),
|
||||
("styleId", int(0)),
|
||||
("indentationLevel", int(i64::from(listed))),
|
||||
("sourceDirectionality", e("AUTO")),
|
||||
("listStartOffset", int(0)),
|
||||
("isFirstLineOfList", b(first)),
|
||||
("listStartOffset", int(i64::from(start))),
|
||||
("isFirstLineOfList", b(begins)),
|
||||
])
|
||||
};
|
||||
data.push(("lines", arr(lists.iter().enumerate().map(line).collect())));
|
||||
|
||||
@@ -680,7 +680,7 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
|
||||
for r in &mut td.runs {
|
||||
px(&mut r.style);
|
||||
}
|
||||
td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { list: td.para_list(p).list, indent: 0 }).collect();
|
||||
td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { indent: 0, ..td.para_list(p) }).collect();
|
||||
td.list = ListStyle::None;
|
||||
}
|
||||
NodeKind::Vector { network } => {
|
||||
|
||||
@@ -10,8 +10,16 @@ use std::fmt;
|
||||
|
||||
/// Deepest struct/message nesting decoded.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Most values decoded from one message (every scalar, array element and field counts).
|
||||
const MAX_VALUES: usize = 20_000_000;
|
||||
/// Most values decoded from one message (every scalar, array element and field counts): at least
|
||||
/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a
|
||||
/// large real file decodes while a small one can't expand into a huge tree, however far its
|
||||
/// chunks inflate. 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is
|
||||
/// lower: past it the file is refused cleanly instead of running out of memory.
|
||||
const MIN_VALUES: usize = 20_000_000;
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
const MAX_VALUES: usize = 128 << 20;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const MAX_VALUES: usize = 32 << 20;
|
||||
/// Most definitions in a schema, and fields in one definition.
|
||||
const MAX_DEFS: usize = 10_000;
|
||||
const MAX_FIELDS: usize = 10_000;
|
||||
@@ -284,13 +292,23 @@ impl Schema {
|
||||
self.defs.iter().position(|d| d.name == name)
|
||||
}
|
||||
|
||||
/// Decode a message of definition `root`.
|
||||
/// Decode a message of definition `root`, within [`MIN_VALUES`] values.
|
||||
pub fn decode_message<'s>(&'s self, root: usize, data: &[u8]) -> Result<Kv<'s>, KiwiError> {
|
||||
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget: MAX_VALUES };
|
||||
self.decode_message_within(root, data, MIN_VALUES)
|
||||
}
|
||||
|
||||
/// [`Self::decode_message`] within `budget` values (see [`value_budget`]).
|
||||
pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result<Kv<'s>, KiwiError> {
|
||||
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget };
|
||||
d.def(root, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Most values decoded from a file whose compressed canvas is `packed` bytes.
|
||||
pub(crate) fn value_budget(packed: usize) -> usize {
|
||||
packed.clamp(MIN_VALUES, MAX_VALUES)
|
||||
}
|
||||
|
||||
struct Decoder<'s, 'b> {
|
||||
schema: &'s Schema,
|
||||
r: Reader<'b>,
|
||||
@@ -327,6 +345,8 @@ impl<'s> Decoder<'s, '_> {
|
||||
loop {
|
||||
let id = self.r.var_uint()?;
|
||||
if id == 0 {
|
||||
// Grown by doubling: trimmed, a large file's tree takes a good part less.
|
||||
out.shrink_to_fit();
|
||||
return Ok(Kv::Object(out));
|
||||
}
|
||||
let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?;
|
||||
|
||||
+114
-60
@@ -6,9 +6,10 @@
|
||||
//! are expanded here from the main component (overrides and derived layout matched by id path,
|
||||
//! component property values applied) so the import looks as saved.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::rc::Rc;
|
||||
|
||||
use figcraft_doc::VectorNetwork;
|
||||
use figcraft_doc::geom::{Affine, Point};
|
||||
use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH};
|
||||
use serde_json::{Map, Value, json};
|
||||
@@ -18,8 +19,7 @@ use super::network;
|
||||
|
||||
type Obj = Map<String, Value>;
|
||||
|
||||
/// Most layers produced (instance expansion included) and deepest nesting followed.
|
||||
const MAX_NODES: usize = crate::rest::MAX_NODES;
|
||||
/// Deepest nesting followed.
|
||||
const MAX_DEPTH: usize = 256;
|
||||
/// Most path commands read from one geometry blob.
|
||||
const MAX_COMMANDS: usize = 200_000;
|
||||
@@ -30,11 +30,15 @@ const NO_STYLE: &str = "4294967295:4294967295";
|
||||
/// Longest asset `key` or `version` kept for resolving references given as assets.
|
||||
const MAX_ASSET_PART: usize = 256;
|
||||
|
||||
/// The REST API file JSON for message `msg`, with the warnings about what was skipped.
|
||||
pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec<String>) {
|
||||
let mut m = Mapper::new(msg);
|
||||
/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the
|
||||
/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]).
|
||||
pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<String>, Vec<Option<VectorNetwork>>) {
|
||||
let mut m = Mapper::new(msg, max_nodes);
|
||||
let doc = m.document();
|
||||
let mut out = json!({"name": name, "document": doc});
|
||||
// Large values are moved in, never passed through `json!`, which serialises its arguments:
|
||||
// a deep copy of the whole tree, briefly holding it twice.
|
||||
let mut out = json!({"name": name});
|
||||
out["document"] = doc;
|
||||
if !m.library_contexts.is_empty() {
|
||||
out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts));
|
||||
}
|
||||
@@ -54,7 +58,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec<String>) {
|
||||
if !styles.is_empty() {
|
||||
out["styleDefinitions"] = Value::Array(styles);
|
||||
}
|
||||
(out, m.warnings)
|
||||
(out, m.warnings, m.networks)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -374,20 +378,27 @@ struct Mapper<'a> {
|
||||
sets: Obj,
|
||||
/// Layers and keys of each main, built when an instance of it first needs them.
|
||||
override_scopes: BTreeMap<String, Rc<OverrideScope>>,
|
||||
/// Entries held by `override_scopes` (bounded by [`MAX_NODES`] together).
|
||||
/// Entries held by `override_scopes` (bounded by `max_nodes` together).
|
||||
override_scope_entries: usize,
|
||||
/// Component-bearing branches of the file's internal canvases (cached library definitions).
|
||||
library_contexts: Vec<Value>,
|
||||
/// Layers visited while scanning internal canvases, across all of them.
|
||||
library_scan_count: usize,
|
||||
/// Most layers produced or scanned; the one place the cap comes from.
|
||||
max_nodes: usize,
|
||||
warnings: Vec<String>,
|
||||
count: usize,
|
||||
/// Most layers produced or scanned, instance expansion included; the one place the cap comes
|
||||
/// from (see [`super::layer_budget`]).
|
||||
max_nodes: usize,
|
||||
/// Vector networks, each built once: the layers that show one (every instance of a main
|
||||
/// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's
|
||||
/// networks took gigabytes.
|
||||
networks: Vec<Option<VectorNetwork>>,
|
||||
/// Index in `networks` by what a network is made from: its blob, its scale and its style table.
|
||||
network_ids: HashMap<(usize, u64, u64, usize), usize>,
|
||||
}
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
fn new(msg: &'a Kv<'a>) -> Self {
|
||||
fn new(msg: &'a Kv<'a>, max_nodes: usize) -> Self {
|
||||
let mut nodes = BTreeMap::new();
|
||||
let mut kids: BTreeMap<String, Vec<(&'a str, String)>> = BTreeMap::new();
|
||||
let mut prop_defs = BTreeMap::new();
|
||||
@@ -447,9 +458,11 @@ impl<'a> Mapper<'a> {
|
||||
override_scope_entries: 0,
|
||||
library_contexts: Vec::new(),
|
||||
library_scan_count: 0,
|
||||
max_nodes: MAX_NODES,
|
||||
warnings: Vec::new(),
|
||||
count: 0,
|
||||
max_nodes,
|
||||
networks: Vec::new(),
|
||||
network_ids: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +511,7 @@ impl<'a> Mapper<'a> {
|
||||
let mut pending: Vec<(String, usize)> = self.children(main).into_iter().map(|id| (id, 1)).collect();
|
||||
let mut complete = true;
|
||||
while let Some((id, depth)) = pending.pop() {
|
||||
if depth > MAX_DEPTH || visited.len() >= MAX_NODES {
|
||||
if depth > MAX_DEPTH || visited.len() >= self.max_nodes {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
@@ -523,7 +536,7 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
let scope = Rc::new(OverrideScope { current, aliases });
|
||||
let cost = scope.current.len() + scope.aliases.len();
|
||||
if cost <= MAX_NODES.saturating_sub(self.override_scope_entries) {
|
||||
if cost <= self.max_nodes.saturating_sub(self.override_scope_entries) {
|
||||
self.override_scope_entries += cost;
|
||||
self.override_scopes.insert(main.to_string(), Rc::clone(&scope));
|
||||
}
|
||||
@@ -563,7 +576,9 @@ impl<'a> Mapper<'a> {
|
||||
if out.is_empty() {
|
||||
self.warn("the file has no pages");
|
||||
}
|
||||
json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out})
|
||||
let mut doc = json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT"});
|
||||
doc["children"] = Value::Array(out);
|
||||
doc
|
||||
}
|
||||
|
||||
/// The ids of the components the visible pages use, directly or through the components they
|
||||
@@ -777,8 +792,9 @@ impl<'a> Mapper<'a> {
|
||||
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
|
||||
return None;
|
||||
}
|
||||
if self.count >= MAX_NODES {
|
||||
self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped"));
|
||||
if self.count >= self.max_nodes {
|
||||
let max = self.max_nodes;
|
||||
self.warn(format!("the file has more than {max} layers; the rest were skipped"));
|
||||
return None;
|
||||
}
|
||||
let base = *self.nodes.get(id)?;
|
||||
@@ -813,7 +829,7 @@ impl<'a> Mapper<'a> {
|
||||
let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view);
|
||||
// Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector.
|
||||
let connector_terminal =
|
||||
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= MAX_NODES);
|
||||
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.max_nodes);
|
||||
// A supported connector snapshot has its own note below instead of the generic stand-in one.
|
||||
let stand = stand_in(own).filter(|_| !connector_snapshot);
|
||||
let ty = match own {
|
||||
@@ -947,7 +963,7 @@ impl<'a> Mapper<'a> {
|
||||
fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option<Value> {
|
||||
let shape = v.str("shapeWithTextType").unwrap_or("SQUARE");
|
||||
let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty();
|
||||
if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= MAX_NODES {
|
||||
if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes {
|
||||
return None;
|
||||
}
|
||||
let mut s = Obj::new();
|
||||
@@ -979,7 +995,7 @@ impl<'a> Mapper<'a> {
|
||||
/// layer filling it, inset a little; centred unless it's a note.
|
||||
fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option<Value> {
|
||||
let chars = own_text(v)?;
|
||||
if self.count >= MAX_NODES {
|
||||
if self.count >= self.max_nodes {
|
||||
return None;
|
||||
}
|
||||
self.count += 1;
|
||||
@@ -1013,7 +1029,7 @@ impl<'a> Mapper<'a> {
|
||||
if connector_has_saved_label(v) {
|
||||
self.warn("connector labels were skipped");
|
||||
}
|
||||
if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) {
|
||||
if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) {
|
||||
return;
|
||||
}
|
||||
let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid);
|
||||
@@ -1114,7 +1130,7 @@ impl<'a> Mapper<'a> {
|
||||
out.insert("opacity".into(), json!(root_opacity * line_opacity));
|
||||
}
|
||||
if connector_has_saved_label(v) {
|
||||
let reason = if self.count >= MAX_NODES { "node budget" } else { "depth limit" };
|
||||
let reason = if self.count >= self.max_nodes { "node budget" } else { "depth limit" };
|
||||
self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children"));
|
||||
}
|
||||
}
|
||||
@@ -1144,8 +1160,8 @@ impl<'a> Mapper<'a> {
|
||||
|
||||
/// Snapshot the straight, manual connector contract carried by its generated children.
|
||||
fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec<Value> {
|
||||
if self.count >= MAX_NODES {
|
||||
self.warn(format!("the file has more than {MAX_NODES} layers; connector snapshot children were skipped"));
|
||||
if self.count >= self.max_nodes {
|
||||
self.warn(format!("the file has more than {} layers; connector snapshot children were skipped", self.max_nodes));
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(vector) = self.connector_snapshot_vector(v, rest_id, Affine::IDENTITY, true) else {
|
||||
@@ -1207,8 +1223,8 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
let background_view = View::of(background).with([background_style]);
|
||||
if !background_view.list("fillPaints").is_empty() {
|
||||
if self.count >= MAX_NODES {
|
||||
self.warn(format!("the file has more than {MAX_NODES} layers; connector label background was skipped"));
|
||||
if self.count >= self.max_nodes {
|
||||
self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes));
|
||||
} else {
|
||||
let mut rect = Obj::new();
|
||||
rect.insert("id".into(), json!(format!("{rest_id};label-background")));
|
||||
@@ -1235,8 +1251,8 @@ impl<'a> Mapper<'a> {
|
||||
transform: Affine,
|
||||
view: &View<'a>,
|
||||
) {
|
||||
if self.count >= MAX_NODES {
|
||||
self.warn(format!("the file has more than {MAX_NODES} layers; connector label was skipped"));
|
||||
if self.count >= self.max_nodes {
|
||||
self.warn(format!("the file has more than {} layers; connector label was skipped", self.max_nodes));
|
||||
return;
|
||||
}
|
||||
let mut text = Obj::new();
|
||||
@@ -1510,10 +1526,8 @@ impl<'a> Mapper<'a> {
|
||||
o.insert("style".into(), Value::Object(style));
|
||||
let Some(td) = td else { return };
|
||||
char_styles(o, td, size, &mut |w| self.warn(w));
|
||||
let lines: Vec<&str> = td
|
||||
.get("lines")
|
||||
.map(Kv::list)
|
||||
.unwrap_or_default()
|
||||
let raw = td.get("lines").map(Kv::list).unwrap_or_default();
|
||||
let lines: Vec<&str> = raw
|
||||
.iter()
|
||||
.map(|l| match l.get("lineType").and_then(Kv::str) {
|
||||
Some("ORDERED_LIST") => "ORDERED",
|
||||
@@ -1524,6 +1538,12 @@ impl<'a> Mapper<'a> {
|
||||
if lines.iter().any(|l| *l != "NONE") {
|
||||
o.insert("lineTypes".into(), json!(lines));
|
||||
}
|
||||
// Where a numbered list starts counting, less one (not a REST field).
|
||||
let starts: Vec<u32> =
|
||||
raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect();
|
||||
if starts.iter().any(|n| *n > 0) {
|
||||
o.insert("listStartOffsets".into(), json!(starts));
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry supported text-path data through the private .fig REST ingress only.
|
||||
@@ -1648,7 +1668,8 @@ impl<'a> Mapper<'a> {
|
||||
/// False when there's none or it's damaged (the outlines stand in).
|
||||
fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool {
|
||||
let Some(vd) = v.get("vectorData") else { return false };
|
||||
let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false };
|
||||
let Some(blob_id) = num(vd, "vectorNetworkBlob").map(|i| i as usize) else { return false };
|
||||
let Some(blob) = self.blobs.get(blob_id).copied() else { return false };
|
||||
let Some(net) = network::decode(blob) else {
|
||||
self.warn("damaged vector networks import as outlines");
|
||||
return false;
|
||||
@@ -1686,34 +1707,24 @@ impl<'a> Mapper<'a> {
|
||||
size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n)
|
||||
};
|
||||
let (sx, sy) = (scale("x"), scale("y"));
|
||||
let vertices: Vec<Value> = net
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
|
||||
let st = style(p.style);
|
||||
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
|
||||
out["radius"] = json!(r);
|
||||
}
|
||||
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
|
||||
Some("ANGLE") => out["mirror"] = json!("angle"),
|
||||
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
|
||||
Some("NONE") => out["mirror"] = json!("none"),
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
let segments: Vec<Value> =
|
||||
net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect();
|
||||
let regions: Vec<Value> =
|
||||
net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect();
|
||||
// An absent layer join is the source default.
|
||||
let layer_join = v.str("strokeJoin").unwrap_or("MITER");
|
||||
let point_join = |p: &network::Vertex| style(p.style).and_then(|s| s.get("strokeJoin"));
|
||||
let known = |j: &str| matches!(j, "MITER" | "BEVEL" | "ROUND");
|
||||
let unresolved_style = net.vertices.iter().any(|p| p.style != 0 && style(p.style).is_none());
|
||||
let unknown_join = net.vertices.iter().any(|p| point_join(p).is_some_and(|j| !j.str().is_some_and(known)));
|
||||
let unsupported_join = net.vertices.iter().any(|p| point_join(p).and_then(Kv::str).is_some_and(|j| known(j) && j != layer_join));
|
||||
if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) {
|
||||
self.warn("filled areas of a vector with their own fill use the layer's fill");
|
||||
}
|
||||
let join = v.str("strokeJoin");
|
||||
if net.vertices.iter().any(|p| style(p.style).and_then(|s| s.get("strokeJoin")).and_then(Kv::str).is_some_and(|j| Some(j) != join)) {
|
||||
self.warn("points with their own stroke join use the layer's");
|
||||
if unresolved_style {
|
||||
self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners");
|
||||
}
|
||||
if unknown_join {
|
||||
self.warn("vector points with unknown stroke joins use the layer join");
|
||||
}
|
||||
if unsupported_join && (o.contains_key("strokeDashes") || o.contains_key("strokeWidthProfile")) {
|
||||
self.warn("point stroke joins were preserved; dashed or variable-width strokes render with the layer join");
|
||||
}
|
||||
// The stroke starts at the first end point and stops at the last (as the editor draws it).
|
||||
let mut degree = vec![0usize; net.vertices.len()];
|
||||
@@ -1733,7 +1744,49 @@ impl<'a> Mapper<'a> {
|
||||
o.insert("strokeCapStart".into(), json!(cap_name(first.unwrap_or(node))));
|
||||
o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node))));
|
||||
}
|
||||
o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments, "regions": regions}));
|
||||
// Every instance of a main shows the same network: it is written once and shared.
|
||||
let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize);
|
||||
let id = match self.network_ids.get(&key) {
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
let vertices: Vec<Value> = net
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
|
||||
let st = style(p.style);
|
||||
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
|
||||
out["radius"] = json!(r);
|
||||
}
|
||||
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
|
||||
Some("ANGLE") => out["mirror"] = json!("angle"),
|
||||
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
|
||||
Some("NONE") => out["mirror"] = json!("none"),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) {
|
||||
out["join"] = json!(join.to_ascii_lowercase());
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
let segments: Vec<Value> = net
|
||||
.segments
|
||||
.iter()
|
||||
.map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]}))
|
||||
.collect();
|
||||
let regions: Vec<Value> =
|
||||
net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect();
|
||||
let mut entry = json!({});
|
||||
entry["vertices"] = Value::Array(vertices);
|
||||
entry["segments"] = Value::Array(segments);
|
||||
entry["regions"] = Value::Array(regions);
|
||||
self.networks.push(crate::rest::network(&entry));
|
||||
self.network_ids.insert(key, self.networks.len() - 1);
|
||||
self.networks.len() - 1
|
||||
}
|
||||
};
|
||||
o.insert("vectorNetworkRef".into(), json!(id));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -2741,6 +2794,7 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
|
||||
if let Some(skip_ink) = v.flag("textDecorationSkipInk") {
|
||||
s.insert("textDecorationSkipInk".into(), json!(skip_ink));
|
||||
}
|
||||
copy_str(&mut s, v, "leadingTrim", "leadingTrim");
|
||||
let font_size = v.num("fontSize").or(size).unwrap_or(12.0);
|
||||
if let Some(ls) = v.get("letterSpacing") {
|
||||
let val = num(ls, "value").unwrap_or(0.0);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Connector snapshot flattening stays within the node and depth budgets.
|
||||
|
||||
use super::{MAX_DEPTH, MAX_NODES, Mapper, View};
|
||||
use super::{MAX_DEPTH, Mapper, View};
|
||||
use crate::fig::kiwi::Kv;
|
||||
use crate::rest::MAX_NODES;
|
||||
use figcraft_doc::geom::Affine;
|
||||
|
||||
fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
@@ -106,7 +107,7 @@ fn connector_snapshot_respects_last_and_zero_child_slots() {
|
||||
let message = message();
|
||||
let node = message.get("nodeChanges").unwrap().list().first().unwrap();
|
||||
let view = View::of(node);
|
||||
let mut mapper = Mapper::new(&message);
|
||||
let mut mapper = Mapper::new(&message, MAX_NODES);
|
||||
mapper.count = MAX_NODES - 1;
|
||||
let children = mapper.connector_snapshot_children(&view, "1:123");
|
||||
assert_eq!(children.len(), 1, "the final available slot preserves the saved vector");
|
||||
@@ -116,7 +117,7 @@ fn connector_snapshot_respects_last_and_zero_child_slots() {
|
||||
assert_eq!(mapper.count, MAX_NODES);
|
||||
assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped")));
|
||||
|
||||
let mut mapper = Mapper::new(&message);
|
||||
let mut mapper = Mapper::new(&message, MAX_NODES);
|
||||
mapper.count = MAX_NODES;
|
||||
let children = mapper.connector_snapshot_children(&view, "1:123");
|
||||
assert!(children.is_empty());
|
||||
@@ -127,14 +128,14 @@ fn connector_snapshot_respects_last_and_zero_child_slots() {
|
||||
#[test]
|
||||
fn connector_snapshot_flattens_vector_at_depth_and_node_limits() {
|
||||
let message = message();
|
||||
let mut mapper = Mapper::new(&message);
|
||||
let mut mapper = Mapper::new(&message, MAX_NODES);
|
||||
let depth_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector");
|
||||
assert_eq!(depth_limited["type"], "VECTOR");
|
||||
assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty());
|
||||
assert!(depth_limited.get("children").is_none());
|
||||
assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children"));
|
||||
|
||||
let mut mapper = Mapper::new(&message);
|
||||
let mut mapper = Mapper::new(&message, MAX_NODES);
|
||||
mapper.count = MAX_NODES - 1;
|
||||
let budget_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, 0).expect("last allowed connector");
|
||||
assert_eq!(budget_limited["type"], "VECTOR");
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! zstd: a binary schema in the kiwi format ([`kiwi`]), then a message encoded with it. Every file
|
||||
//! carries its own schema, so the message is decoded generically and read by field name: a list
|
||||
//! of node changes (each with its id, parent id and position among its siblings) that make up the
|
||||
//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`]). [`map`]
|
||||
//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`], and the bytes
|
||||
//! of images that travel inside the message, [`embedded_images`]). [`map`]
|
||||
//! turns that into the REST API file JSON shape and the REST importer builds the document, so both
|
||||
//! imports share one mapping. Layer kinds the editor doesn't have (sticky notes, connectors,
|
||||
//! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped
|
||||
@@ -38,6 +39,7 @@ mod text_skip_ink_tests;
|
||||
#[cfg(test)]
|
||||
mod text_style_tests;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
|
||||
use figcraft_doc::Document;
|
||||
@@ -45,7 +47,7 @@ use figcraft_doc::Document;
|
||||
pub use export::export;
|
||||
|
||||
use crate::FormatError;
|
||||
use kiwi::Schema;
|
||||
use kiwi::{Kv, Schema};
|
||||
|
||||
/// Largest `canvas.fig` member read, and largest decompressed chunk.
|
||||
const MAX_CANVAS: u64 = 256 * 1024 * 1024;
|
||||
@@ -54,6 +56,18 @@ const MAX_CHUNK: u64 = 512 * 1024 * 1024;
|
||||
const MAX_IMAGE: u64 = 256 * 1024 * 1024;
|
||||
/// Most chunks read from `canvas.fig`.
|
||||
const MAX_CHUNKS: usize = 16;
|
||||
/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its
|
||||
/// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances
|
||||
/// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the
|
||||
/// budget follows what the file takes, not what it unpacks to: a small file can't claim a large
|
||||
/// count, however it's built; a large one gets room.
|
||||
const PACKED_BYTES_PER_LAYER: usize = 32;
|
||||
const MAX_FIG_NODES: usize = 2_000_000;
|
||||
|
||||
/// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes.
|
||||
pub(crate) fn layer_budget(packed: usize) -> usize {
|
||||
(packed / PACKED_BYTES_PER_LAYER).clamp(crate::rest::MAX_NODES, MAX_FIG_NODES)
|
||||
}
|
||||
|
||||
/// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas.
|
||||
pub fn is_fig(bytes: &[u8]) -> bool {
|
||||
@@ -72,14 +86,24 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option<Result<(Document, Vec<Stri
|
||||
/// Build a document from a `.fig` file. Returns it with the warnings about what was skipped or
|
||||
/// approximated. `name`: the document name when the file doesn't carry one.
|
||||
pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), FormatError> {
|
||||
let archive = Archive::open(bytes)?;
|
||||
let (schema, message) = decode_canvas(&archive.canvas)?;
|
||||
let mut archive = Archive::open(bytes)?;
|
||||
// The budgets follow the compressed canvas, which the file itself bounds (its zip member may
|
||||
// inflate too).
|
||||
let packed = archive.canvas.len().min(bytes.len());
|
||||
// Each stage's input is dropped as soon as the next one is built: in a large file every one
|
||||
// of them takes gigabytes, and holding them all at once runs out of memory.
|
||||
let (schema, message) = decode_canvas(&std::mem::take(&mut archive.canvas))?;
|
||||
let root = schema.find("Message").ok_or_else(|| corrupt("the schema has no Message definition"))?;
|
||||
let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?;
|
||||
let msg = schema.decode_message_within(root, &message, kiwi::value_budget(packed)).map_err(|e| corrupt(e.to_string()))?;
|
||||
let max_nodes = layer_budget(packed);
|
||||
drop(message);
|
||||
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
|
||||
let (json, mut warnings) = map::to_rest(&msg, file_name);
|
||||
let images = |r: &str| archive.image(r);
|
||||
let (doc, more) = crate::rest::import_fig(&json, &images)?;
|
||||
let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
|
||||
// The message goes before the layers are built, so the images it carries are copied out first.
|
||||
let embedded: BTreeMap<String, Vec<u8>> = embedded_images(&msg).into_iter().map(|(hash, bytes)| (hash, bytes.to_vec())).collect();
|
||||
drop(msg);
|
||||
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned());
|
||||
let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
|
||||
warnings.extend(more.into_iter().filter_map(map::adapt_warning));
|
||||
Ok((doc, warnings))
|
||||
}
|
||||
@@ -109,6 +133,41 @@ fn corrupt(msg: impl Into<String>) -> FormatError {
|
||||
FormatError::Corrupt(format!(".fig file: {}", msg.into()))
|
||||
}
|
||||
|
||||
/// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's
|
||||
/// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed
|
||||
/// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The
|
||||
/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. The
|
||||
/// bytes stay borrowed from the message.
|
||||
fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap<String, &'m [u8]> {
|
||||
let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
|
||||
let mut found = BTreeMap::new();
|
||||
let mut pending = vec![msg];
|
||||
while let Some(value) = pending.pop() {
|
||||
match value {
|
||||
Kv::Object(fields) => {
|
||||
if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) {
|
||||
found.entry(hash).or_insert(bytes);
|
||||
}
|
||||
pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field));
|
||||
}
|
||||
Kv::Array(items) => pending.extend(items),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The hash and bytes of an `Image` value whose `dataBlob` points at a usable blob.
|
||||
fn embedded_image<'k>(image: &Kv, blobs: &'k [Kv]) -> Option<(String, &'k [u8])> {
|
||||
let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?;
|
||||
let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None };
|
||||
let bytes = blobs.get(usize::try_from(*index).ok()?)?.get("bytes").and_then(Kv::bytes)?;
|
||||
if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE {
|
||||
return None;
|
||||
}
|
||||
Some((hash.iter().map(|b| format!("{b:02x}")).collect(), bytes))
|
||||
}
|
||||
|
||||
/// The parts of a `.fig` archive the import reads.
|
||||
struct Archive<'b> {
|
||||
canvas: Vec<u8>,
|
||||
|
||||
@@ -24,7 +24,7 @@ fn node(local: u32, kind: &'static str, parent: Option<u32>, mut fields: Vec<(&'
|
||||
}
|
||||
|
||||
fn mapped(msg: &Kv<'_>) -> Value {
|
||||
to_rest(msg, "Synthetic resources").0
|
||||
to_rest(msg, "Synthetic resources", crate::rest::MAX_NODES).0
|
||||
}
|
||||
|
||||
fn named_id<'b>(v: &'b Value, id: &str) -> Option<&'b Value> {
|
||||
@@ -210,12 +210,12 @@ fn unassigned_slot_keeps_default_and_bad_content_target_warns() {
|
||||
assert_eq!(slot["children"].as_array().unwrap().len(), 1);
|
||||
assert!(slot.get("overrides").is_none());
|
||||
|
||||
let (rest, warnings) = to_rest(&slot_message(Some(999), false), "Synthetic");
|
||||
let (rest, warnings, _) = to_rest(&slot_message(Some(999), false), "Synthetic", crate::rest::MAX_NODES);
|
||||
assert!(warnings.iter().any(|w| w.contains("slot content assignment target is missing or is not slot content")), "{warnings:?}");
|
||||
let slot = named_id(&rest["document"], "I9:20;9:12").unwrap();
|
||||
assert_eq!(slot["children"].as_array().unwrap().len(), 1);
|
||||
|
||||
let (rest, warnings) = to_rest(&slot_message(Some(33), true), "Synthetic");
|
||||
let (rest, warnings, _) = to_rest(&slot_message(Some(33), true), "Synthetic", crate::rest::MAX_NODES);
|
||||
let main = named_id(&rest["document"], "9:10").unwrap();
|
||||
assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT");
|
||||
assert!(warnings.iter().any(|w| w.contains("preferred slot component keys cannot be resolved")), "{warnings:?}");
|
||||
|
||||
@@ -379,7 +379,7 @@ fn public_rest_import_ignores_private_fig_provenance() {
|
||||
assert!(doc.library_store().is_none() && doc.nodes().all(|node| node.name != "Must not import"));
|
||||
// Valid provenance on mapped layers links nothing either.
|
||||
for message in [message(false), external_namespace_message()] {
|
||||
let (json, _) = super::map::to_rest(&message, "Identity");
|
||||
let (json, _, _) = super::map::to_rest(&message, "Identity", crate::rest::MAX_NODES);
|
||||
let (doc, _) = crate::rest::import(&json, &|_| None).unwrap();
|
||||
assert!(doc.nodes().all(|n| n.source.is_none()));
|
||||
}
|
||||
@@ -806,7 +806,7 @@ fn edit_node(value: &mut Value, id: &str, edit: &mut dyn FnMut(&mut Value)) {
|
||||
#[test]
|
||||
fn missing_foreign_self_and_wrong_namespace_provenance_is_not_guessed() {
|
||||
for (origin, id) in [("1:99999", "I1:70;1:40"), ("1:12", "I1:70;1:40"), ("1:70", "1:70"), ("1:40", "I1:70;1:40")] {
|
||||
let (mut json, _) = super::map::to_rest(&message(false), "Identity");
|
||||
let (mut json, _, _) = super::map::to_rest(&message(false), "Identity", crate::rest::MAX_NODES);
|
||||
edit_node(&mut json["document"]["children"][0], id, &mut |node| {
|
||||
node[crate::rest::FIG_SOURCE_FIELD]["origin"] = json!(origin);
|
||||
if id == "1:70" {
|
||||
@@ -825,7 +825,7 @@ fn missing_foreign_self_and_wrong_namespace_provenance_is_not_guessed() {
|
||||
|
||||
#[test]
|
||||
fn forward_main_references_and_duplicate_ids_are_conservative() {
|
||||
let (mut json, _) = super::map::to_rest(&message(false), "Identity");
|
||||
let (mut json, _, _) = super::map::to_rest(&message(false), "Identity", crate::rest::MAX_NODES);
|
||||
json["document"]["children"][0]["children"].as_array_mut().unwrap().rotate_right(1);
|
||||
let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap();
|
||||
assert!(doc.node(named(&doc, "Copy")).unwrap().source.is_some());
|
||||
@@ -838,7 +838,7 @@ fn forward_main_references_and_duplicate_ids_are_conservative() {
|
||||
|
||||
#[test]
|
||||
fn missing_contextual_targets_do_not_fall_back_to_a_global_slot() {
|
||||
let (mut json, _) = super::map::to_rest(&message(false), "Identity");
|
||||
let (mut json, _, _) = super::map::to_rest(&message(false), "Identity", crate::rest::MAX_NODES);
|
||||
edit_node(&mut json["document"]["children"][0], "1:40", &mut |node| {
|
||||
node["children"].as_array_mut().unwrap().retain(|n| n["id"] != "I1:40;1:60");
|
||||
});
|
||||
@@ -1019,7 +1019,7 @@ fn absent_external_mains_keep_known_owned_instances_and_contextual_copies() {
|
||||
#[test]
|
||||
fn external_namespace_is_private_and_requires_actual_wire_instances() {
|
||||
for mode in 0..3 {
|
||||
let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External");
|
||||
let (mut json, _, _) = super::map::to_rest(&external_namespace_message(), "External", crate::rest::MAX_NODES);
|
||||
if mode == 0 {
|
||||
let (doc, _) = crate::rest::import(&json, &|_| None).unwrap();
|
||||
assert!(doc.nodes().all(|n| n.source.is_none()));
|
||||
@@ -1042,7 +1042,7 @@ fn external_namespace_is_private_and_requires_actual_wire_instances() {
|
||||
#[test]
|
||||
fn external_namespace_does_not_cover_present_wrong_kind_or_blocked_main() {
|
||||
for mode in 0..3 {
|
||||
let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External");
|
||||
let (mut json, _, _) = super::map::to_rest(&external_namespace_message(), "External", crate::rest::MAX_NODES);
|
||||
let page = &mut json["document"]["children"][0];
|
||||
match mode {
|
||||
0 => page["children"].as_array_mut().unwrap().push(json!({"id":"1:99", "type":"FRAME", "name":"Wrong kind",
|
||||
@@ -1066,7 +1066,7 @@ fn external_namespace_does_not_cover_present_wrong_kind_or_blocked_main() {
|
||||
#[test]
|
||||
fn external_namespace_rejects_foreign_siblings_duplicates_and_bad_paths() {
|
||||
for mode in 0..4 {
|
||||
let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External");
|
||||
let (mut json, _, _) = super::map::to_rest(&external_namespace_message(), "External", crate::rest::MAX_NODES);
|
||||
let page = &mut json["document"]["children"][0];
|
||||
match mode {
|
||||
0 => {
|
||||
@@ -1113,7 +1113,7 @@ fn an_explicit_no_style_detaches_and_beats_the_older_style_field() {
|
||||
node(7, Some(2), "RECTANGLE", "Linked", vec![("inheritFillStyleID", g(5))]),
|
||||
]),
|
||||
)]);
|
||||
let (json, _) = super::map::to_rest(&message, "Detach");
|
||||
let (json, _, _) = super::map::to_rest(&message, "Detach", crate::rest::MAX_NODES);
|
||||
let find = |name: &str| json["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["name"] == name).unwrap().clone();
|
||||
assert!(find("Box")["styles"].get("fill").is_none(), "{}", find("Box")["styles"]);
|
||||
assert!(find("Linked")["styles"].get("fill").is_some(), "the older field still links when nothing detaches");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! fixtures built in code with the parent module's helpers.
|
||||
|
||||
use figcraft_doc::proto::Action;
|
||||
use figcraft_doc::{HandleMirroring, StrokeCap, WindingRule};
|
||||
use figcraft_doc::{HandleMirroring, StrokeCap, StrokeJoin, WindingRule};
|
||||
|
||||
use super::super::network::{self, Network, Region, Segment, Vertex};
|
||||
use super::*;
|
||||
@@ -19,7 +19,7 @@ fn vtx(style: u32, x: f64, y: f64) -> Vertex {
|
||||
/// A square with one curved side and a rounded, mirrored corner, filled even-odd.
|
||||
fn square_blob() -> Vec<u8> {
|
||||
network::encode(&Network {
|
||||
vertices: vec![vtx(0, 0.0, 0.0), vtx(1, 50.0, 0.0), vtx(0, 50.0, 50.0), vtx(0, 0.0, 50.0)],
|
||||
vertices: vec![vtx(0, 0.0, 0.0), vtx(1, 50.0, 0.0), vtx(2, 50.0, 50.0), vtx(3, 0.0, 50.0)],
|
||||
segments: vec![
|
||||
seg(0, 1, (10.0, 0.0), (-10.0, 0.0)),
|
||||
seg(1, 2, (0.0, 0.0), (0.0, 0.0)),
|
||||
@@ -39,6 +39,15 @@ fn arrow_blob() -> Vec<u8> {
|
||||
})
|
||||
}
|
||||
|
||||
/// An open path with one missing style ID and one unsupported point join.
|
||||
fn join_fallback_blob() -> Vec<u8> {
|
||||
network::encode(&Network {
|
||||
vertices: vec![vtx(4, 0.0, 0.0), vtx(5, 50.0, 0.0), vtx(0, 100.0, 0.0)],
|
||||
segments: vec![seg(0, 1, (0.0, 0.0), (0.0, 0.0)), seg(1, 2, (0.0, 0.0), (0.0, 0.0))],
|
||||
regions: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
fn vector_data(blob: u64, nx: f64, ny: f64, styles: Vec<Kv<'static>>) -> Kv<'static> {
|
||||
o(vec![("vectorNetworkBlob", u(blob)), ("normalizedSize", xy(nx, ny)), ("styleOverrideTable", arr(styles))])
|
||||
}
|
||||
@@ -121,13 +130,24 @@ fn tail_message() -> Kv<'static> {
|
||||
("transform", at(700.0, 0.0)),
|
||||
("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])),
|
||||
("fillGeometry", outline(0)),
|
||||
("strokeJoin", e("MITER")),
|
||||
("dashPattern", arr(vec![f(4.0), f(2.0)])),
|
||||
(
|
||||
"vectorData",
|
||||
vector_data(
|
||||
1,
|
||||
100.0,
|
||||
50.0,
|
||||
vec![o(vec![("styleID", u(1)), ("cornerRadius", f(4.0)), ("handleMirroring", e("ANGLE_AND_LENGTH"))])],
|
||||
vec![
|
||||
o(vec![
|
||||
("styleID", u(1)),
|
||||
("cornerRadius", f(4.0)),
|
||||
("handleMirroring", e("ANGLE_AND_LENGTH")),
|
||||
("strokeJoin", e("BEVEL")),
|
||||
]),
|
||||
o(vec![("styleID", u(2)), ("strokeJoin", e("MITER"))]),
|
||||
o(vec![("styleID", u(3)), ("strokeJoin", e("ROUND"))]),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -146,6 +166,19 @@ fn tail_message() -> Kv<'static> {
|
||||
("vectorData", vector_data(2, 100.0, 0.0, vec![o(vec![("styleID", u(2)), ("strokeCap", e("ARROW_LINES"))])])),
|
||||
],
|
||||
),
|
||||
node(
|
||||
42,
|
||||
Some((1, "#")),
|
||||
"VECTOR",
|
||||
"Join Fallbacks",
|
||||
vec![
|
||||
("size", xy(100.0, 10.0)),
|
||||
("transform", at(0.0, 330.0)),
|
||||
stroke(),
|
||||
("strokeJoin", e("MITER")),
|
||||
("vectorData", vector_data(4, 100.0, 10.0, vec![o(vec![("styleID", u(5)), ("strokeJoin", u(999))])])),
|
||||
],
|
||||
),
|
||||
node(
|
||||
32,
|
||||
Some((1, "#")),
|
||||
@@ -223,7 +256,7 @@ fn tail_message() -> Kv<'static> {
|
||||
];
|
||||
let mut broken = square_blob();
|
||||
broken.truncate(30);
|
||||
let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken];
|
||||
let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken, join_fallback_blob()];
|
||||
o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(blobs.into_iter().map(|b| o(vec![("bytes", Kv::Bytes(b))])).collect()))])
|
||||
}
|
||||
|
||||
@@ -247,6 +280,11 @@ fn vectors_import_as_editable_networks() {
|
||||
assert_eq!(pts, [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
assert_eq!((wave.vertices[1].radius, wave.vertices[1].mirror), (4.0, Some(HandleMirroring::AngleAndLength)));
|
||||
assert_eq!(wave.vertices[0].mirror, None);
|
||||
assert_eq!(
|
||||
wave.vertices.iter().map(|v| v.join).collect::<Vec<_>>(),
|
||||
[None, Some(StrokeJoin::Bevel), Some(StrokeJoin::Miter), Some(StrokeJoin::Round)]
|
||||
);
|
||||
assert_eq!(named(&doc, "Wave").stroke.join, StrokeJoin::Miter);
|
||||
assert_eq!((wave.segments[0].ta, wave.segments[0].tb), ((20.0, 0.0), (-20.0, 0.0)));
|
||||
assert_eq!(wave.regions.len(), 1);
|
||||
assert_eq!((wave.regions[0].loops.clone(), wave.regions[0].winding), (vec![vec![0, 1, 2, 3]], WindingRule::EvenOdd));
|
||||
@@ -260,6 +298,17 @@ fn vectors_import_as_editable_networks() {
|
||||
// A damaged network falls back to the outline.
|
||||
assert_eq!(network_of(&doc, "Broken").regions.len(), 1);
|
||||
assert!(warnings.iter().any(|w| w.contains("damaged vector networks import as outlines")), "{warnings:?}");
|
||||
let fallback = network_of(&doc, "Join Fallbacks");
|
||||
assert!(fallback.vertices.iter().all(|v| v.join.is_none()));
|
||||
assert_eq!(named(&doc, "Join Fallbacks").stroke.join, StrokeJoin::Miter);
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("vector points refer to point settings missing from the file; they use the layer's joins and plain corners")),
|
||||
"{warnings:?}"
|
||||
);
|
||||
assert!(warnings.iter().any(|w| w.contains("unknown stroke joins use the layer join")), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("dashed or variable-width strokes render with the layer join")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -446,8 +495,8 @@ fn mangled_tail_files_never_panic() {
|
||||
m[i] = r.next() as u8;
|
||||
}
|
||||
if let Ok(kv) = sc.decode_message(root, &m) {
|
||||
let (json, _) = map::to_rest(&kv, "x");
|
||||
let _ = crate::rest::import(&json, &crate::rest::no_images);
|
||||
let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES);
|
||||
let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,13 @@ enum NavigationType NAVIGATE OVERLAY
|
||||
enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE
|
||||
enum EasingType IN_CUBIC OUT_CUBIC
|
||||
enum ConstraintType MIN CENTER MAX STRETCH SCALE
|
||||
enum LeadingTrim NONE CAP_HEIGHT
|
||||
struct GUID uint sessionID, uint localID
|
||||
struct Color float r, float g, float b, float a
|
||||
struct Vector float x, float y
|
||||
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
|
||||
struct ColorStop Color color, float position
|
||||
message Image byte[] hash, string name
|
||||
message Image byte[] hash, string name, uint dataBlob
|
||||
message AssetRef string key, string version
|
||||
message VariableID GUID guid, AssetRef assetRef
|
||||
message PropRefValue GUID defId
|
||||
@@ -72,7 +73,7 @@ message Event InteractionType interactionType
|
||||
message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions
|
||||
message Interaction Event event, Action[] actions
|
||||
message StartingPoint string name, string position
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern
|
||||
message Blob byte[] bytes
|
||||
message Message NodeChange[] nodeChanges, Blob[] blobs
|
||||
";
|
||||
@@ -283,6 +284,7 @@ fn message() -> Kv<'static> {
|
||||
("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])),
|
||||
("fontSize", f(16.0)),
|
||||
("fontName", font("Regular")),
|
||||
("leadingTrim", e("CAP_HEIGHT")),
|
||||
("textData", text),
|
||||
],
|
||||
),
|
||||
@@ -646,6 +648,7 @@ fn imports_the_fixture() {
|
||||
let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") };
|
||||
assert_eq!(t.characters, "Hi there");
|
||||
assert_eq!(t.style.font_size, 16.0);
|
||||
assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)");
|
||||
assert_eq!(t.runs.len(), 1);
|
||||
assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold"));
|
||||
// The group's children keep their place on the page (group at 100, 100).
|
||||
@@ -708,6 +711,53 @@ fn bare_canvas_and_zlib_chunks_open() {
|
||||
assert!(import(&out, "Z").is_ok());
|
||||
}
|
||||
|
||||
/// The fixture message with the photo's image bytes in its own blobs, as copies written by plugins
|
||||
/// and tools carry them: the PNG goes at the end of `blobs` and `image.dataBlob` points `shift`
|
||||
/// places past it (0 is the PNG itself).
|
||||
fn message_with_image_in_blobs(shift: u64) -> Kv<'static> {
|
||||
fn point_images_at(value: &mut Kv<'static>, blob: u64) {
|
||||
match value {
|
||||
Kv::Object(fields) => {
|
||||
for (name, field) in fields.iter_mut() {
|
||||
match field {
|
||||
Kv::Object(image) if *name == "image" => image.push(("dataBlob", u(blob))),
|
||||
_ => point_images_at(field, blob),
|
||||
}
|
||||
}
|
||||
}
|
||||
Kv::Array(items) => items.iter_mut().for_each(|item| point_images_at(item, blob)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut msg = message();
|
||||
let index = {
|
||||
let Kv::Object(fields) = &mut msg else { panic!("the message is an object") };
|
||||
let Some((_, Kv::Array(blobs))) = fields.iter_mut().find(|(name, _)| *name == "blobs") else { panic!("no blobs") };
|
||||
blobs.push(o(vec![("bytes", Kv::Bytes(png()))]));
|
||||
blobs.len() as u64 - 1
|
||||
};
|
||||
point_images_at(&mut msg, index + shift);
|
||||
msg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_canvas_reads_images_carried_in_its_blobs() {
|
||||
// The clipboard holds a bare canvas (no `images/`); the image comes from `image.dataBlob`.
|
||||
let c = canvas(&schema(), &message_with_image_in_blobs(0));
|
||||
let (doc, warnings) = import(&c, "Pasted").unwrap();
|
||||
let photo = named(&doc, "Photo");
|
||||
let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") };
|
||||
assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1)));
|
||||
assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_data_blob_past_the_blobs_stays_a_placeholder() {
|
||||
let c = canvas(&schema(), &message_with_image_in_blobs(5));
|
||||
let (_, warnings) = import(&c, "Pasted").unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_files_are_not_fig() {
|
||||
assert!(try_import(b"{\"document\": {}}", "x").is_none());
|
||||
@@ -757,7 +807,7 @@ fn mangled_kiwi_never_panics() {
|
||||
}
|
||||
for cut in (0..msg.len()).step_by(3) {
|
||||
if let Ok(kv) = sc.decode_message(root, &msg[..cut]) {
|
||||
let _ = map::to_rest(&kv, "x");
|
||||
let _ = map::to_rest(&kv, "x", crate::rest::MAX_NODES);
|
||||
}
|
||||
}
|
||||
for _ in 0..2000 {
|
||||
@@ -767,8 +817,8 @@ fn mangled_kiwi_never_panics() {
|
||||
m[i] = r.next() as u8;
|
||||
}
|
||||
if let Ok(kv) = sc.decode_message(root, &m) {
|
||||
let (json, _) = map::to_rest(&kv, "x");
|
||||
let _ = crate::rest::import(&json, &crate::rest::no_images);
|
||||
let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES);
|
||||
let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES);
|
||||
}
|
||||
let mut sbytes = schema_bytes.clone();
|
||||
let i = (r.next() as usize) % sbytes.len();
|
||||
@@ -790,6 +840,61 @@ fn mangled_kiwi_never_panics() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_files_get_a_larger_value_budget() {
|
||||
// Small files keep the fixed floor: a few bytes can't expand into a huge tree.
|
||||
assert_eq!(kiwi::value_budget(0), 20_000_000);
|
||||
assert_eq!(kiwi::value_budget(1 << 20), 20_000_000);
|
||||
// A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds
|
||||
// 30.5 million values was refused as "too many values".
|
||||
assert!(kiwi::value_budget(83_491_232) > 30_514_899);
|
||||
// A real 194 MB design-system file (UX4G): a 39.5 MB canvas holding 29.7 million values.
|
||||
assert!(kiwi::value_budget(39_528_420) > 29_703_730);
|
||||
// Still bounded by the largest canvas read.
|
||||
assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layer_cap_follows_the_file_size() {
|
||||
// A small (or broken) file keeps the REST import's cap: instances expand into a layer per
|
||||
// layer of their component, and a few bytes mustn't claim millions of layers.
|
||||
assert_eq!(layer_budget(0), crate::rest::MAX_NODES);
|
||||
assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES);
|
||||
// A real 894 004-layer file (an 83.5 MB canvas) stopped at 200 000 and came in with 27 of
|
||||
// its 30 pages empty.
|
||||
assert!(layer_budget(83_491_232) > 894_004);
|
||||
// No file gets more than the ceiling.
|
||||
assert_eq!(layer_budget(MAX_CANVAS as usize), MAX_FIG_NODES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_file_that_unpacks_large_gets_no_larger_budgets() {
|
||||
// 40 MB of zeros in a blob pack into a few KB. Keyed to the unpacked message, they lifted the
|
||||
// layer cap above the 231 231 layers its instances expand into; keyed to the file, the cap
|
||||
// stays at the REST import's.
|
||||
let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])];
|
||||
nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(10.0, 10.0))]));
|
||||
nodes.extend((10..1010).map(|i| node(i, Some((5, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))])));
|
||||
nodes.extend((2000..2230).map(|i| node(i, Some((1, "!")), "INSTANCE", "I", vec![("symbolData", o(vec![("symbolID", g(5))]))])));
|
||||
let blob = o(vec![("bytes", Kv::Bytes(vec![0; 40 << 20]))]);
|
||||
let file = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![blob]))]));
|
||||
assert!(file.len() < 1 << 20, "{} bytes", file.len());
|
||||
let (doc, warnings) = import(&file, "x").unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("more than 200000 layers")), "{warnings:?}");
|
||||
assert!(doc.node_count() <= crate::rest::MAX_NODES + 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mapper_stops_at_the_layer_cap_it_is_given() {
|
||||
let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])];
|
||||
nodes.extend((2..12).map(|i| node(i, Some((1, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))])));
|
||||
let msg = o(vec![("nodeChanges", arr(nodes))]);
|
||||
let (_, warnings, _) = map::to_rest(&msg, "x", 5);
|
||||
assert!(warnings.iter().any(|w| w.contains("more than 5 layers")), "{warnings:?}");
|
||||
let (_, warnings, _) = map::to_rest(&msg, "x", crate::rest::MAX_NODES);
|
||||
assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_refused() {
|
||||
// A message that contains itself, nested past the depth cap.
|
||||
@@ -1045,6 +1150,9 @@ mod tail;
|
||||
#[path = "image_filter_tests.rs"]
|
||||
mod image_filters;
|
||||
|
||||
#[path = "text_list_tests.rs"]
|
||||
mod text_list;
|
||||
|
||||
/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset).
|
||||
pub(super) fn samples() -> Vec<std::path::PathBuf> {
|
||||
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() };
|
||||
@@ -1088,7 +1196,7 @@ fn dump_samples() {
|
||||
std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap();
|
||||
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||||
std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap();
|
||||
let (rest, _) = map::to_rest(&msg, &stem);
|
||||
let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len()));
|
||||
std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Numbered lists that start past 1 (#686): each text line says where its list starts counting,
|
||||
//! less one (`listStartOffset`). Fixtures built in code with the parent module's helpers.
|
||||
|
||||
use figcraft_doc::{ListStyle, NodeKind, ParaList};
|
||||
|
||||
use super::*;
|
||||
|
||||
const LIST_SCHEMA: &str = "
|
||||
enum NodeType NONE DOCUMENT CANVAS TEXT
|
||||
enum LineType PLAIN ORDERED_LIST UNORDERED_LIST
|
||||
struct GUID uint sessionID, uint localID
|
||||
struct Vector float x, float y
|
||||
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
|
||||
message ParentIndex GUID guid, string position
|
||||
message TextLineData LineType lineType, int indentationLevel, int listStartOffset, bool isFirstLineOfList
|
||||
message TextData string characters, TextLineData[] lines
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData
|
||||
message Message NodeChange[] nodeChanges
|
||||
";
|
||||
|
||||
/// A list line: type, start offset, first of its list.
|
||||
fn line(ty: &'static str, offset: i64, first: bool) -> Kv<'static> {
|
||||
let level = i64::from(ty != "PLAIN");
|
||||
o(vec![("lineType", e(ty)), ("indentationLevel", Kv::Int(level)), ("listStartOffset", Kv::Int(offset)), ("isFirstLineOfList", Kv::Bool(first))])
|
||||
}
|
||||
|
||||
fn text_node(id: u64, name: &str, chars: &str, lines: Vec<Kv<'static>>) -> Kv<'static> {
|
||||
let pos = ["!", "\"", "#", "$", "%", "&"][id as usize % 6];
|
||||
node(
|
||||
id,
|
||||
Some((1, pos)),
|
||||
"TEXT",
|
||||
name,
|
||||
vec![
|
||||
("size", xy(200.0, 60.0)),
|
||||
("transform", at(0.0, 0.0)),
|
||||
("fontSize", f(16.0)),
|
||||
("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn starts(doc: &figcraft_doc::Document, name: &str) -> Vec<(ListStyle, u32)> {
|
||||
let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") };
|
||||
(0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start_offset)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_lists_keep_their_start() {
|
||||
use ListStyle::{Bulleted as B, None as P, Numbered as N};
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "Document", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
|
||||
text_node(2, "From 1", "Start here", vec![line("ORDERED_LIST", 0, true)]),
|
||||
text_node(3, "From 2", "Next try this", vec![line("ORDERED_LIST", 1, true)]),
|
||||
text_node(4, "First only", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 0, false), line("ORDERED_LIST", 0, false)]),
|
||||
text_node(5, "Every line", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 3, false), line("ORDERED_LIST", 3, false)]),
|
||||
text_node(6, "Mixed", "x\na\nb", vec![line("PLAIN", 0, false), line("ORDERED_LIST", 4, true), line("UNORDERED_LIST", 2, true)]),
|
||||
text_node(7, "Junk", "a", vec![line("ORDERED_LIST", -5, true)]),
|
||||
];
|
||||
let sc = Schema::parse(LIST_SCHEMA).unwrap();
|
||||
let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(starts(&doc, "From 1"), [(N, 0)]);
|
||||
assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2.");
|
||||
assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]);
|
||||
assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]);
|
||||
assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 0)], "bullets have no number, so no start");
|
||||
assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_export_writes_where_a_list_starts() {
|
||||
let mut doc = figcraft_doc::Document::new("Lists");
|
||||
let page = doc.first_page().unwrap();
|
||||
use ListStyle::{Bulleted as B, None as P, Numbered as N};
|
||||
let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot");
|
||||
let pl = |list, start_offset| ParaList { list, indent: 0, start_offset };
|
||||
td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)];
|
||||
doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap();
|
||||
let (bytes, _) = super::super::export(&doc, None).unwrap();
|
||||
let a = Archive::open(&bytes).unwrap();
|
||||
let (schema, message) = decode_canvas(&a.canvas).unwrap();
|
||||
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||||
let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("List")).unwrap();
|
||||
let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap();
|
||||
let offsets: Vec<f64> = lines.iter().map(|l| l.get("listStartOffset").and_then(Kv::f64).unwrap()).collect();
|
||||
// Every line of a numbered list carries its first item's start (a reader that takes it per
|
||||
// line or per list counts the same); bullets and plain lines carry none.
|
||||
assert_eq!(offsets, [1.0, 1.0, 0.0, 5.0, 0.0]);
|
||||
let (back, _) = import(&bytes, "x").unwrap();
|
||||
assert_eq!(starts(&back, "List"), [(N, 1), (N, 1), (P, 0), (N, 5), (B, 0)]);
|
||||
}
|
||||
@@ -154,7 +154,7 @@ fn linked_underline_message() -> Kv<'static> {
|
||||
#[test]
|
||||
fn mapped_text_path_reaches_native_model_with_runs_and_no_standin_warning() {
|
||||
let message = message(true);
|
||||
let (json, warnings) = super::map::to_rest(&message, "Text path");
|
||||
let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES);
|
||||
assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}");
|
||||
let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap();
|
||||
assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}");
|
||||
@@ -171,7 +171,7 @@ fn mapped_text_path_reaches_native_model_with_runs_and_no_standin_warning() {
|
||||
#[test]
|
||||
fn public_rest_import_ignores_private_fig_text_path_field() {
|
||||
let message = message(true);
|
||||
let (json, _) = super::map::to_rest(&message, "Text path");
|
||||
let (json, _, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES);
|
||||
// The mapper's private field is accepted only by import_fig, never by public REST import.
|
||||
let path = json["document"]["children"][0]["children"][0]["_figTextPath"].clone();
|
||||
assert!(path.is_object());
|
||||
@@ -195,7 +195,7 @@ fn serialized_kiwi_canvas_import_decodes_start_and_builds_native_path() {
|
||||
#[test]
|
||||
fn malformed_text_path_falls_back_with_specific_warning() {
|
||||
let message = message(false);
|
||||
let (json, warnings) = super::map::to_rest(&message, "Text path");
|
||||
let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES);
|
||||
assert!(warnings.iter().any(|w| w.contains("text path has no start data") && w.contains("text imports off its path")), "{warnings:?}");
|
||||
let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap();
|
||||
let node = doc.nodes().find(|n| n.name == "broken path").unwrap();
|
||||
@@ -205,7 +205,7 @@ fn malformed_text_path_falls_back_with_specific_warning() {
|
||||
#[test]
|
||||
fn linked_text_style_decoration_warns_from_effective_mapped_style() {
|
||||
let message = linked_underline_message();
|
||||
let (json, warnings) = super::map::to_rest(&message, "Text path");
|
||||
let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES);
|
||||
assert_eq!(json["document"]["children"][0]["children"][0]["style"]["textDecoration"], "UNDERLINE");
|
||||
assert!(warnings.iter().any(|w| w == "text path underline or strikethrough decoration is not rendered"), "{warnings:?}");
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ fn raw(base: Option<Kv<'static>>, runs: Vec<Kv<'static>>, chars: &str, ids: Vec<
|
||||
}
|
||||
nodes.push(node(11, "TEXT", "Label", Some(2), layer));
|
||||
let message = object(vec![("nodeChanges", Kv::Array(nodes))]);
|
||||
super::map::to_rest(&message, "Synthetic skip ink").0
|
||||
super::map::to_rest(&message, "Synthetic skip ink", crate::rest::MAX_NODES).0
|
||||
}
|
||||
|
||||
fn imported(value: &Value) -> Document {
|
||||
@@ -162,7 +162,7 @@ fn instance_path_override_preserves_explicit_false_over_true_main() {
|
||||
instance,
|
||||
]),
|
||||
)]);
|
||||
let value = super::map::to_rest(&message, "Instance skip ink").0;
|
||||
let value = super::map::to_rest(&message, "Instance skip ink", crate::rest::MAX_NODES).0;
|
||||
let copy = value["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["name"] == "Copy").unwrap();
|
||||
let label = copy["children"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap();
|
||||
assert_eq!(label["style"]["textDecorationSkipInk"], false);
|
||||
|
||||
@@ -43,7 +43,7 @@ fn chars(text: &str) -> Kv<'static> {
|
||||
fn rest(nodes: Vec<Kv<'static>>) -> Value {
|
||||
let base = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])];
|
||||
let message = object(vec![("nodeChanges", Kv::Array(base.into_iter().chain(nodes).collect()))]);
|
||||
super::map::to_rest(&message, "Synthetic text").0
|
||||
super::map::to_rest(&message, "Synthetic text", crate::rest::MAX_NODES).0
|
||||
}
|
||||
|
||||
fn import(value: &Value) -> Document {
|
||||
|
||||
@@ -202,3 +202,20 @@ fn the_header_is_read_on_its_own() {
|
||||
let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap();
|
||||
assert_eq!(index, Some(["ab".to_string()].into_iter().collect()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_json_may_unpack_in_proportion_to_its_packed_size() {
|
||||
const MB: u64 = 1024 * 1024;
|
||||
// A small archive keeps the member cap: it can't unpack into gigabytes.
|
||||
assert_eq!(document_limit(0, 0), MAX_MEMBER);
|
||||
assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER);
|
||||
// A real 900 000-layer document saved by us packs 1 477 139 810 bytes into 244 362 923, and
|
||||
// didn't open again under the 512 MB cap.
|
||||
if cfg!(not(target_pointer_width = "32")) {
|
||||
assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810);
|
||||
}
|
||||
// The packed size an archive claims counts only up to the archive's own size.
|
||||
assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER);
|
||||
// Never more than the ceiling.
|
||||
assert_eq!(document_limit(u64::MAX, u64::MAX), MAX_DOCUMENT);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,15 @@ pub const FORMAT: &str = "figcraft";
|
||||
pub const VERSION: u32 = 1;
|
||||
/// Largest archive member we read (guards against zip bombs).
|
||||
const MAX_MEMBER: u64 = 512 * 1024 * 1024;
|
||||
/// `document.json` may unpack to [`DOCUMENT_RATIO`] times what it takes in the archive, within
|
||||
/// [`MAX_MEMBER`] and [`MAX_DOCUMENT`]: a large document saved by us (a 900 000-layer file packs
|
||||
/// 1.48 GB into 244 MB) opens again, while a small archive still can't unpack into gigabytes.
|
||||
/// 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is lower.
|
||||
const DOCUMENT_RATIO: u64 = 16;
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024;
|
||||
/// Most versions a file keeps: saving more is refused, extra entries in a file are skipped.
|
||||
pub const MAX_VERSIONS: usize = 100;
|
||||
/// Longest version name and description kept (chars).
|
||||
@@ -455,6 +464,11 @@ pub(crate) fn read_member<R: Read + Seek>(z: &mut zip::ZipArchive<R>, name: &str
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Largest `document.json` read from an archive of `len` bytes in which it takes `packed`.
|
||||
pub(crate) fn document_limit(packed: u64, len: u64) -> u64 {
|
||||
packed.min(len).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT)
|
||||
}
|
||||
|
||||
/// How many images, and how many bytes of them together, one file may give.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct ImageBudget {
|
||||
@@ -476,7 +490,9 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F
|
||||
return Ok(Loaded { issues, ..Loaded::from(doc) });
|
||||
}
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let text = String::from_utf8(read_member(&mut z, "document.json", MAX_MEMBER)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let packed = z.by_name("document.json").map(|f| f.compressed_size()).unwrap_or(0);
|
||||
let limit = document_limit(packed, bytes.len() as u64);
|
||||
let text = String::from_utf8(read_member(&mut z, "document.json", limit)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let (mut doc, mut issues, own_images) = parse_json(&text)?;
|
||||
let (images, unread) = read_images(&mut z, own_images.as_ref(), budget);
|
||||
report_lost_images(&mut issues, &unread, own_images.as_ref(), &images);
|
||||
|
||||
@@ -53,13 +53,13 @@ use crate::FormatError;
|
||||
use props::{MAX_COORD, MAX_SIZE, clip_str, flag, list, num, text};
|
||||
pub use variables::export as export_variables;
|
||||
|
||||
/// Most layers imported from one file.
|
||||
/// Most layers imported from one file. The `.fig` import sets its own, from the file's size.
|
||||
pub const MAX_NODES: usize = 200_000;
|
||||
/// Deepest layer nesting followed.
|
||||
const MAX_DEPTH: usize = 256;
|
||||
/// Private .fig provenance, consumed only by `import_fig`.
|
||||
/// Private .fig provenance, consumed only by the `.fig` import ([`import_with_networks`]).
|
||||
pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource";
|
||||
/// Hidden .fig library context; accepted only by [`import_fig`].
|
||||
/// Hidden .fig library context; accepted only by the `.fig` import ([`import_with_networks`]).
|
||||
pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts";
|
||||
/// Longest SVG path string read from one geometry entry.
|
||||
const MAX_PATH_BYTES: usize = 4 << 20;
|
||||
@@ -146,22 +146,43 @@ pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec<String>
|
||||
/// in the file JSON (a `meta` with `variableCollections`, or `variableCollections` and
|
||||
/// `variables` at the top level) are read too; the first definition of an id wins.
|
||||
pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) -> Result<(Document, Vec<String>), FormatError> {
|
||||
import_impl(v, variables, images, false)
|
||||
import_parts(v, variables, images, Vec::new(), MAX_NODES, false)
|
||||
}
|
||||
|
||||
/// Internal .fig JSON path: GUID provenance is not part of the public REST import contract.
|
||||
/// The `.fig` import itself goes through [`import_with_networks`].
|
||||
#[cfg(test)]
|
||||
pub(crate) fn import_fig(v: &Value, images: ImageResolver) -> Result<(Document, Vec<String>), FormatError> {
|
||||
import_impl(v, None, images, true)
|
||||
import_with_networks(v, images, Vec::new(), MAX_NODES)
|
||||
}
|
||||
|
||||
fn import_impl(v: &Value, variables: Option<&Value>, images: ImageResolver, fig_sources: bool) -> Result<(Document, Vec<String>), FormatError> {
|
||||
/// [`import_fig`] for the `.fig` import's JSON, whose vectors name their networks by
|
||||
/// `vectorNetworkRef`, an index in `networks` (built once each, and far smaller than as JSON).
|
||||
/// `max_nodes`: the layer cap, set by the `.fig` import in place of [`MAX_NODES`].
|
||||
pub(crate) fn import_with_networks(
|
||||
v: &Value,
|
||||
images: ImageResolver,
|
||||
networks: Vec<Option<VectorNetwork>>,
|
||||
max_nodes: usize,
|
||||
) -> Result<(Document, Vec<String>), FormatError> {
|
||||
import_parts(v, None, images, networks, max_nodes, true)
|
||||
}
|
||||
|
||||
fn import_parts(
|
||||
v: &Value,
|
||||
variables: Option<&Value>,
|
||||
images: ImageResolver,
|
||||
networks: Vec<Option<VectorNetwork>>,
|
||||
max_nodes: usize,
|
||||
fig_sources: bool,
|
||||
) -> Result<(Document, Vec<String>), FormatError> {
|
||||
let entries: Vec<&Value> = if is_document(v) { vec![v] } else { nodes_entries(v).collect() };
|
||||
if entries.is_empty() {
|
||||
return Err(FormatError::Corrupt("not a REST API file JSON (no `document`)".into()));
|
||||
}
|
||||
let name = text(v, "name").filter(|n| !n.trim().is_empty()).map_or("Imported file", clip_str);
|
||||
let mut cx = Ctx::new(Document::new(name), images);
|
||||
cx.fig_sources = fig_sources.then(|| fig_sources::Sources::new(MAX_NODES));
|
||||
cx.fig_sources = fig_sources.then(|| fig_sources::Sources::new(max_nodes));
|
||||
let blank = cx.doc.first_page();
|
||||
let mut loose: Option<NodeId> = None;
|
||||
for e in &entries {
|
||||
@@ -175,6 +196,8 @@ fn import_impl(v: &Value, variables: Option<&Value>, images: ImageResolver, fig_
|
||||
cx.warn("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored");
|
||||
}
|
||||
cx.style_definitions(v);
|
||||
cx.networks = networks;
|
||||
cx.max_nodes = max_nodes;
|
||||
for e in entries {
|
||||
let Some(root) = e.get("document") else { continue };
|
||||
match text(root, "type") {
|
||||
@@ -356,6 +379,10 @@ pub(crate) struct Ctx<'a> {
|
||||
/// Imported variables by id, and the mode ids of imported collections (default first).
|
||||
var_kinds: BTreeMap<String, VariableType>,
|
||||
var_modes: BTreeMap<String, Vec<String>>,
|
||||
/// Vector networks built by the `.fig` import, named by layers' `vectorNetworkRef`.
|
||||
networks: Vec<Option<VectorNetwork>>,
|
||||
/// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap.
|
||||
max_nodes: usize,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
@@ -380,6 +407,8 @@ impl<'a> Ctx<'a> {
|
||||
overlay_meta: BTreeMap::new(),
|
||||
var_kinds: BTreeMap::new(),
|
||||
var_modes: BTreeMap::new(),
|
||||
networks: Vec::new(),
|
||||
max_nodes: MAX_NODES,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,8 +464,9 @@ impl<'a> Ctx<'a> {
|
||||
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
|
||||
return;
|
||||
}
|
||||
if self.count >= MAX_NODES {
|
||||
self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped"));
|
||||
if self.count >= self.max_nodes {
|
||||
let max = self.max_nodes;
|
||||
self.warn(format!("the file has more than {max} layers; the rest were skipped"));
|
||||
return;
|
||||
}
|
||||
let Some(ty) = text(j, "type") else {
|
||||
@@ -642,14 +672,18 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our
|
||||
/// extension, written by the `.fig` import), else from its geometry. A stroke-only vector
|
||||
/// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape):
|
||||
/// returns whether it does, and the caller moves the paints with [`outline_stroke`] once the
|
||||
/// styles and variables of the stroke are read.
|
||||
/// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or
|
||||
/// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its
|
||||
/// geometry. A stroke-only vector uses the stroke outline, painted with the stroke paints
|
||||
/// (exact look, editable as a shape): returns whether it does, and the caller moves the paints
|
||||
/// with [`outline_stroke`] once the styles and variables of the stroke are read.
|
||||
fn vector(&mut self, n: &mut Node, j: &Value) -> bool {
|
||||
if let Some(net) = j.get("vectorNetwork") {
|
||||
match network(net) {
|
||||
let net = match j.get("vectorNetworkRef") {
|
||||
Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
|
||||
None => j.get("vectorNetwork").map(network),
|
||||
};
|
||||
if let Some(net) = net {
|
||||
match net {
|
||||
Some(network) => {
|
||||
n.kind = NodeKind::Vector { network };
|
||||
return false;
|
||||
@@ -1173,7 +1207,7 @@ fn local(parent: Affine, world: Affine) -> Affine {
|
||||
|
||||
/// Finite path data within the coordinate cap.
|
||||
/// A vector network in our own JSON shape, when it's within bounds and refers only to what it has.
|
||||
fn network(v: &Value) -> Option<VectorNetwork> {
|
||||
pub(crate) fn network(v: &Value) -> Option<VectorNetwork> {
|
||||
use serde::Deserialize;
|
||||
let mut net = VectorNetwork::deserialize(v).ok()?;
|
||||
let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum();
|
||||
|
||||
@@ -729,3 +729,18 @@ fn layer_count_is_capped() {
|
||||
assert_eq!(doc.node_count(), MAX_NODES + 1);
|
||||
assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fig_import_sets_its_own_layer_cap() {
|
||||
// The `.fig` import passes a cap from the file's size: above `MAX_NODES` every layer comes in.
|
||||
let kids: Vec<Value> = (0..MAX_NODES + 5).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect();
|
||||
let json = file(json!([page("Page", Value::Array(kids))]));
|
||||
let (doc, warnings) = import_with_networks(&json, &no_images, Vec::new(), MAX_NODES + 10).unwrap();
|
||||
assert_eq!(doc.node_count(), MAX_NODES + 6);
|
||||
assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}");
|
||||
// Below it, the import stops at the cap it was given.
|
||||
let few: Vec<Value> = (0..10).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect();
|
||||
let (doc, warnings) = import_with_networks(&file(json!([page("Page", Value::Array(few))])), &no_images, Vec::new(), 4).unwrap();
|
||||
assert_eq!(doc.node_count(), 5);
|
||||
assert!(warnings.iter().any(|w| w.contains("more than 4 layers")), "{warnings:?}");
|
||||
}
|
||||
|
||||
@@ -233,10 +233,12 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec<StyleRun> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Each paragraph's list type and nesting level from `lineTypes` and `lineIndentations`.
|
||||
/// Each paragraph's list type, nesting level and start number from `lineTypes`,
|
||||
/// `lineIndentations` and (from `.fig` files) `listStartOffsets`.
|
||||
fn lists(v: &Value, td: &mut TextData) {
|
||||
let types = list(v, "lineTypes");
|
||||
let indents = list(v, "lineIndentations");
|
||||
let starts = list(v, "listStartOffsets");
|
||||
td.paragraphs = types
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -247,6 +249,7 @@ fn lists(v: &Value, td: &mut TextData) {
|
||||
_ => ListStyle::None,
|
||||
},
|
||||
indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8),
|
||||
start_offset: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32),
|
||||
})
|
||||
.collect();
|
||||
// Clamp the levels and fold one style for all back into the layer's list style.
|
||||
|
||||
@@ -250,8 +250,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"save_file",
|
||||
"Save file",
|
||||
"Save the active document as a .figcraft file. `path` is required the first time (an untitled document has no path); later saves reuse it.",
|
||||
obj(json!({"path": string("Output .figcraft path")}), &[]),
|
||||
"Save the active document as a .figcraft file. `path` is required the first time (an untitled document has no path); later saves reuse it. A `path` ending in .fig writes an experimental .fig export copy instead and leaves the document's own path and unsaved changes alone.",
|
||||
obj(json!({"path": string("Output .figcraft path, or a .fig path for an experimental export copy")}), &[]),
|
||||
false,
|
||||
),
|
||||
tool("undo", "Undo", "Undo the last change (one command, batch or gesture). Returns {undone: bool}.", empty(), false),
|
||||
|
||||
@@ -258,11 +258,16 @@ pub(crate) fn kjoin(j: StrokeJoin) -> Join {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain centred stroke of `path` at `width`.
|
||||
/// The widest stroke outlined: past this the rasteriser's fixed-point maths overflows and panics,
|
||||
/// and nothing visible changes (a stroke this wide covers any canvas already).
|
||||
pub const MAX_STROKE_WIDTH: f64 = 1e6;
|
||||
|
||||
/// Plain centred stroke of `path` at `width` (capped at [`MAX_STROKE_WIDTH`]).
|
||||
pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath {
|
||||
if !(width.is_finite() && width > 0.0 && finite(path)) {
|
||||
return BezPath::new();
|
||||
}
|
||||
let width = width.min(MAX_STROKE_WIDTH);
|
||||
let mut style = kurbo::Stroke::new(width)
|
||||
.with_join(kjoin(props.join))
|
||||
.with_miter_limit(props.miter_limit.clamp(1.0, 100.0))
|
||||
@@ -284,10 +289,17 @@ pub fn stroke_outline(path: &BezPath, props: &StrokeProps, closed_shape: bool) -
|
||||
/// [`stroke_outline`] of a shape whose stroke follows vector network `net` (`path` is its stroke
|
||||
/// path): points with a cap or join of their own draw them there.
|
||||
pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool, net: Option<&VectorNetwork>) -> BezPath {
|
||||
let w = props.weight;
|
||||
if !(w.is_finite() && w > 0.0) {
|
||||
if !(props.weight.is_finite() && props.weight > 0.0) {
|
||||
return BezPath::new();
|
||||
}
|
||||
let capped;
|
||||
let props = if props.weight > MAX_STROKE_WIDTH {
|
||||
capped = StrokeProps { weight: MAX_STROKE_WIDTH, ..props.clone() };
|
||||
&capped
|
||||
} else {
|
||||
props
|
||||
};
|
||||
let w = props.weight;
|
||||
let styled = net.and_then(|n| points::Styled::new(n, props));
|
||||
// Centred at `width` (twice the weight for Inside/Outside, which clip half of it away).
|
||||
let centred = |width: f64| match (&styled, &props.brush, props.profile()) {
|
||||
@@ -525,6 +537,50 @@ mod tests {
|
||||
assert!(arrowhead(&BezPath::new(), StrokeCap::LineArrow, 1.0, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_point_joins_keep_the_layer_stroke_and_differing_ones_apply() {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((10.0, 10.0));
|
||||
path.line_to((20.0, 0.0));
|
||||
let net = VectorNetwork {
|
||||
vertices: vec![
|
||||
figcraft_doc::Vertex { cap: Some(StrokeCap::Square), ..figcraft_doc::Vertex::new(0.0, 0.0) },
|
||||
figcraft_doc::Vertex { x: 10.0, y: 10.0, radius: 2.0, join: Some(StrokeJoin::Miter), ..figcraft_doc::Vertex::new(10.0, 10.0) },
|
||||
figcraft_doc::Vertex { cap: Some(StrokeCap::Square), ..figcraft_doc::Vertex::new(20.0, 0.0) },
|
||||
],
|
||||
segments: vec![figcraft_doc::Segment::line(0, 1), figcraft_doc::Segment::line(1, 2)],
|
||||
..Default::default()
|
||||
};
|
||||
let props = StrokeProps { weight: 4.0, ..Default::default() };
|
||||
let inherited = stroke_outline_of(&path, &props, false, Some(&net));
|
||||
let mut without_join = net.clone();
|
||||
without_join.vertices[1].join = None;
|
||||
let baseline = stroke_outline_of(&path, &props, false, Some(&without_join));
|
||||
assert_eq!(inherited, baseline);
|
||||
|
||||
let different_layer = StrokeProps { join: StrokeJoin::Bevel, ..props };
|
||||
let explicit = stroke_outline_of(&path, &different_layer, false, Some(&net));
|
||||
let layer_only = stroke_outline_of(&path, &different_layer, false, Some(&without_join));
|
||||
assert_ne!(explicit, layer_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_joins_do_not_break_closed_stroke_runs() {
|
||||
let path = sq(0.0, 0.0, 20.0);
|
||||
let mut net = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)], true);
|
||||
for vertex in &mut net.vertices {
|
||||
vertex.join = Some(StrokeJoin::Round);
|
||||
}
|
||||
let props = StrokeProps { join: StrokeJoin::Round, weight: 4.0, ..Default::default() };
|
||||
let styled = stroke_outline_of(&path, &props, true, Some(&net));
|
||||
for vertex in &mut net.vertices {
|
||||
vertex.join = None;
|
||||
}
|
||||
let inherited = stroke_outline_of(&path, &props, true, Some(&net));
|
||||
assert_eq!(styled, inherited);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes() {
|
||||
let mut line = BezPath::new();
|
||||
|
||||
@@ -18,11 +18,17 @@ pub(crate) struct Styled {
|
||||
impl Styled {
|
||||
/// `None` when no point has a style of its own, or the stroke is dashed, variable or a brush.
|
||||
/// shortcut: dashed, variable-width and brush strokes keep the layer's caps and joins at every point; stroke them run by run too if asked for.
|
||||
/// Joins equal to the layer do not require styled runs; stored overrides remain unchanged.
|
||||
pub(crate) fn new(net: &VectorNetwork, props: &StrokeProps) -> Option<Styled> {
|
||||
if !net.has_point_styles() || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() {
|
||||
if !net.has_point_styles(props.join) || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() {
|
||||
return None;
|
||||
}
|
||||
let net = net.rounded().unwrap_or_else(|| net.clone());
|
||||
let mut net = net.rounded().unwrap_or_else(|| net.clone());
|
||||
for vertex in &mut net.vertices {
|
||||
if vertex.join == Some(props.join) {
|
||||
vertex.join = None;
|
||||
}
|
||||
}
|
||||
let runs = net.stroke_runs();
|
||||
let mut degrees = vec![0usize; net.vertices.len()];
|
||||
for s in &net.segments {
|
||||
|
||||
@@ -155,7 +155,7 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
props.dashes.len().hash(&mut h);
|
||||
// Points' own caps and joins (vector networks).
|
||||
let net = match &n.kind {
|
||||
NodeKind::Vector { network } if network.has_point_styles() => Some(network),
|
||||
NodeKind::Vector { network } if network.has_point_styles(props.join) => Some(network),
|
||||
_ => None,
|
||||
};
|
||||
for v in net.map(|n| n.vertices.as_slice()).unwrap_or_default() {
|
||||
|
||||
@@ -638,7 +638,7 @@ impl W<'_> {
|
||||
|| k.brush.is_some()
|
||||
|| k.cap_start.is_arrow()
|
||||
|| k.cap_end.is_arrow()
|
||||
|| matches!(&n.kind, NodeKind::Vector { network } if network.has_point_styles())
|
||||
|| matches!(&n.kind, NodeKind::Vector { network } if network.has_point_styles(k.join))
|
||||
|| !(w.is_finite() && w > 0.0)
|
||||
{
|
||||
return None;
|
||||
|
||||
@@ -115,3 +115,28 @@ fn text_run_fill_survives_live_text_export() {
|
||||
assert!(svg.contains("#000000"), "{svg}");
|
||||
assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b</tspan>") && svg.contains(">a</tspan>"), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() {
|
||||
use figcraft_doc::{StrokeJoin, VectorNetwork};
|
||||
use figcraft_geom::kurbo::Point;
|
||||
let svg_with = |join: StrokeJoin| {
|
||||
let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 50.0), Point::new(100.0, 0.0)], false);
|
||||
for v in &mut network.vertices {
|
||||
v.join = Some(StrokeJoin::Miter);
|
||||
}
|
||||
if let Some(v) = network.vertices.get_mut(1) {
|
||||
v.join = Some(join);
|
||||
}
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let v = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 50.0).unwrap();
|
||||
let n = d.get_mut(v).unwrap();
|
||||
n.strokes = vec![Paint::solid(Color::from_hex("000000").unwrap())];
|
||||
n.stroke.weight = 2.0;
|
||||
n.stroke.join = StrokeJoin::Miter;
|
||||
export_svg(&d, &[v], &SvgOptions::default()).unwrap()
|
||||
};
|
||||
assert!(svg_with(StrokeJoin::Miter).contains("stroke-width=\"2\""), "equal joins stay a stroke");
|
||||
assert!(!svg_with(StrokeJoin::Round).contains("stroke-width="), "a differing join is drawn as an outline");
|
||||
}
|
||||
|
||||
@@ -827,8 +827,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
}
|
||||
|
||||
/// The markers of a text's paragraphs ("" for plain ones). Numbers count the items of one list at
|
||||
/// one level, restarting after a plain paragraph, a shallower item or the other kind of list;
|
||||
/// levels go 1. a. i. and • ◦ ▪, then round again.
|
||||
/// one level, restarting after a plain paragraph, a shallower item or the other kind of list,
|
||||
/// from the first item's start number; levels go 1. a. i. and • ◦ ▪, then round again.
|
||||
pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
|
||||
const BULLETS: [&str; 3] = ["\u{2022}", "\u{25E6}", "\u{25AA}"];
|
||||
let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1];
|
||||
@@ -844,7 +844,8 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
|
||||
*c = (ListStyle::None, 0);
|
||||
}
|
||||
let Some(c) = count.get_mut(d) else { return String::new() };
|
||||
*c = if c.0 == pl.list { (pl.list, c.1 + 1) } else { (pl.list, 1) };
|
||||
let first = usize::try_from(pl.start_offset).unwrap_or(usize::MAX).saturating_add(1);
|
||||
*c = if c.0 == pl.list { (pl.list, c.1.saturating_add(1)) } else { (pl.list, first) };
|
||||
match (pl.list, d % 3) {
|
||||
(ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)),
|
||||
(ListStyle::Numbered, 2) => format!("{}.", roman(c.1)),
|
||||
|
||||
@@ -43,7 +43,32 @@ fn starts(l: &TextLayout) -> Vec<(f64, Option<f64>)> {
|
||||
}
|
||||
|
||||
fn pl(list: ListStyle, indent: u8) -> ParaList {
|
||||
ParaList { list, indent }
|
||||
ParaList { list, indent, start_offset: 0 }
|
||||
}
|
||||
|
||||
fn from(list: ListStyle, indent: u8, start_offset: u32) -> ParaList {
|
||||
ParaList { list, indent, start_offset }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_lists_count_from_their_start() {
|
||||
use ListStyle::{Bulleted as B, None as P, Numbered as N};
|
||||
let cases: Vec<(Vec<ParaList>, Vec<&str>, &str)> = vec![
|
||||
(vec![from(N, 0, 1)], vec!["2."], "a one-item list started at 2 (#686)"),
|
||||
(vec![from(N, 0, 3), pl(N, 0), pl(N, 0)], vec!["4.", "5.", "6."], "the items after the first count on from it"),
|
||||
(vec![from(N, 0, 3), from(N, 0, 3), from(N, 0, 3)], vec!["4.", "5.", "6."], "items that go on counting ignore their own start"),
|
||||
(vec![from(N, 0, 1), pl(P, 0), from(N, 0, 1)], vec!["2.", "", "2."], "a list after a plain paragraph starts from its own start"),
|
||||
(vec![pl(N, 0), from(N, 1, 1), pl(N, 1)], vec!["1.", "b.", "c."], "a nested level starts from its first item's start"),
|
||||
(vec![from(B, 0, 4), pl(B, 0)], vec!["\u{2022}", "\u{2022}"], "bullets have no number"),
|
||||
(vec![from(N, 0, 4_000), pl(N, 2)], vec!["4001.", "i."], "big starts"),
|
||||
];
|
||||
for (lists, want, why) in cases {
|
||||
assert_eq!(list_markers(&lists), want, "{why}");
|
||||
}
|
||||
// The largest start never overflows, on 32-bit targets too.
|
||||
let max = list_markers(&[from(N, 0, u32::MAX), pl(N, 0), pl(N, 0)]);
|
||||
assert_eq!(max.len(), 3);
|
||||
assert!(max.iter().all(|m| m.starts_with("429496729")), "{max:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,6 +13,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commity: {commits}, +{added} / −{deleted} řádků (Δ {delta}), +{bin_added} / −{bin_deleted} binárních souborů, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekce tohoto souboru s těmito režimy a proměnnými: přepište jejich hodnoty pro jednotlivé režimy a přidejte vlastní proměnné"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Soubor .figcraft s tímto názvem už existuje. Nahrazení potvrdíte výběrem jeho úplného názvu."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Samostatná vrstva se zarovná ke svému rámci: vyberte více vrstev nebo jednu v rámci"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Responzivní produktová stránka: úvodní blok, funkce, čísla, citace a zápatí"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Pružina se pohybuje až do ustálení: délku určuje její hmotnost, tuhost a tlumení"
|
||||
@@ -266,6 +267,7 @@ status = "draft — needs native review"
|
||||
"Commits" = "Commity"
|
||||
"Company" = "Společnost"
|
||||
"Compare changes" = "Porovnat změny"
|
||||
"Compatibility notes" = "Poznámky ke kompatibilitě"
|
||||
"Completed" = "Dokončeno"
|
||||
"Component" = "Komponenta"
|
||||
"Component name" = "Název komponenty"
|
||||
@@ -472,6 +474,7 @@ status = "draft — needs native review"
|
||||
"False" = "Nepravda"
|
||||
"Feb" = "února"
|
||||
"Fetching from the server…" = "Stahování ze serveru…"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft zatím nepodporuje všechny funkce těchto souborů. Poznámky uvádějí, co bylo v importované kopii vynecháno nebo změněno; původní soubor zůstává beze změny."
|
||||
"File" = "Soubor"
|
||||
"File name" = "Název souboru"
|
||||
"File not found" = "Soubor nenalezen"
|
||||
@@ -595,11 +598,14 @@ status = "draft — needs native review"
|
||||
"If {condition}" = "Pokud {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Ignorovat automatické rozvržení (absolutní poloha)"
|
||||
"Image" = "Obrázek"
|
||||
"Images" = "Obrázky"
|
||||
"Import" = "Import"
|
||||
"Import path" = "Cesta importu"
|
||||
"Import plugin from manifest" = "Importovat plugin z manifestu"
|
||||
"Import plugin from manifest…" = "Importovat plugin z manifestu…"
|
||||
"Import report" = "Zpráva o importu"
|
||||
"Import variables" = "Importovat proměnné"
|
||||
"Imported document" = "Importovaný dokument"
|
||||
"Imported {name}" = "Importováno: {name}"
|
||||
"Imported {n} new variables, updated {updated}" = { one = "Importována {n} nová proměnná, aktualizováno: {updated}", few = "Importovány {n} nové proměnné, aktualizováno: {updated}", other = "Importováno {n} nových proměnných, aktualizováno: {updated}" }
|
||||
"Import…" = "Importovat…"
|
||||
@@ -801,6 +807,7 @@ status = "draft — needs native review"
|
||||
"No colour variables fit here." = "Žádné vhodné barevné proměnné."
|
||||
"No colour variables yet. Create them in Local variables." = "Zatím žádné barevné proměnné. Vytvořte je v Místních proměnných."
|
||||
"No comments yet" = "Zatím žádné komentáře"
|
||||
"No compatibility warnings reported" = "Nebyla hlášena žádná upozornění na kompatibilitu"
|
||||
"No components yet. Select layers and press {keys}." = "Zatím žádné komponenty. Vyberte vrstvy a stiskněte {keys}."
|
||||
"No conflicts." = "Žádné konflikty."
|
||||
"No contributor data was built into this copy." = "Toto sestavení neobsahuje údaje o přispěvatelích."
|
||||
@@ -892,6 +899,7 @@ status = "draft — needs native review"
|
||||
"Open overlay" = "Otevřít překryv"
|
||||
"Open prototype preview" = "Otevřít náhled prototypu"
|
||||
"Open variables ({count})" = "Otevřít proměnné ({count})"
|
||||
"Opened as a new document" = "Otevřeno jako nový dokument"
|
||||
"Opened “{name}” in a new tab" = "Soubor „{name}“ otevřen v nové kartě"
|
||||
"Open|comments" = "Otevřené"
|
||||
"Options" = "Možnosti"
|
||||
@@ -916,6 +924,8 @@ status = "draft — needs native review"
|
||||
"Padding" = "Vnitřní odsazení"
|
||||
"Page" = "Stránka"
|
||||
"Pages" = "Stránky"
|
||||
"Pages added to the document" = "Stránky přidány do dokumentu"
|
||||
"Placed on the current page" = "Umístěno na aktuální stránku"
|
||||
"Pages: {pages}" = "Stránky: {pages}"
|
||||
"Paint bucket" = "Plechovka barvy"
|
||||
"Pan the canvas" = "Posunout plátno"
|
||||
@@ -930,6 +940,7 @@ status = "draft — needs native review"
|
||||
"Paste a team library link (a view link)" = "Vložte odkaz na týmovou knihovnu (odkaz pro prohlížení)"
|
||||
"Paste here" = "Vložit sem"
|
||||
"Paste to replace" = "Vložit a nahradit"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { one = "Vloženy vrstvy návrhu; vynechán {n} druh obsahu", few = "Vloženy vrstvy návrhu; vynechány {n} druhy obsahu", other = "Vloženy vrstvy návrhu; vynecháno {n} druhů obsahu" }
|
||||
"Pattern" = "Vzor"
|
||||
"Pattern alignment" = "Zarovnání vzoru"
|
||||
"Pattern scale" = "Měřítko vzoru"
|
||||
@@ -1393,6 +1404,7 @@ status = "draft — needs native review"
|
||||
"Use as mask" = "Použít jako masku"
|
||||
"Use auto layout" = "Použít automatické rozvržení"
|
||||
"Use number keys for opacity" = "Nastavovat krytí číselnými klávesami"
|
||||
"Use the .figcraft or .fig extension." = "Použijte příponu .figcraft nebo .fig."
|
||||
"Use the layer's fill" = "Použít výplň vrstvy"
|
||||
"Used in {files} · {instances}" = "Použití – {files} · {instances}"
|
||||
"Username" = "Uživatelské jméno"
|
||||
@@ -1485,26 +1497,89 @@ status = "draft — needs native review"
|
||||
"call any command its access allows" = "volat libovolný příkaz povolený jeho oprávněními"
|
||||
"call only: {commands}" = "volat pouze: {commands}"
|
||||
"can edit the document" = "může upravovat dokument"
|
||||
"conditional prototype actions nested too deeply were skipped" = "podmíněné akce prototypu vnořené příliš hluboko byly přeskočeny"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "podmíněné akce prototypu s podmínkami, které nelze vyhodnotit, byly přeskočeny"
|
||||
"connector labels were skipped" = "popisky spojnic byly přeskočeny"
|
||||
"connectors whose ends can't be placed are empty" = "spojnice, jejichž konce nelze umístit, jsou prázdné"
|
||||
"crop marks" = "ořezové značky"
|
||||
"damaged vector geometry was skipped" = "poškozená vektorová geometrie byla přeskočena"
|
||||
"damaged vector networks import as outlines" = "poškozené vektorové sítě se importují jako obrysy"
|
||||
"development: {path}" = "vývoj: {path}"
|
||||
"document children other than pages were skipped" = "potomci dokumentu, kteří nejsou stránkami, byli přeskočeni"
|
||||
"e.g. count + 1" = "např. count + 1"
|
||||
"e.g. count >= 3 and not done" = "např. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "lomené a zakřivené spojnice bez uložené cesty se importují jako přímky"
|
||||
"entries without a node type were skipped" = "položky bez typu uzlu byly přeskočeny"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Odkazy figcraft:// z prohlížeče, chatu nebo e-mailu otevřou tuto aplikaci a připojí k relaci."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "vyplněné oblasti vektoru s vlastní výplní používají výplň vrstvy"
|
||||
"from {server}" = "ze serveru {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "spouštěče herního ovladače bez právě jednoho známého tlačítka ovladače byly přeskočeny"
|
||||
"hidden" = "skryté"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "vlastnosti výměny instance, jejichž výchozí komponenta není v souboru, byly přeskočeny"
|
||||
"instances inside their own main component were imported as frames" = "instance uvnitř vlastní hlavní komponenty byly importovány jako rámce"
|
||||
"instances of components that aren't in the file show no layers" = "instance komponent, které nejsou v souboru, nezobrazují žádné vrstvy"
|
||||
"instances of components that aren't in the file were imported as frames" = "instance komponent, které nejsou v souboru, byly importovány jako rámce"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instance, které by vnořily komponentu samu do sebe, byly importovány jako rámce"
|
||||
"instances without a main component were imported as empty frames" = "instance bez hlavní komponenty byly importovány jako prázdné rámce"
|
||||
"invalid or oversized vector networks import as outlines" = "neplatné nebo příliš velké vektorové sítě se importují jako obrysy"
|
||||
"invalid or oversized vector path data was skipped" = "neplatná nebo příliš velká data vektorových cest byla přeskočena"
|
||||
"just now" = "právě teď"
|
||||
"key triggers without a supported key were skipped" = "spouštěče kláves bez podporované klávesy byly přeskočeny"
|
||||
"key up triggers import as key presses" = "spouštěče uvolnění klávesy se importují jako stisky klávesy"
|
||||
"layers without a position were placed at their parent's origin" = "vrstvy bez pozice byly umístěny do počátku nadřazeného prvku"
|
||||
"layers without a type were skipped" = "vrstvy bez typu byly přeskočeny"
|
||||
"locked" = "zamknuté"
|
||||
"no bleed" = "bez spadávky"
|
||||
"nobody else here yet" = "zatím tu není nikdo další"
|
||||
"now" = "nyní"
|
||||
"or" = "nebo"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "spojení tahu v bodech byla zachována; přerušované tahy a tahy s proměnnou šířkou se kreslí se spojením vrstvy"
|
||||
"prototype actions of unknown types were skipped" = "akce prototypu neznámých typů byly přeskočeny"
|
||||
"prototype actions without a destination in the file were skipped" = "akce prototypu bez cíle v souboru byly přeskočeny"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "výrazy prototypu s proměnnými, které nejsou v JSON proměnných, byly přeskočeny"
|
||||
"prototype expressions with unsupported functions were skipped" = "výrazy prototypu s nepodporovanými funkcemi byly přeskočeny"
|
||||
"prototype interactions without supported actions were skipped" = "interakce prototypu bez podporovaných akcí byly přeskočeny"
|
||||
"prototype navigations of unknown types were skipped" = "navigace prototypu neznámých typů byly přeskočeny"
|
||||
"prototype transitions of unknown types play instantly" = "přechody prototypu neznámých typů proběhnou okamžitě"
|
||||
"prototype triggers of unknown types were skipped" = "spouštěče prototypu neznámých typů byly přeskočeny"
|
||||
"put a widget on the canvas" = "umístit widget na plátno"
|
||||
"read and edit the document" = "číst a upravovat dokument"
|
||||
"read the document" = "číst dokument"
|
||||
"reads the document" = "čte dokument"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "akce nastavení proměnné u proměnných, které nejsou v JSON proměnných, byly přeskočeny"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "akce nastavení proměnné s hodnotami, které nelze vyhodnotit, byly přeskočeny"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "akce nastavení proměnné s hodnotami, které neodpovídají proměnné, byly přeskočeny"
|
||||
"set-variable actions without a value were skipped" = "akce nastavení proměnné bez hodnoty byly přeskočeny"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "akce nastavení režimu proměnných u kolekcí nebo režimů, které nejsou v JSON proměnných, byly přeskočeny"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "některé vektorové body odkazují na nastavení bodů, která v souboru chybí; používají spojení vrstvy a nezaoblené rohy"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "hvězdy a mnohoúhelníky bez počtu bodů nebo geometrie používají výchozí počet bodů"
|
||||
"text style overrides refer to missing table entries" = "přepsání textových stylů odkazují na chybějící položky tabulky"
|
||||
"the file has no pages" = "soubor nemá žádné stránky"
|
||||
"the plugin" = "plugin"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "JSON proměnných nemá `variableCollections` (očekávala se odpověď s místními proměnnými) a byl ignorován"
|
||||
"this session only" = "pouze v této relaci"
|
||||
"unknown component property types were skipped" = "neznámé typy vlastností komponent byly přeskočeny"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "aliasy na proměnné, které nejsou v JSON proměnných (nebo jsou jiného typu), byly přeskočeny"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "vazby proměnných na vlastnosti, které vrstva nemá, byly přeskočeny"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "vazby na proměnné, které nejsou v JSON proměnných (proměnné knihovny), byly přeskočeny"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "vazby proměnných byly přeskočeny: JSON souboru nemá definice proměnných (předejte JSON místních proměnných jako `variables` nebo vložte jeho `meta` vedle JSON souboru)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "vazby proměnných, jejichž typ neodpovídá vlastnosti, byly přeskočeny"
|
||||
"variable collections without modes were skipped" = "kolekce proměnných bez režimů byly přeskočeny"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "vyhledání režimu proměnné ve výrazech prototypu, které nejsou proměnnou a jedním z jejích režimů, byla přeskočena"
|
||||
"variable modes of collections that aren't in the file were skipped" = "režimy proměnných kolekcí, které nejsou v souboru, byly přeskočeny"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "režimy proměnných kolekcí, které nejsou v JSON proměnných, byly přeskočeny"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "hodnoty proměnných, které neodpovídají svému typu (nebo složené barvy), byly přeskočeny"
|
||||
"variables of unknown types were skipped" = "proměnné neznámých typů byly přeskočeny"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "proměnné, jejichž kolekce není v JSON proměnných, byly přeskočeny"
|
||||
"variables without readable values were skipped" = "proměnné bez čitelných hodnot byly přeskočeny"
|
||||
"vector points with unknown stroke joins use the layer join" = "vektorové body s neznámým spojením tahu používají spojení vrstvy"
|
||||
"vectors without geometry are empty" = "vektory bez geometrie jsou prázdné"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vektory bez geometrie jsou prázdné (exportujte JSON souboru s geometry=paths)"
|
||||
"very long text was cut to 1,048,576 characters" = "velmi dlouhý text byl zkrácen na 1 048 576 znaků"
|
||||
"very long vector paths were skipped" = "velmi dlouhé vektorové cesty byly přeskočeny"
|
||||
"video actions are not supported and were skipped" = "akce videa nejsou podporovány a byly přeskočeny"
|
||||
"video triggers are not supported and were skipped" = "spouštěče videa nejsou podporovány a byly přeskočeny"
|
||||
"yesterday" = "včera"
|
||||
"{instances} in {files}" = "{instances} · {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: návrh překladu, čeká na kontrolu rodilým mluvčím."
|
||||
@@ -1549,6 +1624,7 @@ status = "draft — needs native review"
|
||||
"{n} matches" = { one = "{n} shoda", few = "{n} shody", other = "{n} shod" }
|
||||
"{n} minutes ago" = { one = "Před {n} minutou", few = "Před {n} minutami", other = "Před {n} minutami" }
|
||||
"{n} months ago" = { one = "Před {n} měsícem", few = "Před {n} měsíci", other = "Před {n} měsíci" }
|
||||
"{n} more notes omitted from this report" = { one = "{n} další poznámka v této zprávě vynechána", few = "{n} další poznámky v této zprávě vynechány", other = "{n} dalších poznámek v této zprávě vynecháno" }
|
||||
"{n} of {total}" = "{n} z {total}"
|
||||
"{n} people joined" = { one = "Připojila se {n} osoba", few = "Připojily se {n} osoby", other = "Připojilo se {n} osob" }
|
||||
"{n} preferred components" = { one = "{n} preferovaná komponenta", few = "{n} preferované komponenty", other = "{n} preferovaných komponent" }
|
||||
@@ -1708,6 +1784,7 @@ status = "draft — needs native review"
|
||||
"edit.duplicate" = "Duplikovat"
|
||||
"edit.findReplace" = "Najít a nahradit"
|
||||
"edit.paste" = "Vložit"
|
||||
"edit.pasteDesignLayers" = "Vložit vrstvy návrhu"
|
||||
"edit.pasteOver" = "Vložit přes výběr"
|
||||
"edit.pasteProperties" = "Vložit vlastnosti"
|
||||
"edit.pasteReplace" = "Vložit a nahradit"
|
||||
|
||||
@@ -17,6 +17,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{bin_added} / −{bin_deleted} Binärdateien, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Eine Sammlung dieser Datei mit diesen Modi und Variablen: Überschreiben Sie ihre Werte je Modus und fügen Sie eigene Variablen hinzu"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Eine .figcraft-Datei mit diesem Namen existiert bereits. Wählen Sie ihren vollständigen Namen, um das Ersetzen zu bestätigen."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Eine einzelne Ebene richtet sich an ihrem Rahmen aus: Wählen Sie weitere Ebenen oder eine Ebene in einem Rahmen"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Eine Feder läuft, bis sie zur Ruhe kommt: Masse, Steifigkeit und Dämpfung bestimmen die Dauer"
|
||||
"About" = "Über"
|
||||
@@ -261,6 +262,7 @@ status = "draft — needs native review"
|
||||
"Commits" = "Commits"
|
||||
"Company" = "Unternehmen"
|
||||
"Compare changes" = "Änderungen vergleichen"
|
||||
"Compatibility notes" = "Hinweise zur Kompatibilität"
|
||||
"Completed" = "Abgeschlossen"
|
||||
"Component" = "Komponente"
|
||||
"Component name" = "Komponentenname"
|
||||
@@ -464,6 +466,7 @@ status = "draft — needs native review"
|
||||
"False" = "Falsch"
|
||||
"Fetching from the server…" = "Wird vom Server abgerufen…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft konnte sein Fenster nicht öffnen: {error}"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft unterstützt noch nicht alle Funktionen dieser Dateien. Die Hinweise nennen, was in der importierten Kopie weggelassen oder geändert wurde; die Originaldatei bleibt unverändert."
|
||||
"File" = "Datei"
|
||||
"File name" = "Dateiname"
|
||||
"File published" = "Datei veröffentlicht"
|
||||
@@ -580,11 +583,14 @@ status = "draft — needs native review"
|
||||
"If {condition}" = "Wenn {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Auto-Layout ignorieren (absolute Position)"
|
||||
"Image" = "Bild"
|
||||
"Images" = "Bilder"
|
||||
"Import" = "Importieren"
|
||||
"Import path" = "Importpfad"
|
||||
"Import plugin from manifest" = "Plugin aus Manifest importieren"
|
||||
"Import plugin from manifest…" = "Plugin aus Manifest importieren…"
|
||||
"Import report" = "Importbericht"
|
||||
"Import variables" = "Variablen importieren"
|
||||
"Imported document" = "Importiertes Dokument"
|
||||
"Imported {name}" = "{name} importiert"
|
||||
"Imported {n} new variables, updated {updated}" = { one = "{n} neue Variable importiert, {updated} aktualisiert", other = "{n} neue Variablen importiert, {updated} aktualisiert" }
|
||||
"Import…" = "Importieren…"
|
||||
@@ -774,6 +780,7 @@ status = "draft — needs native review"
|
||||
"No colour variables fit here." = "Hier passen keine Farbvariablen."
|
||||
"No colour variables yet. Create them in Local variables." = "Noch keine Farbvariablen. Erstellen Sie sie unter Lokale Variablen."
|
||||
"No comments yet" = "Noch keine Kommentare"
|
||||
"No compatibility warnings reported" = "Keine Kompatibilitätswarnungen gemeldet"
|
||||
"No components yet. Select layers and press {keys}." = "Noch keine Komponenten. Wählen Sie Ebenen aus und drücken Sie {keys}."
|
||||
"No conflicts." = "Keine Konflikte."
|
||||
"No contributor data was built into this copy." = "Diese Kopie enthält keine Daten zu Mitwirkenden."
|
||||
@@ -860,6 +867,7 @@ status = "draft — needs native review"
|
||||
"Open overlay" = "Overlay öffnen"
|
||||
"Open prototype preview" = "Prototyp-Vorschau öffnen"
|
||||
"Open variables ({count})" = "Variablen öffnen ({count})"
|
||||
"Opened as a new document" = "Als neues Dokument geöffnet"
|
||||
"Opened “{name}” in a new tab" = "„{name}“ in neuem Tab geöffnet"
|
||||
"Opening the file…" = "Datei wird geöffnet…"
|
||||
"Opening {n} files as new files" = { one = "Wird als neue Datei geöffnet", other = "{n} Dateien werden als neue Dateien geöffnet" }
|
||||
@@ -885,6 +893,8 @@ status = "draft — needs native review"
|
||||
"Padding" = "Innenabstand"
|
||||
"Page" = "Seite"
|
||||
"Pages" = "Seiten"
|
||||
"Pages added to the document" = "Seiten zum Dokument hinzugefügt"
|
||||
"Placed on the current page" = "Auf der aktuellen Seite platziert"
|
||||
"Pages: {pages}" = "Seiten: {pages}"
|
||||
"Paint bucket" = "Farbeimer"
|
||||
"Pan the canvas" = "Arbeitsfläche verschieben"
|
||||
@@ -899,6 +909,7 @@ status = "draft — needs native review"
|
||||
"Paste a team library link (a view link)" = "Link einer Teambibliothek einfügen (ein Ansichtslink)"
|
||||
"Paste here" = "Hier einfügen"
|
||||
"Paste to replace" = "Zum Ersetzen einfügen"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { one = "Designebenen eingefügt; {n} Art von Inhalt wurde ausgelassen", other = "Designebenen eingefügt; {n} Arten von Inhalt wurden ausgelassen" }
|
||||
"Pattern" = "Muster"
|
||||
"Pattern alignment" = "Musterausrichtung"
|
||||
"Pattern scale" = "Musterskalierung"
|
||||
@@ -1347,6 +1358,7 @@ status = "draft — needs native review"
|
||||
"Use as mask" = "Als Maske verwenden"
|
||||
"Use auto layout" = "Auto-Layout verwenden"
|
||||
"Use number keys for opacity" = "Zifferntasten für Deckkraft verwenden"
|
||||
"Use the .figcraft or .fig extension." = "Verwenden Sie die Dateiendung .figcraft oder .fig."
|
||||
"Use the layer's fill" = "Füllung der Ebene verwenden"
|
||||
"Used in {files} · {instances}" = "Verwendet in {files} · {instances}"
|
||||
"Username" = "Benutzername"
|
||||
@@ -1435,25 +1447,88 @@ status = "draft — needs native review"
|
||||
"call any command its access allows" = "jeden Befehl aufrufen, den sein Zugriff erlaubt"
|
||||
"call only: {commands}" = "nur aufrufen: {commands}"
|
||||
"can edit the document" = "kann das Dokument bearbeiten"
|
||||
"conditional prototype actions nested too deeply were skipped" = "zu tief verschachtelte bedingte Prototyp-Aktionen wurden übersprungen"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "bedingte Prototyp-Aktionen mit nicht auswertbaren Bedingungen wurden übersprungen"
|
||||
"connector labels were skipped" = "Verbinderbeschriftungen wurden übersprungen"
|
||||
"connectors whose ends can't be placed are empty" = "Verbinder, deren Enden sich nicht platzieren lassen, sind leer"
|
||||
"crop marks" = "Schnittmarken"
|
||||
"damaged vector geometry was skipped" = "beschädigte Vektorgeometrie wurde übersprungen"
|
||||
"damaged vector networks import as outlines" = "beschädigte Vektornetze werden als Umrisse importiert"
|
||||
"development: {path}" = "Entwicklung: {path}"
|
||||
"document children other than pages were skipped" = "Dokumentinhalte, die keine Seiten sind, wurden übersprungen"
|
||||
"e.g. count + 1" = "z. B. count + 1"
|
||||
"e.g. count >= 3 and not done" = "z. B. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "gewinkelte und gebogene Verbinder ohne gespeicherten Pfad werden als gerade Linien importiert"
|
||||
"entries without a node type were skipped" = "Einträge ohne Knotentyp wurden übersprungen"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-Links aus Browser, Chat oder E-Mail öffnen diese App und treten der Sitzung bei."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "gefüllte Bereiche eines Vektors mit eigener Füllung verwenden die Füllung der Ebene"
|
||||
"from {server}" = "von {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "Gamepad-Auslöser ohne genau eine bekannte Controllertaste wurden übersprungen"
|
||||
"hidden" = "ausgeblendet"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "Instanztausch-Eigenschaften, deren Standardkomponente nicht in der Datei ist, wurden übersprungen"
|
||||
"instances inside their own main component were imported as frames" = "Instanzen innerhalb ihrer eigenen Hauptkomponente wurden als Rahmen importiert"
|
||||
"instances of components that aren't in the file show no layers" = "Instanzen von Komponenten, die nicht in der Datei sind, zeigen keine Ebenen"
|
||||
"instances of components that aren't in the file were imported as frames" = "Instanzen von Komponenten, die nicht in der Datei sind, wurden als Rahmen importiert"
|
||||
"instances that would nest a component inside itself were imported as frames" = "Instanzen, die eine Komponente in sich selbst verschachteln würden, wurden als Rahmen importiert"
|
||||
"instances without a main component were imported as empty frames" = "Instanzen ohne Hauptkomponente wurden als leere Rahmen importiert"
|
||||
"invalid or oversized vector networks import as outlines" = "ungültige oder zu große Vektornetze werden als Umrisse importiert"
|
||||
"invalid or oversized vector path data was skipped" = "ungültige oder zu große Vektorpfaddaten wurden übersprungen"
|
||||
"key triggers without a supported key were skipped" = "Tastenauslöser ohne unterstützte Taste wurden übersprungen"
|
||||
"key up triggers import as key presses" = "Auslöser beim Loslassen einer Taste werden als Tastendruck importiert"
|
||||
"layers without a position were placed at their parent's origin" = "Ebenen ohne Position wurden am Ursprung ihres übergeordneten Elements platziert"
|
||||
"layers without a type were skipped" = "Ebenen ohne Typ wurden übersprungen"
|
||||
"locked" = "gesperrt"
|
||||
"no bleed" = "kein Anschnitt"
|
||||
"nobody else here yet" = "noch niemand sonst hier"
|
||||
"now" = "jetzt"
|
||||
"or" = "oder"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "die Konturecken der Punkte wurden übernommen; gestrichelte Konturen und Konturen mit variabler Breite werden mit der Ecke der Ebene gezeichnet"
|
||||
"prototype actions of unknown types were skipped" = "Prototyp-Aktionen unbekannter Art wurden übersprungen"
|
||||
"prototype actions without a destination in the file were skipped" = "Prototyp-Aktionen ohne Ziel in der Datei wurden übersprungen"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "Prototyp-Ausdrücke mit Variablen, die nicht im Variablen-JSON sind, wurden übersprungen"
|
||||
"prototype expressions with unsupported functions were skipped" = "Prototyp-Ausdrücke mit nicht unterstützten Funktionen wurden übersprungen"
|
||||
"prototype interactions without supported actions were skipped" = "Prototyp-Interaktionen ohne unterstützte Aktionen wurden übersprungen"
|
||||
"prototype navigations of unknown types were skipped" = "Prototyp-Navigationen unbekannter Art wurden übersprungen"
|
||||
"prototype transitions of unknown types play instantly" = "Prototyp-Übergänge unbekannter Art werden sofort ausgeführt"
|
||||
"prototype triggers of unknown types were skipped" = "Prototyp-Auslöser unbekannter Art wurden übersprungen"
|
||||
"put a widget on the canvas" = "ein Widget auf die Arbeitsfläche setzen"
|
||||
"read and edit the document" = "das Dokument lesen und bearbeiten"
|
||||
"read the document" = "das Dokument lesen"
|
||||
"reads the document" = "liest das Dokument"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "Aktionen „Variable festlegen“ für Variablen, die nicht im Variablen-JSON sind, wurden übersprungen"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "Aktionen „Variable festlegen“ mit nicht auswertbaren Werten wurden übersprungen"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "Aktionen „Variable festlegen“ mit Werten, die nicht zur Variable passen, wurden übersprungen"
|
||||
"set-variable actions without a value were skipped" = "Aktionen „Variable festlegen“ ohne Wert wurden übersprungen"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "Aktionen „Variablenmodus festlegen“ für Sammlungen oder Modi, die nicht im Variablen-JSON sind, wurden übersprungen"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "einige Vektorpunkte verweisen auf Punkteinstellungen, die in der Datei fehlen; sie übernehmen die Ecken der Ebenenkontur und bleiben ungerundet"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "Sterne und Polygone ohne Punktanzahl oder Geometrie verwenden die Standard-Punktanzahl"
|
||||
"text style overrides refer to missing table entries" = "Textstil-Überschreibungen verweisen auf fehlende Tabelleneinträge"
|
||||
"the file has no pages" = "die Datei hat keine Seiten"
|
||||
"the plugin" = "das Plugin"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "das Variablen-JSON enthält keine `variableCollections` (erwartet wurde die Antwort mit den lokalen Variablen) und wurde ignoriert"
|
||||
"this session only" = "nur diese Sitzung"
|
||||
"unknown component property types were skipped" = "unbekannte Komponenteneigenschaftstypen wurden übersprungen"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "Variablen-Aliase auf Variablen, die nicht im Variablen-JSON sind (oder einen anderen Typ haben), wurden übersprungen"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "Variablenbindungen an Eigenschaften, die die Ebene nicht hat, wurden übersprungen"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "Variablenbindungen an Variablen, die nicht im Variablen-JSON sind (Bibliotheksvariablen), wurden übersprungen"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "Variablenbindungen wurden übersprungen: Das Datei-JSON enthält keine Variablendefinitionen (übergeben Sie das lokale Variablen-JSON als `variables` oder betten Sie dessen `meta` neben dem Datei-JSON ein)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "Variablenbindungen, deren Variablentyp nicht zur Eigenschaft passt, wurden übersprungen"
|
||||
"variable collections without modes were skipped" = "Variablensammlungen ohne Modi wurden übersprungen"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "Variablenmodus-Abfragen in Prototyp-Ausdrücken, die nicht aus einer Variable und einem ihrer Modi bestehen, wurden übersprungen"
|
||||
"variable modes of collections that aren't in the file were skipped" = "Variablenmodi von Sammlungen, die nicht in der Datei sind, wurden übersprungen"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "Variablenmodi von Sammlungen, die nicht im Variablen-JSON sind, wurden übersprungen"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "Variablenwerte, die nicht zu ihrem Typ passen (oder zusammengesetzte Farben), wurden übersprungen"
|
||||
"variables of unknown types were skipped" = "Variablen unbekannter Typen wurden übersprungen"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "Variablen, deren Sammlung nicht im Variablen-JSON ist, wurden übersprungen"
|
||||
"variables without readable values were skipped" = "Variablen ohne lesbare Werte wurden übersprungen"
|
||||
"vector points with unknown stroke joins use the layer join" = "Vektorpunkte mit unbekannten Konturecken verwenden die Ecke der Ebene"
|
||||
"vectors without geometry are empty" = "Vektoren ohne Geometrie sind leer"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "Vektoren ohne Geometrie sind leer (exportieren Sie das Datei-JSON mit geometry=paths)"
|
||||
"very long text was cut to 1,048,576 characters" = "sehr langer Text wurde auf 1.048.576 Zeichen gekürzt"
|
||||
"very long vector paths were skipped" = "sehr lange Vektorpfade wurden übersprungen"
|
||||
"video actions are not supported and were skipped" = "Videoaktionen werden nicht unterstützt und wurden übersprungen"
|
||||
"video triggers are not supported and were skipped" = "Videoauslöser werden nicht unterstützt und wurden übersprungen"
|
||||
"{instances} in {files}" = "{instances} in {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: Übersetzungsentwurf, wartet auf die Prüfung durch Muttersprachler."
|
||||
"{length} bleed" = "{length} Anschnitt"
|
||||
@@ -1496,6 +1571,7 @@ status = "draft — needs native review"
|
||||
"{n} matches" = { one = "{n} Treffer", other = "{n} Treffer" }
|
||||
"{n} minutes ago" = { one = "vor {n} Minute", other = "vor {n} Minuten" }
|
||||
"{n} months ago" = { one = "vor {n} Monat", other = "vor {n} Monaten" }
|
||||
"{n} more notes omitted from this report" = { one = "{n} weitere Notiz in diesem Bericht nicht aufgeführt", other = "{n} weitere Notizen in diesem Bericht nicht aufgeführt" }
|
||||
"{n} of {total}" = "{n} von {total}"
|
||||
"{n} people joined" = { one = "{n} Person ist beigetreten", other = "{n} Personen sind beigetreten" }
|
||||
"{n} preferred components" = { one = "{n} bevorzugte Komponente", other = "{n} bevorzugte Komponenten" }
|
||||
@@ -1654,6 +1730,7 @@ status = "draft — needs native review"
|
||||
"edit.duplicate" = "Duplizieren"
|
||||
"edit.findReplace" = "Suchen und ersetzen"
|
||||
"edit.paste" = "Einfügen"
|
||||
"edit.pasteDesignLayers" = "Designebenen einfügen"
|
||||
"edit.pasteOver" = "Über Auswahl einfügen"
|
||||
"edit.pasteProperties" = "Eigenschaften einfügen"
|
||||
"edit.pasteReplace" = "Zum Ersetzen einfügen"
|
||||
|
||||
@@ -16,6 +16,7 @@ name = "English"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "A .figcraft file with this name already exists. Choose its full name to confirm replacement."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "A layer on its own aligns to its frame: select more layers, or one in a frame"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long"
|
||||
@@ -272,6 +273,7 @@ name = "English"
|
||||
"Commits" = "Commits"
|
||||
"Company" = "Company"
|
||||
"Compare changes" = "Compare changes"
|
||||
"Compatibility notes" = "Compatibility notes"
|
||||
"Completed" = "Completed"
|
||||
"Component" = "Component"
|
||||
"Component name" = "Component name"
|
||||
@@ -484,6 +486,7 @@ name = "English"
|
||||
"Feb" = "Feb"
|
||||
"Fetching from the server…" = "Fetching from the server…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {error}"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged."
|
||||
"File" = "File"
|
||||
"File name" = "File name"
|
||||
"File not found" = "File not found"
|
||||
@@ -608,11 +611,14 @@ name = "English"
|
||||
"If {condition}" = "If {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)"
|
||||
"Image" = "Image"
|
||||
"Images" = "Images"
|
||||
"Import" = "Import"
|
||||
"Import path" = "Import path"
|
||||
"Import plugin from manifest" = "Import plugin from manifest"
|
||||
"Import plugin from manifest…" = "Import plugin from manifest…"
|
||||
"Import report" = "Import report"
|
||||
"Import variables" = "Import variables"
|
||||
"Imported document" = "Imported document"
|
||||
"Imported {name}" = "Imported {name}"
|
||||
"Imported {n} new variables, updated {updated}" = { one = "Imported {n} new variable, updated {updated}", other = "Imported {n} new variables, updated {updated}" }
|
||||
"Import…" = "Import…"
|
||||
@@ -814,6 +820,7 @@ name = "English"
|
||||
"No colour variables fit here." = "No colour variables fit here."
|
||||
"No colour variables yet. Create them in Local variables." = "No colour variables yet. Create them in Local variables."
|
||||
"No comments yet" = "No comments yet"
|
||||
"No compatibility warnings reported" = "No compatibility warnings reported"
|
||||
"No components yet. Select layers and press {keys}." = "No components yet. Select layers and press {keys}."
|
||||
"No conflicts." = "No conflicts."
|
||||
"No contributor data was built into this copy." = "No contributor data was built into this copy."
|
||||
@@ -905,6 +912,7 @@ name = "English"
|
||||
"Open overlay" = "Open overlay"
|
||||
"Open prototype preview" = "Open prototype preview"
|
||||
"Open variables ({count})" = "Open variables ({count})"
|
||||
"Opened as a new document" = "Opened as a new document"
|
||||
"Opened “{name}” in a new tab" = "Opened “{name}” in a new tab"
|
||||
"Opening the file…" = "Opening the file…"
|
||||
"Opening {n} files as new files" = { one = "Opening as a new file", other = "Opening {n} files as new files" }
|
||||
@@ -931,6 +939,8 @@ name = "English"
|
||||
"Padding" = "Padding"
|
||||
"Page" = "Page"
|
||||
"Pages" = "Pages"
|
||||
"Pages added to the document" = "Pages added to the document"
|
||||
"Placed on the current page" = "Placed on the current page"
|
||||
"Pages: {pages}" = "Pages: {pages}"
|
||||
"Paint bucket" = "Paint bucket"
|
||||
"Pan the canvas" = "Pan the canvas"
|
||||
@@ -945,6 +955,7 @@ name = "English"
|
||||
"Paste a team library link (a view link)" = "Paste a team library link (a view link)"
|
||||
"Paste here" = "Paste here"
|
||||
"Paste to replace" = "Paste to replace"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { one = "Pasted design layers; {n} kind of content was left out", other = "Pasted design layers; {n} kinds of content were left out" }
|
||||
"Pattern" = "Pattern"
|
||||
"Pattern alignment" = "Pattern alignment"
|
||||
"Pattern scale" = "Pattern scale"
|
||||
@@ -1415,6 +1426,7 @@ name = "English"
|
||||
"Use as mask" = "Use as mask"
|
||||
"Use auto layout" = "Use auto layout"
|
||||
"Use number keys for opacity" = "Use number keys for opacity"
|
||||
"Use the .figcraft or .fig extension." = "Use the .figcraft or .fig extension."
|
||||
"Use the layer's fill" = "Use the layer's fill"
|
||||
"Used in {files} · {instances}" = "Used in {files} · {instances}"
|
||||
"Username" = "Username"
|
||||
@@ -1508,26 +1520,89 @@ name = "English"
|
||||
"call any command its access allows" = "call any command its access allows"
|
||||
"call only: {commands}" = "call only: {commands}"
|
||||
"can edit the document" = "can edit the document"
|
||||
"conditional prototype actions nested too deeply were skipped" = "conditional prototype actions nested too deeply were skipped"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "conditional prototype actions with conditions that can't be evaluated were skipped"
|
||||
"connector labels were skipped" = "connector labels were skipped"
|
||||
"connectors whose ends can't be placed are empty" = "connectors whose ends can't be placed are empty"
|
||||
"crop marks" = "crop marks"
|
||||
"damaged vector geometry was skipped" = "damaged vector geometry was skipped"
|
||||
"damaged vector networks import as outlines" = "damaged vector networks import as outlines"
|
||||
"development: {path}" = "development: {path}"
|
||||
"document children other than pages were skipped" = "document children other than pages were skipped"
|
||||
"e.g. count + 1" = "e.g. count + 1"
|
||||
"e.g. count >= 3 and not done" = "e.g. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "elbowed and curved connectors without a saved path import as straight lines"
|
||||
"entries without a node type were skipped" = "entries without a node type were skipped"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft:// links from a browser, chat or email open this app and join the session."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "filled areas of a vector with their own fill use the layer's fill"
|
||||
"from {server}" = "from {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "gamepad triggers without exactly one known controller button were skipped"
|
||||
"hidden" = "hidden"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "instance swap properties whose default component isn't in the file were skipped"
|
||||
"instances inside their own main component were imported as frames" = "instances inside their own main component were imported as frames"
|
||||
"instances of components that aren't in the file show no layers" = "instances of components that aren't in the file show no layers"
|
||||
"instances of components that aren't in the file were imported as frames" = "instances of components that aren't in the file were imported as frames"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instances that would nest a component inside itself were imported as frames"
|
||||
"instances without a main component were imported as empty frames" = "instances without a main component were imported as empty frames"
|
||||
"invalid or oversized vector networks import as outlines" = "invalid or oversized vector networks import as outlines"
|
||||
"invalid or oversized vector path data was skipped" = "invalid or oversized vector path data was skipped"
|
||||
"just now" = "just now"
|
||||
"key triggers without a supported key were skipped" = "key triggers without a supported key were skipped"
|
||||
"key up triggers import as key presses" = "key up triggers import as key presses"
|
||||
"layers without a position were placed at their parent's origin" = "layers without a position were placed at their parent's origin"
|
||||
"layers without a type were skipped" = "layers without a type were skipped"
|
||||
"locked" = "locked"
|
||||
"no bleed" = "no bleed"
|
||||
"nobody else here yet" = "nobody else here yet"
|
||||
"now" = "now"
|
||||
"or" = "or"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join"
|
||||
"prototype actions of unknown types were skipped" = "prototype actions of unknown types were skipped"
|
||||
"prototype actions without a destination in the file were skipped" = "prototype actions without a destination in the file were skipped"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "prototype expressions on variables that aren't in the variables JSON were skipped"
|
||||
"prototype expressions with unsupported functions were skipped" = "prototype expressions with unsupported functions were skipped"
|
||||
"prototype interactions without supported actions were skipped" = "prototype interactions without supported actions were skipped"
|
||||
"prototype navigations of unknown types were skipped" = "prototype navigations of unknown types were skipped"
|
||||
"prototype transitions of unknown types play instantly" = "prototype transitions of unknown types play instantly"
|
||||
"prototype triggers of unknown types were skipped" = "prototype triggers of unknown types were skipped"
|
||||
"put a widget on the canvas" = "put a widget on the canvas"
|
||||
"read and edit the document" = "read and edit the document"
|
||||
"read the document" = "read the document"
|
||||
"reads the document" = "reads the document"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "set-variable actions on variables that aren't in the variables JSON were skipped"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "set-variable actions with values that can't be evaluated were skipped"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "set-variable actions with values that don't fit the variable were skipped"
|
||||
"set-variable actions without a value were skipped" = "set-variable actions without a value were skipped"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count"
|
||||
"text style overrides refer to missing table entries" = "text style overrides refer to missing table entries"
|
||||
"the file has no pages" = "the file has no pages"
|
||||
"the plugin" = "the plugin"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "the variables JSON has no `variableCollections` (expected the local variables response) and was ignored"
|
||||
"this session only" = "this session only"
|
||||
"unknown component property types were skipped" = "unknown component property types were skipped"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "variable aliases to variables that aren't in the variables JSON (or of another type) were skipped"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "variable bindings to properties the layer doesn't have were skipped"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "variable bindings to variables that aren't in the variables JSON (library variables) were skipped"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "variable bindings whose variable type doesn't fit the property were skipped"
|
||||
"variable collections without modes were skipped" = "variable collections without modes were skipped"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped"
|
||||
"variable modes of collections that aren't in the file were skipped" = "variable modes of collections that aren't in the file were skipped"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "variable modes of collections that aren't in the variables JSON were skipped"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "variable values that don't match their type (or composed colours) were skipped"
|
||||
"variables of unknown types were skipped" = "variables of unknown types were skipped"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "variables whose collection isn't in the variables JSON were skipped"
|
||||
"variables without readable values were skipped" = "variables without readable values were skipped"
|
||||
"vector points with unknown stroke joins use the layer join" = "vector points with unknown stroke joins use the layer join"
|
||||
"vectors without geometry are empty" = "vectors without geometry are empty"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vectors without geometry are empty (export the file JSON with geometry=paths)"
|
||||
"very long text was cut to 1,048,576 characters" = "very long text was cut to 1,048,576 characters"
|
||||
"very long vector paths were skipped" = "very long vector paths were skipped"
|
||||
"video actions are not supported and were skipped" = "video actions are not supported and were skipped"
|
||||
"video triggers are not supported and were skipped" = "video triggers are not supported and were skipped"
|
||||
"yesterday" = "yesterday"
|
||||
"{instances} in {files}" = "{instances} in {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: draft translation, waiting for a native speaker's review."
|
||||
@@ -1572,6 +1647,7 @@ name = "English"
|
||||
"{n} matches" = { one = "{n} match", other = "{n} matches" }
|
||||
"{n} minutes ago" = { one = "{n} minute ago", other = "{n} minutes ago" }
|
||||
"{n} months ago" = { one = "{n} month ago", other = "{n} months ago" }
|
||||
"{n} more notes omitted from this report" = { one = "{n} more note omitted from this report", other = "{n} more notes omitted from this report" }
|
||||
"{n} of {total}" = "{n} of {total}"
|
||||
"{n} people joined" = { one = "{n} person joined", other = "{n} people joined" }
|
||||
"{n} preferred components" = { one = "{n} preferred component", other = "{n} preferred components" }
|
||||
|
||||
@@ -66,6 +66,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Aún no hay componentes. Selecciona capas y presiona {keys}."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Agrega un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo."
|
||||
"PDF print settings" = "Configuración de impresión PDF"
|
||||
"Pages added to the document" = "Páginas agregadas al documento"
|
||||
"Press a key…" = "Presiona una tecla…"
|
||||
"Prototype settings" = "Configuración del prototipo"
|
||||
"Remove export setting" = "Quitar configuración de exportación"
|
||||
@@ -80,6 +81,8 @@ status = "draft — needs native review"
|
||||
"Type settings" = "Configuración de texto"
|
||||
"With the layers' export settings" = "Con la configuración de exportación de las capas"
|
||||
"You'll get the shared file as a new tab and edit it together." = "Recibirás el archivo compartido en una pestaña nueva y lo editarán en conjunto."
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "se omitieron los desencadenantes de control sin exactamente un botón de control conocido"
|
||||
"key up triggers import as key presses" = "los desencadenantes al soltar una tecla se importan como presiones de tecla"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en esta computadora; el archivo se queda en {server}"
|
||||
"{n} added" = { one = "{n} agregada", other = "{n} agregadas" }
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ name = "Español"
|
||||
status = "draft — needs native review"
|
||||
|
||||
[ui]
|
||||
"Compatibility notes" = "Notas de compatibilidad"
|
||||
"Couldn't download the variables" = "No se pudieron descargar las variables"
|
||||
|
||||
"% of all commits" = "% del total de commits"
|
||||
@@ -18,6 +19,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una colección de este archivo con estos modos y variables: modifica sus valores por modo y añade variables propias"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Ya existe un archivo .figcraft con este nombre. Elige su nombre completo para confirmar que deseas reemplazarlo."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Una capa sola se alinea con su marco: selecciona más capas o una dentro de un marco"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Una página de producto adaptable: cabecera, funciones, cifras, una cita y un pie"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un muelle se reproduce hasta que se asienta: su masa, rigidez y amortiguación fijan cuánto dura"
|
||||
@@ -485,6 +487,7 @@ status = "draft — needs native review"
|
||||
"Feb" = "feb"
|
||||
"Fetching from the server…" = "Obteniendo del servidor…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft no pudo abrir su ventana: {error}"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft aún no admite todas las funciones de estos archivos. Las notas indican qué se omitió o cambió en la copia importada; el archivo original no se modifica."
|
||||
"File" = "Archivo"
|
||||
"File name" = "Nombre del archivo"
|
||||
"File not found" = "No se encuentra el archivo"
|
||||
@@ -609,11 +612,14 @@ status = "draft — needs native review"
|
||||
"If {condition}" = "Si {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)"
|
||||
"Image" = "Imagen"
|
||||
"Images" = "Imágenes"
|
||||
"Import" = "Importar"
|
||||
"Import path" = "Ruta de importación"
|
||||
"Import plugin from manifest" = "Importar plugin desde manifiesto"
|
||||
"Import plugin from manifest…" = "Importar plugin desde manifiesto…"
|
||||
"Import report" = "Informe de importación"
|
||||
"Import variables" = "Importar variables"
|
||||
"Imported document" = "Documento importado"
|
||||
"Imported {name}" = "{name} importado"
|
||||
"Imported {n} new variables, updated {updated}" = { one = "Variables importadas: {n} nueva; actualizadas: {updated}", other = "Variables importadas: {n} nuevas; actualizadas: {updated}" }
|
||||
"Import…" = "Importar…"
|
||||
@@ -815,6 +821,7 @@ status = "draft — needs native review"
|
||||
"No colour variables fit here." = "Ninguna variable de color encaja aquí."
|
||||
"No colour variables yet. Create them in Local variables." = "Aún no hay variables de color. Créalas en Variables locales."
|
||||
"No comments yet" = "Aún no hay comentarios"
|
||||
"No compatibility warnings reported" = "No se han registrado advertencias de compatibilidad"
|
||||
"No components yet. Select layers and press {keys}." = "Aún no hay componentes. Selecciona capas y pulsa {keys}."
|
||||
"No conflicts." = "Sin conflictos."
|
||||
"No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores."
|
||||
@@ -906,6 +913,7 @@ status = "draft — needs native review"
|
||||
"Open overlay" = "Abrir superposición"
|
||||
"Open prototype preview" = "Abrir vista previa del prototipo"
|
||||
"Open variables ({count})" = "Abrir variables ({count})"
|
||||
"Opened as a new document" = "Abierto como un documento nuevo"
|
||||
"Opened “{name}” in a new tab" = "“{name}” abierta en una pestaña nueva"
|
||||
"Opening the file…" = "Abriendo el archivo…"
|
||||
"Opening {n} files as new files" = { one = "Abriendo como archivo nuevo", other = "Abriendo {n} archivos como archivos nuevos" }
|
||||
@@ -932,6 +940,8 @@ status = "draft — needs native review"
|
||||
"Padding" = "Margen interno"
|
||||
"Page" = "Página"
|
||||
"Pages" = "Páginas"
|
||||
"Pages added to the document" = "Páginas añadidas al documento"
|
||||
"Placed on the current page" = "Colocado en la página actual"
|
||||
"Pages: {pages}" = "Páginas: {pages}"
|
||||
"Paint bucket" = "Bote de pintura"
|
||||
"Pan the canvas" = "Desplazar el lienzo"
|
||||
@@ -946,6 +956,7 @@ status = "draft — needs native review"
|
||||
"Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)"
|
||||
"Paste here" = "Pegar aquí"
|
||||
"Paste to replace" = "Pegar para reemplazar"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { one = "Capas de diseño pegadas; se omitió {n} tipo de contenido", other = "Capas de diseño pegadas; se omitieron {n} tipos de contenido" }
|
||||
"Pattern" = "Patrón"
|
||||
"Pattern alignment" = "Alineación del patrón"
|
||||
"Pattern scale" = "Escala del patrón"
|
||||
@@ -1416,6 +1427,7 @@ status = "draft — needs native review"
|
||||
"Use as mask" = "Usar como máscara"
|
||||
"Use auto layout" = "Usar diseño automático"
|
||||
"Use number keys for opacity" = "Usar teclas numéricas para la opacidad"
|
||||
"Use the .figcraft or .fig extension." = "Usa la extensión .figcraft o .fig."
|
||||
"Use the layer's fill" = "Usar el relleno de la capa"
|
||||
"Used in {files} · {instances}" = "Usado en {files} · {instances}"
|
||||
"Username" = "Usuario"
|
||||
@@ -1509,26 +1521,89 @@ status = "draft — needs native review"
|
||||
"call any command its access allows" = "llamar a cualquier comando que su acceso permita"
|
||||
"call only: {commands}" = "llamar solo a: {commands}"
|
||||
"can edit the document" = "puede editar el documento"
|
||||
"conditional prototype actions nested too deeply were skipped" = "se omitieron las acciones condicionales de prototipo anidadas a demasiada profundidad"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "se omitieron las acciones condicionales de prototipo con condiciones que no se pueden evaluar"
|
||||
"connector labels were skipped" = "se omitieron las etiquetas de los conectores"
|
||||
"connectors whose ends can't be placed are empty" = "los conectores cuyos extremos no se pueden colocar están vacíos"
|
||||
"crop marks" = "marcas de corte"
|
||||
"damaged vector geometry was skipped" = "se omitió la geometría vectorial dañada"
|
||||
"damaged vector networks import as outlines" = "las redes vectoriales dañadas se importan como contornos"
|
||||
"development: {path}" = "desarrollo: {path}"
|
||||
"document children other than pages were skipped" = "se omitieron los elementos del documento que no son páginas"
|
||||
"e.g. count + 1" = "p. ej. count + 1"
|
||||
"e.g. count >= 3 and not done" = "p. ej. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "los conectores angulares y curvos sin trazado guardado se importan como líneas rectas"
|
||||
"entries without a node type were skipped" = "se omitieron las entradas sin tipo de nodo"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Los enlaces figcraft:// de un navegador, chat o correo abren esta app y se unen a la sesión."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "las áreas rellenas de un vector con relleno propio usan el relleno de la capa"
|
||||
"from {server}" = "de {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "se omitieron los desencadenantes de mando sin exactamente un botón de mando conocido"
|
||||
"hidden" = "oculta"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado no está en el archivo"
|
||||
"instances inside their own main component were imported as frames" = "las instancias dentro de su propio componente principal se importaron como marcos"
|
||||
"instances of components that aren't in the file show no layers" = "las instancias de componentes que no están en el archivo no muestran capas"
|
||||
"instances of components that aren't in the file were imported as frames" = "las instancias de componentes que no están en el archivo se importaron como marcos"
|
||||
"instances that would nest a component inside itself were imported as frames" = "las instancias que anidarían un componente dentro de sí mismo se importaron como marcos"
|
||||
"instances without a main component were imported as empty frames" = "las instancias sin componente principal se importaron como marcos vacíos"
|
||||
"invalid or oversized vector networks import as outlines" = "las redes vectoriales no válidas o demasiado grandes se importan como contornos"
|
||||
"invalid or oversized vector path data was skipped" = "se omitieron los datos de trazados vectoriales no válidos o demasiado grandes"
|
||||
"just now" = "justo ahora"
|
||||
"key triggers without a supported key were skipped" = "se omitieron los desencadenantes de tecla sin una tecla compatible"
|
||||
"key up triggers import as key presses" = "los desencadenantes al soltar una tecla se importan como pulsaciones de tecla"
|
||||
"layers without a position were placed at their parent's origin" = "las capas sin posición se colocaron en el origen de su elemento padre"
|
||||
"layers without a type were skipped" = "se omitieron las capas sin tipo"
|
||||
"locked" = "bloqueada"
|
||||
"no bleed" = "sin sangrado"
|
||||
"nobody else here yet" = "aún no hay nadie más"
|
||||
"now" = "ahora"
|
||||
"or" = "o"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "se conservaron las uniones de trazo de los puntos; los trazos discontinuos o de ancho variable se dibujan con la unión de la capa"
|
||||
"prototype actions of unknown types were skipped" = "se omitieron las acciones de prototipo de tipos desconocidos"
|
||||
"prototype actions without a destination in the file were skipped" = "se omitieron las acciones de prototipo sin destino en el archivo"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "se omitieron las expresiones de prototipo con variables que no están en el JSON de variables"
|
||||
"prototype expressions with unsupported functions were skipped" = "se omitieron las expresiones de prototipo con funciones no compatibles"
|
||||
"prototype interactions without supported actions were skipped" = "se omitieron las interacciones de prototipo sin acciones compatibles"
|
||||
"prototype navigations of unknown types were skipped" = "se omitieron las navegaciones de prototipo de tipos desconocidos"
|
||||
"prototype transitions of unknown types play instantly" = "las transiciones de prototipo de tipos desconocidos se reproducen al instante"
|
||||
"prototype triggers of unknown types were skipped" = "se omitieron los desencadenantes de prototipo de tipos desconocidos"
|
||||
"put a widget on the canvas" = "poner un widget en el lienzo"
|
||||
"read and edit the document" = "leer y editar el documento"
|
||||
"read the document" = "leer el documento"
|
||||
"reads the document" = "lee el documento"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer variable» sobre variables que no están en el JSON de variables"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "se omitieron las acciones «Establecer variable» con valores que no se pueden evaluar"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "se omitieron las acciones «Establecer variable» con valores que no encajan con la variable"
|
||||
"set-variable actions without a value were skipped" = "se omitieron las acciones «Establecer variable» sin valor"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "las estrellas y los polígonos sin número de puntos ni geometría usan el número de puntos predeterminado"
|
||||
"text style overrides refer to missing table entries" = "las anulaciones de estilo de texto hacen referencia a entradas de tabla que faltan"
|
||||
"the file has no pages" = "el archivo no tiene páginas"
|
||||
"the plugin" = "el plugin"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "el JSON de variables no tiene `variableCollections` (se esperaba la respuesta de variables locales) y se ignoró"
|
||||
"this session only" = "solo esta sesión"
|
||||
"unknown component property types were skipped" = "se omitieron los tipos de propiedad de componente desconocidos"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "se omitieron los alias a variables que no están en el JSON de variables (o son de otro tipo)"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "se omitieron las vinculaciones de variables a propiedades que la capa no tiene"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "se omitieron las vinculaciones a variables que no están en el JSON de variables (variables de biblioteca)"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "se omitieron las vinculaciones de variables: el JSON del archivo no tiene definiciones de variables (pasa el JSON de variables locales como `variables` o incrusta su `meta` junto al JSON del archivo)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "se omitieron las vinculaciones de variables cuyo tipo no encaja con la propiedad"
|
||||
"variable collections without modes were skipped" = "se omitieron las colecciones de variables sin modos"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "se omitieron las consultas de modo de variable en expresiones de prototipo que no son una variable y uno de sus modos"
|
||||
"variable modes of collections that aren't in the file were skipped" = "se omitieron los modos de variable de colecciones que no están en el archivo"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "se omitieron los modos de variable de colecciones que no están en el JSON de variables"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "se omitieron los valores de variable que no coinciden con su tipo (o colores compuestos)"
|
||||
"variables of unknown types were skipped" = "se omitieron las variables de tipos desconocidos"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "se omitieron las variables cuya colección no está en el JSON de variables"
|
||||
"variables without readable values were skipped" = "se omitieron las variables sin valores legibles"
|
||||
"vector points with unknown stroke joins use the layer join" = "los puntos vectoriales con uniones de trazo desconocidas usan la unión de la capa"
|
||||
"vectors without geometry are empty" = "los vectores sin geometría están vacíos"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "los vectores sin geometría están vacíos (exporta el JSON del archivo con geometry=paths)"
|
||||
"very long text was cut to 1,048,576 characters" = "el texto muy largo se recortó a 1.048.576 caracteres"
|
||||
"very long vector paths were skipped" = "se omitieron los trazados vectoriales muy largos"
|
||||
"video actions are not supported and were skipped" = "las acciones de vídeo no son compatibles y se omitieron"
|
||||
"video triggers are not supported and were skipped" = "los desencadenantes de vídeo no son compatibles y se omitieron"
|
||||
"yesterday" = "ayer"
|
||||
"{instances} in {files}" = "{instances} en {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: traducción en borrador, pendiente de revisión por un hablante nativo."
|
||||
@@ -1573,6 +1648,7 @@ status = "draft — needs native review"
|
||||
"{n} matches" = { one = "{n} coincidencia", other = "{n} coincidencias" }
|
||||
"{n} minutes ago" = { one = "hace {n} minuto", other = "hace {n} minutos" }
|
||||
"{n} months ago" = { one = "hace {n} mes", other = "hace {n} meses" }
|
||||
"{n} more notes omitted from this report" = { one = "{n} nota más omitida de este informe", other = "{n} notas más omitidas de este informe" }
|
||||
"{n} of {total}" = "{n} de {total}"
|
||||
"{n} people joined" = { one = "{n} persona se ha unido", other = "{n} personas se han unido" }
|
||||
"{n} preferred components" = { one = "{n} componente preferido", other = "{n} componentes preferidos" }
|
||||
@@ -1733,6 +1809,7 @@ status = "draft — needs native review"
|
||||
"edit.duplicate" = "Duplicar"
|
||||
"edit.findReplace" = "Buscar y reemplazar"
|
||||
"edit.paste" = "Pegar"
|
||||
"edit.pasteDesignLayers" = "Pegar capas de diseño"
|
||||
"edit.pasteOver" = "Pegar sobre la selección"
|
||||
"edit.pasteProperties" = "Pegar propiedades"
|
||||
"edit.pasteReplace" = "Pegar para reemplazar"
|
||||
|
||||
@@ -17,6 +17,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} ressources binaires, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Un fichier .figcraft portant ce nom existe déjà. Choisissez son nom complet pour confirmer le remplacement."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée"
|
||||
"About" = "À propos"
|
||||
@@ -260,6 +261,7 @@ status = "draft — needs native review"
|
||||
"Commits" = "Commits"
|
||||
"Company" = "Entreprise"
|
||||
"Compare changes" = "Comparer les modifications"
|
||||
"Compatibility notes" = "Notes de compatibilité"
|
||||
"Completed" = "Terminé"
|
||||
"Component" = "Composant"
|
||||
"Component name" = "Nom du composant"
|
||||
@@ -460,6 +462,7 @@ status = "draft — needs native review"
|
||||
"False" = "Faux"
|
||||
"Fetching from the server…" = "Récupération depuis le serveur…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft n’a pas pu ouvrir sa fenêtre : {error}"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ne prend pas encore en charge toutes les fonctionnalités de ces fichiers. Les notes indiquent ce qui a été omis ou modifié dans la copie importée ; le fichier d’origine reste inchangé."
|
||||
"File" = "Fichier"
|
||||
"File name" = "Nom du fichier"
|
||||
"File published" = "Fichier publié"
|
||||
@@ -576,11 +579,14 @@ status = "draft — needs native review"
|
||||
"If {condition}" = "Si {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Ignorer la mise en page auto (position absolue)"
|
||||
"Image" = "Image"
|
||||
"Images" = "Images"
|
||||
"Import" = "Importer"
|
||||
"Import path" = "Chemin d’importation"
|
||||
"Import plugin from manifest" = "Importer un plugin depuis un manifeste"
|
||||
"Import plugin from manifest…" = "Importer un plugin depuis un manifeste…"
|
||||
"Import report" = "Rapport d’importation"
|
||||
"Import variables" = "Importer des variables"
|
||||
"Imported document" = "Document importé"
|
||||
"Imported {name}" = "{name} importé"
|
||||
"Imported {n} new variables, updated {updated}" = { one = "{n} nouvelle variable importée, {updated} mises à jour", other = "{n} nouvelles variables importées, {updated} mises à jour" }
|
||||
"Import…" = "Importer…"
|
||||
@@ -770,6 +776,7 @@ status = "draft — needs native review"
|
||||
"No colour variables fit here." = "Aucune variable de couleur ne convient ici."
|
||||
"No colour variables yet. Create them in Local variables." = "Aucune variable de couleur pour l’instant. Créez-en dans Variables locales."
|
||||
"No comments yet" = "Aucun commentaire pour l’instant"
|
||||
"No compatibility warnings reported" = "Aucun avertissement de compatibilité signalé"
|
||||
"No components yet. Select layers and press {keys}." = "Aucun composant pour l’instant. Sélectionnez des calques et appuyez sur {keys}."
|
||||
"No conflicts." = "Aucun conflit."
|
||||
"No contributor data was built into this copy." = "Cette copie ne contient aucune donnée de contributeurs."
|
||||
@@ -856,6 +863,7 @@ status = "draft — needs native review"
|
||||
"Open overlay" = "Ouvrir une superposition"
|
||||
"Open prototype preview" = "Ouvrir l’aperçu du prototype"
|
||||
"Open variables ({count})" = "Ouvrir les variables ({count})"
|
||||
"Opened as a new document" = "Ouvert comme nouveau document"
|
||||
"Opened “{name}” in a new tab" = "« {name} » ouvert dans un nouvel onglet"
|
||||
"Open|comments" = "Ouverts"
|
||||
"Options" = "Options"
|
||||
@@ -879,6 +887,8 @@ status = "draft — needs native review"
|
||||
"Padding" = "Marge intérieure"
|
||||
"Page" = "Page"
|
||||
"Pages" = "Pages"
|
||||
"Pages added to the document" = "Pages ajoutées au document"
|
||||
"Placed on the current page" = "Placé sur la page actuelle"
|
||||
"Pages: {pages}" = "Pages : {pages}"
|
||||
"Paint bucket" = "Pot de peinture"
|
||||
"Pan the canvas" = "Faire défiler le canevas"
|
||||
@@ -893,6 +903,7 @@ status = "draft — needs native review"
|
||||
"Paste a team library link (a view link)" = "Collez le lien d’une bibliothèque d’équipe (un lien de consultation)"
|
||||
"Paste here" = "Coller ici"
|
||||
"Paste to replace" = "Coller pour remplacer"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { one = "Calques de design collés ; {n} type de contenu a été omis", other = "Calques de design collés ; {n} types de contenu ont été omis" }
|
||||
"Pattern" = "Motif"
|
||||
"Pattern alignment" = "Alignement du motif"
|
||||
"Pattern scale" = "Échelle du motif"
|
||||
@@ -1338,6 +1349,7 @@ status = "draft — needs native review"
|
||||
"Use as mask" = "Utiliser comme masque"
|
||||
"Use auto layout" = "Utiliser la mise en page auto"
|
||||
"Use number keys for opacity" = "Utiliser les touches numériques pour l’opacité"
|
||||
"Use the .figcraft or .fig extension." = "Utilisez l’extension .figcraft ou .fig."
|
||||
"Use the layer's fill" = "Utiliser le remplissage du calque"
|
||||
"Used in {files} · {instances}" = "Utilisé dans {files} · {instances}"
|
||||
"Username" = "Nom d’utilisateur"
|
||||
@@ -1426,25 +1438,88 @@ status = "draft — needs native review"
|
||||
"call any command its access allows" = "appeler toute commande que son accès autorise"
|
||||
"call only: {commands}" = "appeler seulement : {commands}"
|
||||
"can edit the document" = "peut modifier le document"
|
||||
"conditional prototype actions nested too deeply were skipped" = "les actions de prototype conditionnelles trop profondément imbriquées ont été ignorées"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "les actions de prototype conditionnelles dont les conditions ne peuvent pas être évaluées ont été ignorées"
|
||||
"connector labels were skipped" = "les libellés des connecteurs ont été ignorés"
|
||||
"connectors whose ends can't be placed are empty" = "les connecteurs dont les extrémités ne peuvent pas être placées sont vides"
|
||||
"crop marks" = "traits de coupe"
|
||||
"damaged vector geometry was skipped" = "la géométrie vectorielle endommagée a été ignorée"
|
||||
"damaged vector networks import as outlines" = "les réseaux vectoriels endommagés sont importés comme tracés"
|
||||
"development: {path}" = "développement : {path}"
|
||||
"document children other than pages were skipped" = "les enfants du document autres que des pages ont été ignorés"
|
||||
"e.g. count + 1" = "p. ex. count + 1"
|
||||
"e.g. count >= 3 and not done" = "p. ex. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "les connecteurs coudés et courbes sans tracé enregistré sont importés comme des lignes droites"
|
||||
"entries without a node type were skipped" = "les entrées sans type de nœud ont été ignorées"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Les liens figcraft:// d’un navigateur, d’une messagerie ou d’un e-mail ouvrent cette application et rejoignent la session."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "les zones remplies d’un vecteur avec leur propre remplissage utilisent le remplissage du calque"
|
||||
"from {server}" = "depuis {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "les déclencheurs de manette sans exactement un bouton de manette connu ont été ignorés"
|
||||
"hidden" = "masqué"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "les propriétés de permutation d’instance dont le composant par défaut n’est pas dans le fichier ont été ignorées"
|
||||
"instances inside their own main component were imported as frames" = "les instances placées dans leur propre composant principal ont été importées comme cadres"
|
||||
"instances of components that aren't in the file show no layers" = "les instances de composants absents du fichier n’affichent aucun calque"
|
||||
"instances of components that aren't in the file were imported as frames" = "les instances de composants absents du fichier ont été importées comme cadres"
|
||||
"instances that would nest a component inside itself were imported as frames" = "les instances qui imbriqueraient un composant dans lui-même ont été importées comme cadres"
|
||||
"instances without a main component were imported as empty frames" = "les instances sans composant principal ont été importées comme cadres vides"
|
||||
"invalid or oversized vector networks import as outlines" = "les réseaux vectoriels non valides ou trop grands sont importés comme tracés"
|
||||
"invalid or oversized vector path data was skipped" = "les données de tracé vectoriel non valides ou trop grandes ont été ignorées"
|
||||
"key triggers without a supported key were skipped" = "les déclencheurs de touche sans touche prise en charge ont été ignorés"
|
||||
"key up triggers import as key presses" = "les déclencheurs au relâchement d’une touche sont importés comme des appuis sur la touche"
|
||||
"layers without a position were placed at their parent's origin" = "les calques sans position ont été placés à l’origine de leur parent"
|
||||
"layers without a type were skipped" = "les calques sans type ont été ignorés"
|
||||
"locked" = "verrouillé"
|
||||
"no bleed" = "sans fond perdu"
|
||||
"nobody else here yet" = "personne d’autre pour l’instant"
|
||||
"now" = "maintenant"
|
||||
"or" = "ou"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "les jointures des points ont été conservées ; les contours en pointillés ou à largeur variable sont dessinés avec la jointure du calque"
|
||||
"prototype actions of unknown types were skipped" = "les actions de prototype de types inconnus ont été ignorées"
|
||||
"prototype actions without a destination in the file were skipped" = "les actions de prototype sans destination dans le fichier ont été ignorées"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "les expressions de prototype portant sur des variables absentes du JSON des variables ont été ignorées"
|
||||
"prototype expressions with unsupported functions were skipped" = "les expressions de prototype utilisant des fonctions non prises en charge ont été ignorées"
|
||||
"prototype interactions without supported actions were skipped" = "les interactions de prototype sans action prise en charge ont été ignorées"
|
||||
"prototype navigations of unknown types were skipped" = "les navigations de prototype de types inconnus ont été ignorées"
|
||||
"prototype transitions of unknown types play instantly" = "les transitions de prototype de types inconnus sont instantanées"
|
||||
"prototype triggers of unknown types were skipped" = "les déclencheurs de prototype de types inconnus ont été ignorés"
|
||||
"put a widget on the canvas" = "placer un widget sur le canevas"
|
||||
"read and edit the document" = "lire et modifier le document"
|
||||
"read the document" = "lire le document"
|
||||
"reads the document" = "lit le document"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "les actions de définition de variable portant sur des variables absentes du JSON des variables ont été ignorées"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "les actions de définition de variable dont les valeurs ne peuvent pas être évaluées ont été ignorées"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "les actions de définition de variable dont les valeurs ne correspondent pas à la variable ont été ignorées"
|
||||
"set-variable actions without a value were skipped" = "les actions de définition de variable sans valeur ont été ignorées"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "les actions de définition du mode de variable portant sur des collections ou des modes absents du JSON des variables ont été ignorées"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "certains points vectoriels renvoient à des réglages de point absents du fichier ; ils utilisent les jointures du calque et des angles simples"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "les étoiles et polygones sans nombre de pointes ni géométrie utilisent le nombre de pointes par défaut"
|
||||
"text style overrides refer to missing table entries" = "les remplacements de style de texte renvoient à des entrées de table manquantes"
|
||||
"the file has no pages" = "le fichier n’a aucune page"
|
||||
"the plugin" = "le plugin"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "le JSON des variables n’a pas de `variableCollections` (la réponse des variables locales était attendue) et a été ignoré"
|
||||
"this session only" = "cette session uniquement"
|
||||
"unknown component property types were skipped" = "les types de propriété de composant inconnus ont été ignorés"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "les alias vers des variables absentes du JSON des variables (ou d’un autre type) ont été ignorés"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "les liaisons de variables à des propriétés que le calque n’a pas ont été ignorées"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "les liaisons à des variables absentes du JSON des variables (variables de bibliothèque) ont été ignorées"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "les liaisons de variables ont été ignorées : le JSON du fichier n’a aucune définition de variable (transmettez le JSON des variables locales comme `variables`, ou intégrez son `meta` à côté du JSON du fichier)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "les liaisons de variables dont le type de variable ne convient pas à la propriété ont été ignorées"
|
||||
"variable collections without modes were skipped" = "les collections de variables sans mode ont été ignorées"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "les recherches de mode de variable dans les expressions de prototype qui ne sont pas une variable et l’un de ses modes ont été ignorées"
|
||||
"variable modes of collections that aren't in the file were skipped" = "les modes de variables de collections absentes du fichier ont été ignorés"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "les modes de variables de collections absentes du JSON des variables ont été ignorés"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "les valeurs de variables qui ne correspondent pas à leur type (ou les couleurs composées) ont été ignorées"
|
||||
"variables of unknown types were skipped" = "les variables de types inconnus ont été ignorées"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "les variables dont la collection est absente du JSON des variables ont été ignorées"
|
||||
"variables without readable values were skipped" = "les variables sans valeurs lisibles ont été ignorées"
|
||||
"vector points with unknown stroke joins use the layer join" = "les points vectoriels aux jointures inconnues utilisent la jointure du calque"
|
||||
"vectors without geometry are empty" = "les vecteurs sans géométrie sont vides"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "les vecteurs sans géométrie sont vides (exportez le JSON du fichier avec geometry=paths)"
|
||||
"very long text was cut to 1,048,576 characters" = "le texte très long a été coupé à 1 048 576 caractères"
|
||||
"very long vector paths were skipped" = "les tracés vectoriels très longs ont été ignorés"
|
||||
"video actions are not supported and were skipped" = "les actions vidéo ne sont pas prises en charge et ont été ignorées"
|
||||
"video triggers are not supported and were skipped" = "les déclencheurs vidéo ne sont pas pris en charge et ont été ignorés"
|
||||
"{instances} in {files}" = "{instances} dans {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language} : traduction provisoire, en attente de relecture par un locuteur natif."
|
||||
"{length} bleed" = "fond perdu de {length}"
|
||||
@@ -1487,6 +1562,7 @@ status = "draft — needs native review"
|
||||
"{n} matches" = { one = "{n} résultat", other = "{n} résultats" }
|
||||
"{n} minutes ago" = { one = "il y a {n} minute", other = "il y a {n} minutes" }
|
||||
"{n} months ago" = { one = "il y a {n} mois", other = "il y a {n} mois" }
|
||||
"{n} more notes omitted from this report" = { one = "{n} autre note omise de ce rapport", other = "{n} autres notes omises de ce rapport" }
|
||||
"{n} of {total}" = "{n} sur {total}"
|
||||
"{n} people joined" = { one = "{n} personne a rejoint la session", other = "{n} personnes ont rejoint la session" }
|
||||
"{n} preferred components" = { one = "{n} composant préféré", other = "{n} composants préférés" }
|
||||
@@ -1645,6 +1721,7 @@ status = "draft — needs native review"
|
||||
"edit.duplicate" = "Dupliquer"
|
||||
"edit.findReplace" = "Rechercher et remplacer"
|
||||
"edit.paste" = "Coller"
|
||||
"edit.pasteDesignLayers" = "Coller des calques de design"
|
||||
"edit.pasteOver" = "Coller par-dessus la sélection"
|
||||
"edit.pasteProperties" = "Coller les propriétés"
|
||||
"edit.pasteReplace" = "Coller pour remplacer"
|
||||
|
||||
@@ -17,6 +17,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} baris (Δ {delta}), +{bin_added} / −{bin_deleted} aset biner, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Koleksi file ini dengan mode dan variabel tersebut: timpa nilainya per mode dan tambahkan variabel sendiri"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "File .figcraft dengan nama ini sudah ada. Pilih nama lengkapnya untuk mengonfirmasi penggantian."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Satu layer saja diratakan ke frame-nya: pilih lebih banyak layer, atau satu layer di dalam frame"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Halaman produk responsif: hero, fitur, angka, kutipan, dan footer"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Spring berjalan hingga stabil: massa, kekakuan, dan redamannya menentukan durasinya"
|
||||
@@ -270,6 +271,7 @@ status = "draft — needs native review"
|
||||
"Commits" = "Commit"
|
||||
"Company" = "Perusahaan"
|
||||
"Compare changes" = "Bandingkan perubahan"
|
||||
"Compatibility notes" = "Catatan kompatibilitas"
|
||||
"Completed" = "Selesai"
|
||||
"Component" = "Component"
|
||||
"Component name" = "Nama component"
|
||||
@@ -476,6 +478,7 @@ status = "draft — needs native review"
|
||||
"False" = "Salah"
|
||||
"Feb" = "Feb"
|
||||
"Fetching from the server…" = "Mengambil dari server…"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft belum mendukung semua fitur file ini. Catatan berisi apa yang dihilangkan atau diubah pada salinan yang diimpor; file aslinya tidak berubah."
|
||||
"File" = "File"
|
||||
"File name" = "Nama file"
|
||||
"File not found" = "File tidak ditemukan"
|
||||
@@ -599,11 +602,14 @@ status = "draft — needs native review"
|
||||
"If {condition}" = "Jika {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Abaikan auto layout (posisi absolut)"
|
||||
"Image" = "Gambar"
|
||||
"Images" = "Gambar"
|
||||
"Import" = "Impor"
|
||||
"Import path" = "Jalur impor"
|
||||
"Import plugin from manifest" = "Impor plugin dari manifest"
|
||||
"Import plugin from manifest…" = "Impor plugin dari manifest…"
|
||||
"Import report" = "Laporan impor"
|
||||
"Import variables" = "Impor variabel"
|
||||
"Imported document" = "Dokumen yang diimpor"
|
||||
"Imported {name}" = "{name} diimpor"
|
||||
"Imported {n} new variables, updated {updated}" = { other = "{n} variabel baru diimpor, {updated} diperbarui" }
|
||||
"Import…" = "Impor…"
|
||||
@@ -805,6 +811,7 @@ status = "draft — needs native review"
|
||||
"No colour variables fit here." = "Tidak ada variabel warna yang cocok di sini."
|
||||
"No colour variables yet. Create them in Local variables." = "Belum ada variabel warna. Buat di Variabel lokal."
|
||||
"No comments yet" = "Belum ada komentar"
|
||||
"No compatibility warnings reported" = "Tidak ada peringatan kompatibilitas"
|
||||
"No components yet. Select layers and press {keys}." = "Belum ada component. Pilih layer lalu tekan {keys}."
|
||||
"No conflicts." = "Tidak ada konflik."
|
||||
"No contributor data was built into this copy." = "Tidak ada data kontributor yang disertakan dalam salinan ini."
|
||||
@@ -896,6 +903,7 @@ status = "draft — needs native review"
|
||||
"Open overlay" = "Buka overlay"
|
||||
"Open prototype preview" = "Buka pratinjau prototype"
|
||||
"Open variables ({count})" = "Buka variabel ({count})"
|
||||
"Opened as a new document" = "Dibuka sebagai dokumen baru"
|
||||
"Opened “{name}” in a new tab" = "“{name}” dibuka di tab baru"
|
||||
"Open|comments" = "Terbuka"
|
||||
"Options" = "Opsi"
|
||||
@@ -920,6 +928,8 @@ status = "draft — needs native review"
|
||||
"Padding" = "Padding"
|
||||
"Page" = "Halaman"
|
||||
"Pages" = "Halaman"
|
||||
"Pages added to the document" = "Halaman ditambahkan ke dokumen"
|
||||
"Placed on the current page" = "Ditempatkan di halaman saat ini"
|
||||
"Pages: {pages}" = "Halaman: {pages}"
|
||||
"Paint bucket" = "Ember cat"
|
||||
"Pan the canvas" = "Geser kanvas"
|
||||
@@ -934,6 +944,7 @@ status = "draft — needs native review"
|
||||
"Paste a team library link (a view link)" = "Tempel link pustaka tim (link akses lihat)"
|
||||
"Paste here" = "Tempel di sini"
|
||||
"Paste to replace" = "Tempel dan ganti"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { other = "Layer desain ditempel; {n} jenis konten tidak disertakan" }
|
||||
"Pattern" = "Pola"
|
||||
"Pattern alignment" = "Perataan pola"
|
||||
"Pattern scale" = "Skala pola"
|
||||
@@ -1397,6 +1408,7 @@ status = "draft — needs native review"
|
||||
"Use as mask" = "Gunakan sebagai mask"
|
||||
"Use auto layout" = "Gunakan auto layout"
|
||||
"Use number keys for opacity" = "Gunakan tombol angka untuk opasitas"
|
||||
"Use the .figcraft or .fig extension." = "Gunakan ekstensi .figcraft atau .fig."
|
||||
"Use the layer's fill" = "Gunakan fill layer"
|
||||
"Used in {files} · {instances}" = "Digunakan di {files} · {instances}"
|
||||
"Username" = "Nama pengguna"
|
||||
@@ -1489,26 +1501,89 @@ status = "draft — needs native review"
|
||||
"call any command its access allows" = "memanggil perintah apa pun yang diizinkan aksesnya"
|
||||
"call only: {commands}" = "hanya memanggil: {commands}"
|
||||
"can edit the document" = "dapat mengedit dokumen"
|
||||
"conditional prototype actions nested too deeply were skipped" = "tindakan prototype kondisional yang bersarang terlalu dalam dilewati"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "tindakan prototype kondisional dengan kondisi yang tidak dapat dievaluasi dilewati"
|
||||
"connector labels were skipped" = "label konektor dilewati"
|
||||
"connectors whose ends can't be placed are empty" = "konektor yang ujungnya tidak dapat ditempatkan kosong"
|
||||
"crop marks" = "tanda potong"
|
||||
"damaged vector geometry was skipped" = "geometri vector yang rusak dilewati"
|
||||
"damaged vector networks import as outlines" = "jaringan vector yang rusak diimpor sebagai outline"
|
||||
"development: {path}" = "pengembangan: {path}"
|
||||
"document children other than pages were skipped" = "anak dokumen selain halaman dilewati"
|
||||
"e.g. count + 1" = "mis. count + 1"
|
||||
"e.g. count >= 3 and not done" = "mis. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "konektor siku dan lengkung tanpa jalur tersimpan diimpor sebagai garis lurus"
|
||||
"entries without a node type were skipped" = "entri tanpa tipe node dilewati"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Link figcraft:// dari browser, chat, atau email membuka aplikasi ini dan bergabung ke sesi."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "area terisi pada vector dengan isian sendiri memakai isian layer"
|
||||
"from {server}" = "dari {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "pemicu gamepad yang tidak memiliki tepat satu tombol kontroler yang dikenal dilewati"
|
||||
"hidden" = "tersembunyi"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "properti tukar instance yang component bawaannya tidak ada di file dilewati"
|
||||
"instances inside their own main component were imported as frames" = "instance di dalam main component-nya sendiri diimpor sebagai frame"
|
||||
"instances of components that aren't in the file show no layers" = "instance dari component yang tidak ada di file tidak menampilkan layer"
|
||||
"instances of components that aren't in the file were imported as frames" = "instance dari component yang tidak ada di file diimpor sebagai frame"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instance yang akan menyarangkan component di dalam dirinya sendiri diimpor sebagai frame"
|
||||
"instances without a main component were imported as empty frames" = "instance tanpa main component diimpor sebagai frame kosong"
|
||||
"invalid or oversized vector networks import as outlines" = "jaringan vector yang tidak valid atau terlalu besar diimpor sebagai outline"
|
||||
"invalid or oversized vector path data was skipped" = "data jalur vector yang tidak valid atau terlalu besar dilewati"
|
||||
"just now" = "baru saja"
|
||||
"key triggers without a supported key were skipped" = "pemicu tombol tanpa tombol yang didukung dilewati"
|
||||
"key up triggers import as key presses" = "pemicu saat tombol dilepas diimpor sebagai penekanan tombol"
|
||||
"layers without a position were placed at their parent's origin" = "layer tanpa posisi ditempatkan di titik asal induknya"
|
||||
"layers without a type were skipped" = "layer tanpa tipe dilewati"
|
||||
"locked" = "terkunci"
|
||||
"no bleed" = "tanpa bleed"
|
||||
"nobody else here yet" = "belum ada orang lain di sini"
|
||||
"now" = "sekarang"
|
||||
"or" = "atau"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "sambungan stroke per titik dipertahankan; stroke putus-putus atau berlebar variabel digambar dengan sambungan layer"
|
||||
"prototype actions of unknown types were skipped" = "tindakan prototype dengan tipe yang tidak dikenal dilewati"
|
||||
"prototype actions without a destination in the file were skipped" = "tindakan prototype tanpa tujuan di file dilewati"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "ekspresi prototype pada variabel yang tidak ada di JSON variabel dilewati"
|
||||
"prototype expressions with unsupported functions were skipped" = "ekspresi prototype dengan fungsi yang tidak didukung dilewati"
|
||||
"prototype interactions without supported actions were skipped" = "interaksi prototype tanpa tindakan yang didukung dilewati"
|
||||
"prototype navigations of unknown types were skipped" = "navigasi prototype dengan tipe yang tidak dikenal dilewati"
|
||||
"prototype transitions of unknown types play instantly" = "transisi prototype dengan tipe yang tidak dikenal berjalan instan"
|
||||
"prototype triggers of unknown types were skipped" = "pemicu prototype dengan tipe yang tidak dikenal dilewati"
|
||||
"put a widget on the canvas" = "menaruh widget di kanvas"
|
||||
"read and edit the document" = "membaca dan mengedit dokumen"
|
||||
"read the document" = "membaca dokumen"
|
||||
"reads the document" = "membaca dokumen"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "tindakan atur variabel pada variabel yang tidak ada di JSON variabel dilewati"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "tindakan atur variabel dengan nilai yang tidak dapat dievaluasi dilewati"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "tindakan atur variabel dengan nilai yang tidak cocok dengan variabelnya dilewati"
|
||||
"set-variable actions without a value were skipped" = "tindakan atur variabel tanpa nilai dilewati"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "tindakan atur mode variabel pada koleksi atau mode yang tidak ada di JSON variabel dilewati"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "beberapa titik vector merujuk ke pengaturan titik yang tidak ada di file; titik tersebut memakai sambungan layer dan sudut biasa"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "bintang dan poligon tanpa jumlah titik atau geometri memakai jumlah titik bawaan"
|
||||
"text style overrides refer to missing table entries" = "penimpaan gaya teks merujuk ke entri tabel yang tidak ada"
|
||||
"the file has no pages" = "file tidak memiliki halaman"
|
||||
"the plugin" = "plugin"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "JSON variabel tidak memiliki `variableCollections` (yang diharapkan adalah respons variabel lokal) dan diabaikan"
|
||||
"this session only" = "hanya sesi ini"
|
||||
"unknown component property types were skipped" = "tipe properti component yang tidak dikenal dilewati"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "alias ke variabel yang tidak ada di JSON variabel (atau bertipe lain) dilewati"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "binding variabel ke properti yang tidak dimiliki layer dilewati"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "binding ke variabel yang tidak ada di JSON variabel (variabel pustaka) dilewati"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "binding variabel dilewati: JSON file tidak memiliki definisi variabel (berikan JSON variabel lokal sebagai `variables`, atau sematkan `meta`-nya di samping JSON file)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "binding variabel yang tipe variabelnya tidak cocok dengan properti dilewati"
|
||||
"variable collections without modes were skipped" = "koleksi variabel tanpa mode dilewati"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "pencarian mode variabel dalam ekspresi prototype yang bukan berupa variabel dan salah satu modenya dilewati"
|
||||
"variable modes of collections that aren't in the file were skipped" = "mode variabel dari koleksi yang tidak ada di file dilewati"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "mode variabel dari koleksi yang tidak ada di JSON variabel dilewati"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "nilai variabel yang tidak sesuai dengan tipenya (atau warna gabungan) dilewati"
|
||||
"variables of unknown types were skipped" = "variabel dengan tipe yang tidak dikenal dilewati"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "variabel yang koleksinya tidak ada di JSON variabel dilewati"
|
||||
"variables without readable values were skipped" = "variabel tanpa nilai yang dapat dibaca dilewati"
|
||||
"vector points with unknown stroke joins use the layer join" = "titik vector dengan sambungan stroke yang tidak dikenal memakai sambungan layer"
|
||||
"vectors without geometry are empty" = "vector tanpa geometri kosong"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vector tanpa geometri kosong (ekspor JSON file dengan geometry=paths)"
|
||||
"very long text was cut to 1,048,576 characters" = "teks yang sangat panjang dipotong menjadi 1.048.576 karakter"
|
||||
"very long vector paths were skipped" = "jalur vector yang sangat panjang dilewati"
|
||||
"video actions are not supported and were skipped" = "tindakan video tidak didukung dan dilewati"
|
||||
"video triggers are not supported and were skipped" = "pemicu video tidak didukung dan dilewati"
|
||||
"yesterday" = "kemarin"
|
||||
"{instances} in {files}" = "{instances} di {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: terjemahan draf, menunggu tinjauan penutur asli."
|
||||
@@ -1553,6 +1628,7 @@ status = "draft — needs native review"
|
||||
"{n} matches" = { other = "{n} kecocokan" }
|
||||
"{n} minutes ago" = { other = "{n} menit lalu" }
|
||||
"{n} months ago" = { other = "{n} bulan lalu" }
|
||||
"{n} more notes omitted from this report" = { other = "{n} catatan lainnya tidak dicantumkan dalam laporan ini" }
|
||||
"{n} of {total}" = "{n} dari {total}"
|
||||
"{n} people joined" = { other = "{n} orang bergabung" }
|
||||
"{n} preferred components" = { other = "{n} component pilihan" }
|
||||
@@ -1712,6 +1788,7 @@ status = "draft — needs native review"
|
||||
"edit.duplicate" = "Duplikat"
|
||||
"edit.findReplace" = "Cari dan ganti"
|
||||
"edit.paste" = "Tempel"
|
||||
"edit.pasteDesignLayers" = "Tempel layer desain"
|
||||
"edit.pasteOver" = "Tempel di atas pilihan"
|
||||
"edit.pasteProperties" = "Tempel properti"
|
||||
"edit.pasteReplace" = "Tempel dan ganti"
|
||||
|
||||
@@ -19,6 +19,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "この名前の .figcraft ファイルは既に存在します。置き換えるには、拡張子まで含む名前を選択。"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "単独のレイヤーはフレームに整列します:レイヤーを追加で選択するか、フレーム内のレイヤーを選択"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "レスポンシブな製品ページ:ヒーロー、機能、数値、引用、フッター"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる"
|
||||
@@ -272,6 +273,7 @@ status = "draft — needs native review"
|
||||
"Commits" = "コミット"
|
||||
"Company" = "会社"
|
||||
"Compare changes" = "変更を比較"
|
||||
"Compatibility notes" = "互換性に関する注記"
|
||||
"Completed" = "完了"
|
||||
"Component" = "コンポーネント"
|
||||
"Component name" = "コンポーネント名"
|
||||
@@ -480,6 +482,7 @@ status = "draft — needs native review"
|
||||
"Feb" = "2月"
|
||||
"Fetching from the server…" = "サーバーから取得中…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft のウィンドウを開けなかった:{error}"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft はこれらのファイルの機能をまだすべてサポートしていない。注記には、インポートしたコピーで省略または変更された内容を示す。元のファイルは変更されない。"
|
||||
"File" = "ファイル"
|
||||
"File name" = "ファイル名"
|
||||
"File not found" = "ファイルが見つからない"
|
||||
@@ -604,11 +607,14 @@ status = "draft — needs native review"
|
||||
"If {condition}" = "条件:{condition}"
|
||||
"Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)"
|
||||
"Image" = "画像"
|
||||
"Images" = "画像"
|
||||
"Import" = "インポート"
|
||||
"Import path" = "インポートパス"
|
||||
"Import plugin from manifest" = "マニフェストからプラグインをインポート"
|
||||
"Import plugin from manifest…" = "マニフェストからプラグインをインポート…"
|
||||
"Import report" = "インポートレポート"
|
||||
"Import variables" = "変数をインポート"
|
||||
"Imported document" = "インポートしたドキュメント"
|
||||
"Imported {name}" = "{name} をインポート済み"
|
||||
"Imported {n} new variables, updated {updated}" = { other = "新しい変数を {n} 個インポートし、{updated} 個を更新" }
|
||||
"Import…" = "インポート…"
|
||||
@@ -810,6 +816,7 @@ status = "draft — needs native review"
|
||||
"No colour variables fit here." = "ここに使えるカラー変数はない。"
|
||||
"No colour variables yet. Create them in Local variables." = "カラー変数はまだない。ローカル変数で作成。"
|
||||
"No comments yet" = "コメントはまだない"
|
||||
"No compatibility warnings reported" = "互換性の警告はない"
|
||||
"No components yet. Select layers and press {keys}." = "コンポーネントはまだない。レイヤーを選択して {keys} を押す。"
|
||||
"No conflicts." = "競合なし。"
|
||||
"No contributor data was built into this copy." = "このコピーには貢献者データが含まれていない。"
|
||||
@@ -901,6 +908,7 @@ status = "draft — needs native review"
|
||||
"Open overlay" = "オーバーレイを開く"
|
||||
"Open prototype preview" = "プロトタイプのプレビューを開く"
|
||||
"Open variables ({count})" = "変数を開く({count})"
|
||||
"Opened as a new document" = "新しいドキュメントとして開いた"
|
||||
"Opened “{name}” in a new tab" = "「{name}」を新しいタブで開いた"
|
||||
"Open|comments" = "未解決"
|
||||
"Options" = "オプション"
|
||||
@@ -925,6 +933,8 @@ status = "draft — needs native review"
|
||||
"Padding" = "パディング"
|
||||
"Page" = "ページ"
|
||||
"Pages" = "ページ"
|
||||
"Pages added to the document" = "ページをドキュメントに追加した"
|
||||
"Placed on the current page" = "現在のページに配置した"
|
||||
"Pages: {pages}" = "ページ:{pages}"
|
||||
"Paint bucket" = "塗りつぶし"
|
||||
"Pan the canvas" = "キャンバスをパン"
|
||||
@@ -939,6 +949,7 @@ status = "draft — needs native review"
|
||||
"Paste a team library link (a view link)" = "チームライブラリのリンク(閲覧リンク)を貼り付け"
|
||||
"Paste here" = "ここに貼り付け"
|
||||
"Paste to replace" = "貼り付けて置換"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { other = "デザインレイヤーを貼り付け済み。{n} 種類のコンテンツは含まれていない" }
|
||||
"Pattern" = "パターン"
|
||||
"Pattern alignment" = "パターンの配置"
|
||||
"Pattern scale" = "パターンの拡大縮小"
|
||||
@@ -1406,6 +1417,7 @@ status = "draft — needs native review"
|
||||
"Use as mask" = "マスクとして使用"
|
||||
"Use auto layout" = "オートレイアウトを使用"
|
||||
"Use number keys for opacity" = "数字キーで不透明度を変更"
|
||||
"Use the .figcraft or .fig extension." = "拡張子は .figcraft または .fig を使用。"
|
||||
"Use the layer's fill" = "レイヤーの塗りを使用"
|
||||
"Used in {files} · {instances}" = "{files}で使用 · {instances}"
|
||||
"Username" = "ユーザー名"
|
||||
@@ -1499,26 +1511,89 @@ status = "draft — needs native review"
|
||||
"call any command its access allows" = "アクセス権で許可された任意のコマンドの呼び出し"
|
||||
"call only: {commands}" = "次のコマンドのみの呼び出し:{commands}"
|
||||
"can edit the document" = "ドキュメントを編集できる"
|
||||
"conditional prototype actions nested too deeply were skipped" = "入れ子が深すぎる条件付きプロトタイプアクションをスキップ"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "評価できない条件を持つ条件付きプロトタイプアクションをスキップ"
|
||||
"connector labels were skipped" = "コネクタのラベルをスキップ"
|
||||
"connectors whose ends can't be placed are empty" = "端点を配置できないコネクタは空"
|
||||
"crop marks" = "トンボ"
|
||||
"damaged vector geometry was skipped" = "破損したベクタージオメトリをスキップ"
|
||||
"damaged vector networks import as outlines" = "破損したベクターネットワークはアウトラインとしてインポート"
|
||||
"development: {path}" = "開発:{path}"
|
||||
"document children other than pages were skipped" = "ページ以外のドキュメントの子をスキップ"
|
||||
"e.g. count + 1" = "例:count + 1"
|
||||
"e.g. count >= 3 and not done" = "例:count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "保存されたパスのない折れ線・曲線コネクタは直線としてインポート"
|
||||
"entries without a node type were skipped" = "ノードタイプのない項目をスキップ"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "ブラウザー、チャット、メールからの figcraft:// リンクでこのアプリが開き、セッションに参加する。"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "独自の塗りを持つベクターの塗りつぶし領域にはレイヤーの塗りを使用"
|
||||
"from {server}" = "{server} から"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "既知のコントローラーボタンがちょうど 1 つではないゲームパッドトリガーをスキップ"
|
||||
"hidden" = "非表示"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "デフォルトのコンポーネントがファイルにないインスタンスの入れ替えプロパティをスキップ"
|
||||
"instances inside their own main component were imported as frames" = "自身のメインコンポーネント内にあるインスタンスはフレームとしてインポート"
|
||||
"instances of components that aren't in the file show no layers" = "ファイルにないコンポーネントのインスタンスにはレイヤーが表示されない"
|
||||
"instances of components that aren't in the file were imported as frames" = "ファイルにないコンポーネントのインスタンスはフレームとしてインポート"
|
||||
"instances that would nest a component inside itself were imported as frames" = "コンポーネントを自身の中に入れ子にしてしまうインスタンスはフレームとしてインポート"
|
||||
"instances without a main component were imported as empty frames" = "メインコンポーネントのないインスタンスは空のフレームとしてインポート"
|
||||
"invalid or oversized vector networks import as outlines" = "無効または大きすぎるベクターネットワークはアウトラインとしてインポート"
|
||||
"invalid or oversized vector path data was skipped" = "無効または大きすぎるベクターパスデータをスキップ"
|
||||
"just now" = "たった今"
|
||||
"key triggers without a supported key were skipped" = "サポートされているキーのないキートリガーをスキップ"
|
||||
"key up triggers import as key presses" = "キーを離すトリガーはキー押下としてインポート"
|
||||
"layers without a position were placed at their parent's origin" = "位置のないレイヤーは親の原点に配置"
|
||||
"layers without a type were skipped" = "タイプのないレイヤーをスキップ"
|
||||
"locked" = "ロック中"
|
||||
"no bleed" = "裁ち落としなし"
|
||||
"nobody else here yet" = "まだ他に誰もいない"
|
||||
"now" = "たった今"
|
||||
"or" = "または"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "ポイントごとの角の形状は保持した。破線や可変幅の線はレイヤーの角の形状で描画"
|
||||
"prototype actions of unknown types were skipped" = "不明なタイプのプロトタイプアクションをスキップ"
|
||||
"prototype actions without a destination in the file were skipped" = "ファイル内に移動先のないプロトタイプアクションをスキップ"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "変数 JSON にない変数を使うプロトタイプの式をスキップ"
|
||||
"prototype expressions with unsupported functions were skipped" = "サポートされていない関数を使うプロトタイプの式をスキップ"
|
||||
"prototype interactions without supported actions were skipped" = "サポートされているアクションのないプロトタイプインタラクションをスキップ"
|
||||
"prototype navigations of unknown types were skipped" = "不明なタイプのプロトタイプナビゲーションをスキップ"
|
||||
"prototype transitions of unknown types play instantly" = "不明なタイプのプロトタイプトランジションは即時に再生"
|
||||
"prototype triggers of unknown types were skipped" = "不明なタイプのプロトタイプトリガーをスキップ"
|
||||
"put a widget on the canvas" = "キャンバスへのウィジェットの配置"
|
||||
"read and edit the document" = "ドキュメントの読み取りと編集"
|
||||
"read the document" = "ドキュメントの読み取り"
|
||||
"reads the document" = "ドキュメントを読み取る"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "変数 JSON にない変数に対する変数設定アクションをスキップ"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "評価できない値を持つ変数設定アクションをスキップ"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "変数に合わない値を持つ変数設定アクションをスキップ"
|
||||
"set-variable actions without a value were skipped" = "値のない変数設定アクションをスキップ"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "変数 JSON にないコレクションまたはモードに対する変数モード設定アクションをスキップ"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "一部のベクターポイントがファイルにないポイント設定を参照している。それらのポイントはレイヤーの角の形状と丸めのない角を使用"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "ポイント数もジオメトリもない星と多角形はデフォルトのポイント数を使用"
|
||||
"text style overrides refer to missing table entries" = "テキストスタイルのオーバーライドが存在しないテーブル項目を参照している"
|
||||
"the file has no pages" = "ファイルにページがない"
|
||||
"the plugin" = "このプラグイン"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "変数 JSON に `variableCollections` がない(ローカル変数のレスポンスを想定)ため無視した"
|
||||
"this session only" = "このセッションのみ"
|
||||
"unknown component property types were skipped" = "不明なタイプのコンポーネントプロパティをスキップ"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "変数 JSON にない(または型が異なる)変数へのエイリアスをスキップ"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "レイヤーにないプロパティへの変数バインディングをスキップ"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "変数 JSON にない変数(ライブラリ変数)へのバインディングをスキップ"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "変数バインディングをスキップ:ファイル JSON に変数の定義がない(ローカル変数 JSON を `variables` として渡すか、その `meta` をファイル JSON の隣に埋め込む)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "変数の型がプロパティに合わない変数バインディングをスキップ"
|
||||
"variable collections without modes were skipped" = "モードのない変数コレクションをスキップ"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "変数とそのモードの 1 つになっていない、プロトタイプの式内の変数モード参照をスキップ"
|
||||
"variable modes of collections that aren't in the file were skipped" = "ファイルにないコレクションの変数モードをスキップ"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "変数 JSON にないコレクションの変数モードをスキップ"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "型に合わない変数値(または合成カラー)をスキップ"
|
||||
"variables of unknown types were skipped" = "不明なタイプの変数をスキップ"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "コレクションが変数 JSON にない変数をスキップ"
|
||||
"variables without readable values were skipped" = "読み取れる値のない変数をスキップ"
|
||||
"vector points with unknown stroke joins use the layer join" = "不明な角の形状を持つベクターポイントはレイヤーの角の形状を使用"
|
||||
"vectors without geometry are empty" = "ジオメトリのないベクターは空"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "ジオメトリのないベクターは空(geometry=paths を指定してファイル JSON を書き出す)"
|
||||
"very long text was cut to 1,048,576 characters" = "非常に長いテキストを 1,048,576 文字に切り詰めた"
|
||||
"very long vector paths were skipped" = "非常に長いベクターパスをスキップ"
|
||||
"video actions are not supported and were skipped" = "ビデオアクションはサポートされていないためスキップ"
|
||||
"video triggers are not supported and were skipped" = "ビデオトリガーはサポートされていないためスキップ"
|
||||
"yesterday" = "昨日"
|
||||
"{instances} in {files}" = "{files}内の{instances}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}:下書きの翻訳。ネイティブスピーカーによるレビュー待ち。"
|
||||
@@ -1563,6 +1638,7 @@ status = "draft — needs native review"
|
||||
"{n} matches" = { other = "{n} 件一致" }
|
||||
"{n} minutes ago" = { other = "{n} 分前" }
|
||||
"{n} months ago" = { other = "{n} か月前" }
|
||||
"{n} more notes omitted from this report" = { other = "このレポートで省略した注記がさらに {n} 件" }
|
||||
"{n} of {total}" = "{n} / {total}"
|
||||
"{n} people joined" = { other = "{n} 人が参加済み" }
|
||||
"{n} preferred components" = { other = "優先コンポーネント {n} 個" }
|
||||
@@ -1723,6 +1799,7 @@ status = "draft — needs native review"
|
||||
"edit.duplicate" = "複製"
|
||||
"edit.findReplace" = "検索と置換"
|
||||
"edit.paste" = "貼り付け"
|
||||
"edit.pasteDesignLayers" = "デザインレイヤーを貼り付け"
|
||||
"edit.pasteOver" = "選択範囲の上に貼り付け"
|
||||
"edit.pasteProperties" = "プロパティを貼り付け"
|
||||
"edit.pasteReplace" = "貼り付けて置換"
|
||||
|
||||
@@ -18,6 +18,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR {prs}개, 커밋 {commits}개, +{added} / −{deleted}줄 (Δ {delta}), 바이너리 에셋 +{bin_added} / −{bin_deleted}, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "이 파일의 컬렉션과 그 모드 및 변수: 모드별로 값을 재정의하고 자체 변수를 추가할 수 있음"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "이 이름의 .figcraft 파일이 이미 있습니다. 바꾸려면 확장자까지 포함한 전체 이름을 선택하세요."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "레이어 하나는 프레임에 맞춰 정렬됩니다: 레이어를 더 선택하거나 프레임 안의 레이어를 선택하세요"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "스프링은 멈출 때까지 재생됨. 길이는 질량, 강성, 감쇠로 결정됨"
|
||||
"About" = "정보"
|
||||
@@ -261,6 +262,7 @@ status = "draft — needs native review"
|
||||
"Commits" = "커밋"
|
||||
"Company" = "회사"
|
||||
"Compare changes" = "변경 사항 비교"
|
||||
"Compatibility notes" = "호환성 참고 사항"
|
||||
"Completed" = "완료됨"
|
||||
"Component" = "컴포넌트"
|
||||
"Component name" = "컴포넌트 이름"
|
||||
@@ -460,6 +462,7 @@ status = "draft — needs native review"
|
||||
"False" = "거짓"
|
||||
"Fetching from the server…" = "서버에서 가져오는 중…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft 창을 열 수 없음: {error}"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft는 아직 이 파일의 모든 기능을 지원하지 않습니다. 참고 사항에는 가져온 사본에서 빠지거나 바뀐 내용이 나와 있으며, 원본 파일은 바뀌지 않습니다."
|
||||
"File" = "파일"
|
||||
"File name" = "파일 이름"
|
||||
"File published" = "파일 게시됨"
|
||||
@@ -576,11 +579,14 @@ status = "draft — needs native review"
|
||||
"If {condition}" = "조건: {condition}"
|
||||
"Ignore auto layout (absolute position)" = "자동 레이아웃 무시(절대 위치)"
|
||||
"Image" = "이미지"
|
||||
"Images" = "이미지"
|
||||
"Import" = "가져오기"
|
||||
"Import path" = "가져오기 경로"
|
||||
"Import plugin from manifest" = "매니페스트에서 플러그인 가져오기"
|
||||
"Import plugin from manifest…" = "매니페스트에서 플러그인 가져오기…"
|
||||
"Import report" = "가져오기 보고서"
|
||||
"Import variables" = "변수 가져오기"
|
||||
"Imported document" = "가져온 문서"
|
||||
"Imported {name}" = "{name} 가져옴"
|
||||
"Imported {n} new variables, updated {updated}" = { other = "새 변수 {n}개를 가져오고 {updated}개를 업데이트함" }
|
||||
"Import…" = "가져오기…"
|
||||
@@ -770,6 +776,7 @@ status = "draft — needs native review"
|
||||
"No colour variables fit here." = "여기에 맞는 색상 변수가 없음."
|
||||
"No colour variables yet. Create them in Local variables." = "아직 색상 변수가 없음. 로컬 변수에서 만드세요."
|
||||
"No comments yet" = "아직 댓글 없음"
|
||||
"No compatibility warnings reported" = "보고된 호환성 경고 없음"
|
||||
"No components yet. Select layers and press {keys}." = "아직 컴포넌트가 없음. 레이어를 선택하고 {keys}를 누르세요."
|
||||
"No conflicts." = "충돌 없음."
|
||||
"No contributor data was built into this copy." = "이 사본에는 기여자 데이터가 포함되지 않음."
|
||||
@@ -856,6 +863,7 @@ status = "draft — needs native review"
|
||||
"Open overlay" = "오버레이 열기"
|
||||
"Open prototype preview" = "프로토타입 미리 보기 열기"
|
||||
"Open variables ({count})" = "변수 열기({count})"
|
||||
"Opened as a new document" = "새 문서로 열림"
|
||||
"Opened “{name}” in a new tab" = "\"{name}\"을(를) 새 탭에서 열었음"
|
||||
"Open|comments" = "미해결"
|
||||
"Options" = "옵션"
|
||||
@@ -879,6 +887,8 @@ status = "draft — needs native review"
|
||||
"Padding" = "패딩"
|
||||
"Page" = "페이지"
|
||||
"Pages" = "페이지"
|
||||
"Pages added to the document" = "문서에 페이지 추가됨"
|
||||
"Placed on the current page" = "현재 페이지에 배치됨"
|
||||
"Pages: {pages}" = "페이지: {pages}"
|
||||
"Paint bucket" = "페인트 통"
|
||||
"Pan the canvas" = "캔버스 이동"
|
||||
@@ -893,6 +903,7 @@ status = "draft — needs native review"
|
||||
"Paste a team library link (a view link)" = "팀 라이브러리 링크(보기 링크) 붙여 넣기"
|
||||
"Paste here" = "여기에 붙여 넣기"
|
||||
"Paste to replace" = "붙여 넣어 바꾸기"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { other = "디자인 레이어를 붙여 넣음. 콘텐츠 {n}종은 제외됨" }
|
||||
"Pattern" = "패턴"
|
||||
"Pattern alignment" = "패턴 정렬"
|
||||
"Pattern scale" = "패턴 배율"
|
||||
@@ -1338,6 +1349,7 @@ status = "draft — needs native review"
|
||||
"Use as mask" = "마스크로 사용"
|
||||
"Use auto layout" = "자동 레이아웃 사용"
|
||||
"Use number keys for opacity" = "숫자 키로 불투명도 변경"
|
||||
"Use the .figcraft or .fig extension." = ".figcraft 또는 .fig 확장자를 사용하세요."
|
||||
"Use the layer's fill" = "레이어의 채우기 사용"
|
||||
"Used in {files} · {instances}" = "{files}에서 사용 · {instances}"
|
||||
"Username" = "사용자 이름"
|
||||
@@ -1426,25 +1438,88 @@ status = "draft — needs native review"
|
||||
"call any command its access allows" = "접근 권한이 허용하는 모든 명령 호출"
|
||||
"call only: {commands}" = "다음 명령만 호출: {commands}"
|
||||
"can edit the document" = "문서를 편집할 수 있음"
|
||||
"conditional prototype actions nested too deeply were skipped" = "너무 깊게 중첩된 조건부 프로토타입 액션을 건너뜀"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "평가할 수 없는 조건이 있는 조건부 프로토타입 액션을 건너뜀"
|
||||
"connector labels were skipped" = "커넥터 레이블을 건너뜀"
|
||||
"connectors whose ends can't be placed are empty" = "끝점을 배치할 수 없는 커넥터는 비어 있음"
|
||||
"crop marks" = "재단선 표시"
|
||||
"damaged vector geometry was skipped" = "손상된 벡터 지오메트리를 건너뜀"
|
||||
"damaged vector networks import as outlines" = "손상된 벡터 네트워크는 윤곽선으로 가져옴"
|
||||
"development: {path}" = "개발: {path}"
|
||||
"document children other than pages were skipped" = "페이지가 아닌 문서 하위 항목을 건너뜀"
|
||||
"e.g. count + 1" = "예: count + 1"
|
||||
"e.g. count >= 3 and not done" = "예: count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "저장된 경로가 없는 꺾인 커넥터와 곡선 커넥터는 직선으로 가져옴"
|
||||
"entries without a node type were skipped" = "노드 유형이 없는 항목을 건너뜀"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "브라우저, 채팅, 이메일의 figcraft:// 링크로 이 앱을 열고 세션에 참여함."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "자체 채우기가 있는 벡터의 채워진 영역은 레이어의 채우기를 사용함"
|
||||
"from {server}" = "{server}에서"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "알려진 컨트롤러 버튼이 정확히 하나가 아닌 게임패드 트리거를 건너뜀"
|
||||
"hidden" = "숨김"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "기본 컴포넌트가 파일에 없는 인스턴스 교체 속성을 건너뜀"
|
||||
"instances inside their own main component were imported as frames" = "자체 메인 컴포넌트 안에 있는 인스턴스는 프레임으로 가져옴"
|
||||
"instances of components that aren't in the file show no layers" = "파일에 없는 컴포넌트의 인스턴스에는 레이어가 표시되지 않음"
|
||||
"instances of components that aren't in the file were imported as frames" = "파일에 없는 컴포넌트의 인스턴스는 프레임으로 가져옴"
|
||||
"instances that would nest a component inside itself were imported as frames" = "컴포넌트를 자기 안에 중첩하게 되는 인스턴스는 프레임으로 가져옴"
|
||||
"instances without a main component were imported as empty frames" = "메인 컴포넌트가 없는 인스턴스는 빈 프레임으로 가져옴"
|
||||
"invalid or oversized vector networks import as outlines" = "잘못되었거나 너무 큰 벡터 네트워크는 윤곽선으로 가져옴"
|
||||
"invalid or oversized vector path data was skipped" = "잘못되었거나 너무 큰 벡터 경로 데이터를 건너뜀"
|
||||
"key triggers without a supported key were skipped" = "지원되는 키가 없는 키 트리거를 건너뜀"
|
||||
"key up triggers import as key presses" = "키를 뗄 때 트리거는 키 누름으로 가져옴"
|
||||
"layers without a position were placed at their parent's origin" = "위치가 없는 레이어는 상위 요소의 원점에 배치됨"
|
||||
"layers without a type were skipped" = "유형이 없는 레이어를 건너뜀"
|
||||
"locked" = "잠김"
|
||||
"no bleed" = "도련 없음"
|
||||
"nobody else here yet" = "아직 다른 사람이 없음"
|
||||
"now" = "방금"
|
||||
"or" = "또는"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "점별 선 연결을 유지함. 파선이나 가변 너비 선은 레이어의 선 연결로 그려짐"
|
||||
"prototype actions of unknown types were skipped" = "알 수 없는 유형의 프로토타입 액션을 건너뜀"
|
||||
"prototype actions without a destination in the file were skipped" = "파일에 대상이 없는 프로토타입 액션을 건너뜀"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "변수 JSON에 없는 변수에 대한 프로토타입 수식을 건너뜀"
|
||||
"prototype expressions with unsupported functions were skipped" = "지원되지 않는 함수가 있는 프로토타입 수식을 건너뜀"
|
||||
"prototype interactions without supported actions were skipped" = "지원되는 액션이 없는 프로토타입 인터랙션을 건너뜀"
|
||||
"prototype navigations of unknown types were skipped" = "알 수 없는 유형의 프로토타입 탐색을 건너뜀"
|
||||
"prototype transitions of unknown types play instantly" = "알 수 없는 유형의 프로토타입 전환은 즉시 재생됨"
|
||||
"prototype triggers of unknown types were skipped" = "알 수 없는 유형의 프로토타입 트리거를 건너뜀"
|
||||
"put a widget on the canvas" = "캔버스에 위젯 배치"
|
||||
"read and edit the document" = "문서 읽기 및 편집"
|
||||
"read the document" = "문서 읽기"
|
||||
"reads the document" = "문서를 읽음"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "변수 JSON에 없는 변수에 대한 변수 설정 액션을 건너뜀"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "평가할 수 없는 값이 있는 변수 설정 액션을 건너뜀"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "변수에 맞지 않는 값이 있는 변수 설정 액션을 건너뜀"
|
||||
"set-variable actions without a value were skipped" = "값이 없는 변수 설정 액션을 건너뜀"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "변수 JSON에 없는 컬렉션이나 모드에 대한 변수 모드 설정 액션을 건너뜀"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "일부 벡터 점이 파일에 없는 점 설정을 참조함. 해당 점은 레이어의 선 연결과 둥글리지 않은 모서리를 사용함"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "점 개수나 지오메트리가 없는 별과 다각형은 기본 점 개수를 사용함"
|
||||
"text style overrides refer to missing table entries" = "텍스트 스타일 재정의가 없는 테이블 항목을 참조함"
|
||||
"the file has no pages" = "파일에 페이지가 없음"
|
||||
"the plugin" = "이 플러그인"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "변수 JSON에 `variableCollections`가 없어(로컬 변수 응답이어야 함) 무시함"
|
||||
"this session only" = "이 세션만"
|
||||
"unknown component property types were skipped" = "알 수 없는 컴포넌트 속성 유형을 건너뜀"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "변수 JSON에 없거나 유형이 다른 변수에 대한 별칭을 건너뜀"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "레이어에 없는 속성에 대한 변수 바인딩을 건너뜀"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "변수 JSON에 없는 변수(라이브러리 변수)에 대한 바인딩을 건너뜀"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "변수 바인딩을 건너뜀: 파일 JSON에 변수 정의가 없음(로컬 변수 JSON을 `variables`로 전달하거나 파일 JSON 옆에 해당 `meta`를 포함하세요)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "변수 유형이 속성에 맞지 않는 변수 바인딩을 건너뜀"
|
||||
"variable collections without modes were skipped" = "모드가 없는 변수 컬렉션을 건너뜀"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "변수와 그 모드 중 하나가 아닌, 프로토타입 수식의 변수 모드 조회를 건너뜀"
|
||||
"variable modes of collections that aren't in the file were skipped" = "파일에 없는 컬렉션의 변수 모드를 건너뜀"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "변수 JSON에 없는 컬렉션의 변수 모드를 건너뜀"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "유형과 맞지 않는 변수 값(또는 합성 색상)을 건너뜀"
|
||||
"variables of unknown types were skipped" = "알 수 없는 유형의 변수를 건너뜀"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "컬렉션이 변수 JSON에 없는 변수를 건너뜀"
|
||||
"variables without readable values were skipped" = "읽을 수 있는 값이 없는 변수를 건너뜀"
|
||||
"vector points with unknown stroke joins use the layer join" = "알 수 없는 선 연결이 있는 벡터 점은 레이어의 선 연결을 사용함"
|
||||
"vectors without geometry are empty" = "지오메트리가 없는 벡터는 비어 있음"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "지오메트리가 없는 벡터는 비어 있음(geometry=paths로 파일 JSON을 내보내세요)"
|
||||
"very long text was cut to 1,048,576 characters" = "매우 긴 텍스트를 1,048,576자로 잘랐음"
|
||||
"very long vector paths were skipped" = "매우 긴 벡터 경로를 건너뜀"
|
||||
"video actions are not supported and were skipped" = "비디오 액션은 지원되지 않아 건너뜀"
|
||||
"video triggers are not supported and were skipped" = "비디오 트리거는 지원되지 않아 건너뜀"
|
||||
"{instances} in {files}" = "{files}의 {instances}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: 초안 번역, 원어민 검토 대기 중."
|
||||
"{length} bleed" = "도련 {length}"
|
||||
@@ -1487,6 +1562,7 @@ status = "draft — needs native review"
|
||||
"{n} matches" = { other = "{n}개 일치" }
|
||||
"{n} minutes ago" = { other = "{n}분 전" }
|
||||
"{n} months ago" = { other = "{n}개월 전" }
|
||||
"{n} more notes omitted from this report" = { other = "참고 사항 {n}개가 이 보고서에서 더 생략됨" }
|
||||
"{n} of {total}" = "{n} / {total}"
|
||||
"{n} people joined" = { other = "{n}명 참여함" }
|
||||
"{n} preferred components" = { other = "선호 컴포넌트 {n}개" }
|
||||
@@ -1645,6 +1721,7 @@ status = "draft — needs native review"
|
||||
"edit.duplicate" = "복제"
|
||||
"edit.findReplace" = "찾기 및 바꾸기"
|
||||
"edit.paste" = "붙여 넣기"
|
||||
"edit.pasteDesignLayers" = "디자인 레이어 붙여 넣기"
|
||||
"edit.pasteOver" = "선택 항목 위에 붙여 넣기"
|
||||
"edit.pasteProperties" = "속성 붙여 넣기"
|
||||
"edit.pasteReplace" = "붙여 넣어 바꾸기"
|
||||
|
||||
@@ -16,6 +16,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{bin_added} / −{bin_deleted} arquivos binários, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Uma coleção deste arquivo com estes modos e variáveis: substitua os valores por modo e adicione variáveis próprias"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Já existe um arquivo .figcraft com este nome. Escolha o nome completo dele para confirmar a substituição."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Uma camada sozinha se alinha ao seu quadro: selecione mais camadas ou uma dentro de um quadro"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Uma mola se move até se estabilizar: sua massa, rigidez e amortecimento definem a duração"
|
||||
"About" = "Sobre"
|
||||
@@ -259,6 +260,7 @@ status = "draft — needs native review"
|
||||
"Commits" = "Commits"
|
||||
"Company" = "Empresa"
|
||||
"Compare changes" = "Comparar alterações"
|
||||
"Compatibility notes" = "Notas de compatibilidade"
|
||||
"Completed" = "Concluído"
|
||||
"Component" = "Componente"
|
||||
"Component name" = "Nome do componente"
|
||||
@@ -459,6 +461,7 @@ status = "draft — needs native review"
|
||||
"False" = "Falso"
|
||||
"Fetching from the server…" = "Buscando no servidor…"
|
||||
"FigCraft couldn't open its window: {error}" = "O FigCraft não conseguiu abrir a janela: {error}"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "O FigCraft ainda não oferece suporte a todos os recursos desses arquivos. As notas listam o que foi omitido ou alterado na cópia importada; o arquivo original não é alterado."
|
||||
"File" = "Arquivo"
|
||||
"File name" = "Nome do arquivo"
|
||||
"File published" = "Arquivo publicado"
|
||||
@@ -575,11 +578,14 @@ status = "draft — needs native review"
|
||||
"If {condition}" = "Se {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Ignorar layout automático (posição absoluta)"
|
||||
"Image" = "Imagem"
|
||||
"Images" = "Imagens"
|
||||
"Import" = "Importar"
|
||||
"Import path" = "Caminho de importação"
|
||||
"Import plugin from manifest" = "Importar plugin a partir do manifesto"
|
||||
"Import plugin from manifest…" = "Importar plugin a partir do manifesto…"
|
||||
"Import report" = "Relatório de importação"
|
||||
"Import variables" = "Importar variáveis"
|
||||
"Imported document" = "Documento importado"
|
||||
"Imported {name}" = "{name} importado"
|
||||
"Imported {n} new variables, updated {updated}" = { one = "{n} variável nova importada, {updated} atualizadas", other = "{n} variáveis novas importadas, {updated} atualizadas" }
|
||||
"Import…" = "Importar…"
|
||||
@@ -769,6 +775,7 @@ status = "draft — needs native review"
|
||||
"No colour variables fit here." = "Nenhuma variável de cor se encaixa aqui."
|
||||
"No colour variables yet. Create them in Local variables." = "Ainda não há variáveis de cor. Crie-as em Variáveis locais."
|
||||
"No comments yet" = "Ainda não há comentários"
|
||||
"No compatibility warnings reported" = "Nenhum aviso de compatibilidade relatado"
|
||||
"No components yet. Select layers and press {keys}." = "Ainda não há componentes. Selecione camadas e pressione {keys}."
|
||||
"No conflicts." = "Nenhum conflito."
|
||||
"No contributor data was built into this copy." = "Esta cópia não inclui dados de colaboradores."
|
||||
@@ -855,6 +862,7 @@ status = "draft — needs native review"
|
||||
"Open overlay" = "Abrir sobreposição"
|
||||
"Open prototype preview" = "Abrir visualização do protótipo"
|
||||
"Open variables ({count})" = "Abrir variáveis ({count})"
|
||||
"Opened as a new document" = "Aberto como novo documento"
|
||||
"Opened “{name}” in a new tab" = "“{name}” aberto em uma nova aba"
|
||||
"Open|comments" = "Abertos"
|
||||
"Options" = "Opções"
|
||||
@@ -878,6 +886,8 @@ status = "draft — needs native review"
|
||||
"Padding" = "Espaçamento interno"
|
||||
"Page" = "Página"
|
||||
"Pages" = "Páginas"
|
||||
"Pages added to the document" = "Páginas adicionadas ao documento"
|
||||
"Placed on the current page" = "Colocado na página atual"
|
||||
"Pages: {pages}" = "Páginas: {pages}"
|
||||
"Paint bucket" = "Balde de tinta"
|
||||
"Pan the canvas" = "Mover a tela"
|
||||
@@ -892,6 +902,7 @@ status = "draft — needs native review"
|
||||
"Paste a team library link (a view link)" = "Cole o link de uma biblioteca de equipe (um link de visualização)"
|
||||
"Paste here" = "Colar aqui"
|
||||
"Paste to replace" = "Colar para substituir"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { one = "Camadas de design coladas; {n} tipo de conteúdo ficou de fora", other = "Camadas de design coladas; {n} tipos de conteúdo ficaram de fora" }
|
||||
"Pattern" = "Padrão"
|
||||
"Pattern alignment" = "Alinhamento do padrão"
|
||||
"Pattern scale" = "Escala do padrão"
|
||||
@@ -1337,6 +1348,7 @@ status = "draft — needs native review"
|
||||
"Use as mask" = "Usar como máscara"
|
||||
"Use auto layout" = "Usar layout automático"
|
||||
"Use number keys for opacity" = "Usar teclas numéricas para a opacidade"
|
||||
"Use the .figcraft or .fig extension." = "Use a extensão .figcraft ou .fig."
|
||||
"Use the layer's fill" = "Usar o preenchimento da camada"
|
||||
"Used in {files} · {instances}" = "Usado em {files} · {instances}"
|
||||
"Username" = "Nome de usuário"
|
||||
@@ -1425,25 +1437,88 @@ status = "draft — needs native review"
|
||||
"call any command its access allows" = "chamar qualquer comando que seu acesso permita"
|
||||
"call only: {commands}" = "chamar apenas: {commands}"
|
||||
"can edit the document" = "pode editar o documento"
|
||||
"conditional prototype actions nested too deeply were skipped" = "ações condicionais de protótipo aninhadas profundamente demais foram ignoradas"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "ações condicionais de protótipo com condições que não podem ser avaliadas foram ignoradas"
|
||||
"connector labels were skipped" = "rótulos de conectores foram ignorados"
|
||||
"connectors whose ends can't be placed are empty" = "conectores cujas pontas não podem ser posicionadas ficam vazios"
|
||||
"crop marks" = "marcas de corte"
|
||||
"damaged vector geometry was skipped" = "geometria vetorial danificada foi ignorada"
|
||||
"damaged vector networks import as outlines" = "redes vetoriais danificadas são importadas como contornos"
|
||||
"development: {path}" = "desenvolvimento: {path}"
|
||||
"document children other than pages were skipped" = "filhos do documento que não são páginas foram ignorados"
|
||||
"e.g. count + 1" = "por ex. count + 1"
|
||||
"e.g. count >= 3 and not done" = "por ex. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "conectores angulares e curvos sem um caminho salvo são importados como linhas retas"
|
||||
"entries without a node type were skipped" = "entradas sem tipo de nó foram ignoradas"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Links figcraft:// de um navegador, chat ou e-mail abrem este app e entram na sessão."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "áreas preenchidas de um vetor com preenchimento próprio usam o preenchimento da camada"
|
||||
"from {server}" = "de {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "gatilhos de gamepad sem exatamente um botão de controle conhecido foram ignorados"
|
||||
"hidden" = "oculta"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "propriedades de troca de instância cujo componente padrão não está no arquivo foram ignoradas"
|
||||
"instances inside their own main component were imported as frames" = "instâncias dentro do próprio componente principal foram importadas como quadros"
|
||||
"instances of components that aren't in the file show no layers" = "instâncias de componentes que não estão no arquivo não mostram camadas"
|
||||
"instances of components that aren't in the file were imported as frames" = "instâncias de componentes que não estão no arquivo foram importadas como quadros"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instâncias que aninhariam um componente dentro dele mesmo foram importadas como quadros"
|
||||
"instances without a main component were imported as empty frames" = "instâncias sem componente principal foram importadas como quadros vazios"
|
||||
"invalid or oversized vector networks import as outlines" = "redes vetoriais inválidas ou grandes demais são importadas como contornos"
|
||||
"invalid or oversized vector path data was skipped" = "dados de caminho vetorial inválidos ou grandes demais foram ignorados"
|
||||
"key triggers without a supported key were skipped" = "gatilhos de tecla sem uma tecla compatível foram ignorados"
|
||||
"key up triggers import as key presses" = "gatilhos ao soltar uma tecla são importados como pressionamentos de tecla"
|
||||
"layers without a position were placed at their parent's origin" = "camadas sem posição foram colocadas na origem do elemento pai"
|
||||
"layers without a type were skipped" = "camadas sem tipo foram ignoradas"
|
||||
"locked" = "bloqueada"
|
||||
"no bleed" = "sem sangria"
|
||||
"nobody else here yet" = "ainda não há mais ninguém"
|
||||
"now" = "agora"
|
||||
"or" = "ou"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "as junções de traço por ponto foram mantidas; traços tracejados ou de largura variável são desenhados com a junção da camada"
|
||||
"prototype actions of unknown types were skipped" = "ações de protótipo de tipos desconhecidos foram ignoradas"
|
||||
"prototype actions without a destination in the file were skipped" = "ações de protótipo sem destino no arquivo foram ignoradas"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "expressões de protótipo com variáveis que não estão no JSON de variáveis foram ignoradas"
|
||||
"prototype expressions with unsupported functions were skipped" = "expressões de protótipo com funções sem suporte foram ignoradas"
|
||||
"prototype interactions without supported actions were skipped" = "interações de protótipo sem ações compatíveis foram ignoradas"
|
||||
"prototype navigations of unknown types were skipped" = "navegações de protótipo de tipos desconhecidos foram ignoradas"
|
||||
"prototype transitions of unknown types play instantly" = "transições de protótipo de tipos desconhecidos são instantâneas"
|
||||
"prototype triggers of unknown types were skipped" = "gatilhos de protótipo de tipos desconhecidos foram ignorados"
|
||||
"put a widget on the canvas" = "colocar um widget na tela"
|
||||
"read and edit the document" = "ler e editar o documento"
|
||||
"read the document" = "ler o documento"
|
||||
"reads the document" = "lê o documento"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "ações de definir variável em variáveis que não estão no JSON de variáveis foram ignoradas"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "ações de definir variável com valores que não podem ser avaliados foram ignoradas"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "ações de definir variável com valores que não cabem na variável foram ignoradas"
|
||||
"set-variable actions without a value were skipped" = "ações de definir variável sem valor foram ignoradas"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "ações de definir modo de variável em coleções ou modos que não estão no JSON de variáveis foram ignoradas"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "alguns pontos vetoriais se referem a configurações de ponto ausentes no arquivo; eles usam as junções da camada e cantos simples"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "estrelas e polígonos sem número de pontas nem geometria usam o número de pontas padrão"
|
||||
"text style overrides refer to missing table entries" = "substituições de estilo de texto se referem a entradas de tabela ausentes"
|
||||
"the file has no pages" = "o arquivo não tem páginas"
|
||||
"the plugin" = "o plugin"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "o JSON de variáveis não tem `variableCollections` (era esperada a resposta de variáveis locais) e foi ignorado"
|
||||
"this session only" = "somente nesta sessão"
|
||||
"unknown component property types were skipped" = "tipos desconhecidos de propriedade de componente foram ignorados"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "aliases para variáveis que não estão no JSON de variáveis (ou de outro tipo) foram ignorados"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "vínculos de variáveis a propriedades que a camada não tem foram ignorados"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "vínculos a variáveis que não estão no JSON de variáveis (variáveis de biblioteca) foram ignorados"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "vínculos de variáveis foram ignorados: o JSON do arquivo não tem definições de variáveis (passe o JSON de variáveis locais como `variables` ou incorpore o `meta` dele ao lado do JSON do arquivo)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "vínculos de variáveis cujo tipo de variável não combina com a propriedade foram ignorados"
|
||||
"variable collections without modes were skipped" = "coleções de variáveis sem modos foram ignoradas"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "consultas de modo de variável em expressões de protótipo que não são uma variável e um de seus modos foram ignoradas"
|
||||
"variable modes of collections that aren't in the file were skipped" = "modos de variáveis de coleções que não estão no arquivo foram ignorados"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "modos de variáveis de coleções que não estão no JSON de variáveis foram ignorados"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "valores de variáveis que não correspondem ao seu tipo (ou cores compostas) foram ignorados"
|
||||
"variables of unknown types were skipped" = "variáveis de tipos desconhecidos foram ignoradas"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "variáveis cuja coleção não está no JSON de variáveis foram ignoradas"
|
||||
"variables without readable values were skipped" = "variáveis sem valores legíveis foram ignoradas"
|
||||
"vector points with unknown stroke joins use the layer join" = "pontos vetoriais com junções de traço desconhecidas usam a junção da camada"
|
||||
"vectors without geometry are empty" = "vetores sem geometria ficam vazios"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vetores sem geometria ficam vazios (exporte o JSON do arquivo com geometry=paths)"
|
||||
"very long text was cut to 1,048,576 characters" = "textos muito longos foram cortados em 1.048.576 caracteres"
|
||||
"very long vector paths were skipped" = "caminhos vetoriais muito longos foram ignorados"
|
||||
"video actions are not supported and were skipped" = "ações de vídeo não têm suporte e foram ignoradas"
|
||||
"video triggers are not supported and were skipped" = "gatilhos de vídeo não têm suporte e foram ignorados"
|
||||
"{instances} in {files}" = "{instances} em {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: tradução em rascunho, aguardando revisão de um falante nativo."
|
||||
"{length} bleed" = "sangria de {length}"
|
||||
@@ -1486,6 +1561,7 @@ status = "draft — needs native review"
|
||||
"{n} matches" = { one = "{n} resultado", other = "{n} resultados" }
|
||||
"{n} minutes ago" = { one = "há {n} minuto", other = "há {n} minutos" }
|
||||
"{n} months ago" = { one = "há {n} mês", other = "há {n} meses" }
|
||||
"{n} more notes omitted from this report" = { one = "{n} nota a mais omitida deste relatório", other = "{n} notas a mais omitidas deste relatório" }
|
||||
"{n} of {total}" = "{n} de {total}"
|
||||
"{n} people joined" = { one = "{n} pessoa entrou", other = "{n} pessoas entraram" }
|
||||
"{n} preferred components" = { one = "{n} componente preferido", other = "{n} componentes preferidos" }
|
||||
@@ -1644,6 +1720,7 @@ status = "draft — needs native review"
|
||||
"edit.duplicate" = "Duplicar"
|
||||
"edit.findReplace" = "Localizar e substituir"
|
||||
"edit.paste" = "Colar"
|
||||
"edit.pasteDesignLayers" = "Colar camadas de design"
|
||||
"edit.pasteOver" = "Colar sobre a seleção"
|
||||
"edit.pasteProperties" = "Colar propriedades"
|
||||
"edit.pasteReplace" = "Colar para substituir"
|
||||
|
||||
@@ -15,6 +15,7 @@ name = "Русский"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, коммитов: {commits}, +{added} / −{deleted} строк (Δ {delta}), +{bin_added} / −{bin_deleted} бинарных ресурсов, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Коллекция этого файла с этими режимами и переменными: переопределяйте значения для каждого режима и добавляйте собственные переменные"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Файл .figcraft с таким именем уже существует. Чтобы подтвердить замену, выберите его полное имя."
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Адаптивная страница продукта: главный блок, возможности, показатели, цитата и подвал"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Заголовок, повестка, крупный показатель, диаграмма и заключительный слайд"
|
||||
@@ -267,6 +268,7 @@ name = "Русский"
|
||||
"Commits" = "Коммиты"
|
||||
"Company" = "Компания"
|
||||
"Compare changes" = "Сравнить изменения"
|
||||
"Compatibility notes" = "Замечания о совместимости"
|
||||
"Completed" = "Завершено"
|
||||
"Component" = "Компонент"
|
||||
"Component name" = "Имя компонента"
|
||||
@@ -473,6 +475,7 @@ name = "Русский"
|
||||
"False" = "Ложь"
|
||||
"Feb" = "февр."
|
||||
"Fetching from the server…" = "Загрузка с сервера…"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft пока поддерживает не все возможности этих файлов. В замечаниях перечислено, что было пропущено или изменено в импортированной копии; исходный файл не меняется."
|
||||
"File" = "Файл"
|
||||
"File name" = "Имя файла"
|
||||
"File not found" = "Файл не найден"
|
||||
@@ -596,11 +599,14 @@ name = "Русский"
|
||||
"If {condition}" = "Если {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Игнорировать автолейаут (абсолютная позиция)"
|
||||
"Image" = "Изображение"
|
||||
"Images" = "Изображения"
|
||||
"Import" = "Импорт"
|
||||
"Import path" = "Путь импорта"
|
||||
"Import plugin from manifest" = "Импортировать плагин из манифеста"
|
||||
"Import plugin from manifest…" = "Импортировать плагин из манифеста…"
|
||||
"Import report" = "Отчёт об импорте"
|
||||
"Import variables" = "Импортировать переменные"
|
||||
"Imported document" = "Импортированный документ"
|
||||
"Imported {name}" = "Импортировано: {name}"
|
||||
"Imported {n} new variables, updated {updated}" = { one = "Импортирована {n} новая переменная, обновлено: {updated}", few = "Импортированы {n} новые переменные, обновлено: {updated}", many = "Импортировано {n} новых переменных, обновлено: {updated}", other = "Импортировано {n} новой переменной, обновлено: {updated}" }
|
||||
"Import…" = "Импорт…"
|
||||
@@ -802,6 +808,7 @@ name = "Русский"
|
||||
"No colour variables fit here." = "Нет подходящих цветовых переменных."
|
||||
"No colour variables yet. Create them in Local variables." = "Цветовых переменных пока нет. Создайте их в разделе «Локальные переменные»."
|
||||
"No comments yet" = "Комментариев пока нет"
|
||||
"No compatibility warnings reported" = "Предупреждений о совместимости нет"
|
||||
"No components yet. Select layers and press {keys}." = "Компонентов пока нет. Выделите слои и нажмите {keys}."
|
||||
"No conflicts." = "Конфликтов нет."
|
||||
"No contributor data was built into this copy." = "В этой сборке нет данных об участниках."
|
||||
@@ -893,6 +900,7 @@ name = "Русский"
|
||||
"Open overlay" = "Открыть оверлей"
|
||||
"Open prototype preview" = "Открыть предпросмотр прототипа"
|
||||
"Open variables ({count})" = "Открыть переменные ({count})"
|
||||
"Opened as a new document" = "Открыт как новый документ"
|
||||
"Opened “{name}” in a new tab" = "«{name}» открыт в новой вкладке"
|
||||
"Open|comments" = "Открытые"
|
||||
"Options" = "Параметры"
|
||||
@@ -917,6 +925,8 @@ name = "Русский"
|
||||
"Padding" = "Внутренние отступы"
|
||||
"Page" = "Страница"
|
||||
"Pages" = "Страницы"
|
||||
"Pages added to the document" = "Страницы добавлены в документ"
|
||||
"Placed on the current page" = "Размещено на текущей странице"
|
||||
"Pages: {pages}" = "Страницы: {pages}"
|
||||
"Paint bucket" = "Заливка области"
|
||||
"Pan the canvas" = "Перемещение по холсту"
|
||||
@@ -931,6 +941,7 @@ name = "Русский"
|
||||
"Paste a team library link (a view link)" = "Вставьте ссылку на командную библиотеку (для просмотра)"
|
||||
"Paste here" = "Вставить здесь"
|
||||
"Paste to replace" = "Вставить с заменой"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { one = "Слои дизайна вставлены; пропущен {n} тип содержимого", few = "Слои дизайна вставлены; пропущены {n} типа содержимого", many = "Слои дизайна вставлены; пропущено {n} типов содержимого", other = "Слои дизайна вставлены; пропущено {n} типа содержимого" }
|
||||
"Pattern" = "Паттерн"
|
||||
"Pattern alignment" = "Выравнивание паттерна"
|
||||
"Pattern scale" = "Масштаб паттерна"
|
||||
@@ -1394,6 +1405,7 @@ name = "Русский"
|
||||
"Use as mask" = "Использовать как маску"
|
||||
"Use auto layout" = "Использовать автолейаут"
|
||||
"Use number keys for opacity" = "Задавать непрозрачность цифровыми клавишами"
|
||||
"Use the .figcraft or .fig extension." = "Используйте расширение .figcraft или .fig."
|
||||
"Use the layer's fill" = "Использовать заливку слоя"
|
||||
"Used in {files} · {instances}" = "Используется в {files} · {instances}"
|
||||
"Username" = "Имя пользователя"
|
||||
@@ -1486,26 +1498,89 @@ name = "Русский"
|
||||
"call any command its access allows" = "вызывать любые команды в рамках разрешений"
|
||||
"call only: {commands}" = "вызывать только: {commands}"
|
||||
"can edit the document" = "может редактировать документ"
|
||||
"conditional prototype actions nested too deeply were skipped" = "условные действия прототипа со слишком глубокой вложенностью пропущены"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "условные действия прототипа с условиями, которые нельзя вычислить, пропущены"
|
||||
"connector labels were skipped" = "подписи соединительных линий пропущены"
|
||||
"connectors whose ends can't be placed are empty" = "соединительные линии, концы которых нельзя разместить, пусты"
|
||||
"crop marks" = "метки обреза"
|
||||
"damaged vector geometry was skipped" = "повреждённая векторная геометрия пропущена"
|
||||
"damaged vector networks import as outlines" = "повреждённые векторные сети импортируются как контуры"
|
||||
"development: {path}" = "разработка: {path}"
|
||||
"document children other than pages were skipped" = "дочерние элементы документа, не являющиеся страницами, пропущены"
|
||||
"e.g. count + 1" = "например, count + 1"
|
||||
"e.g. count >= 3 and not done" = "например, count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "ломаные и изогнутые соединительные линии без сохранённого пути импортируются как прямые"
|
||||
"entries without a node type were skipped" = "записи без типа узла пропущены"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Ссылки figcraft:// из браузера, чата или почты открывают это приложение и подключают к сессии."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "залитые области вектора с собственной заливкой используют заливку слоя"
|
||||
"from {server}" = "с {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "триггеры геймпада без ровно одной известной кнопки контроллера пропущены"
|
||||
"hidden" = "скрыто"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "свойства замены экземпляра, компонент по умолчанию которых отсутствует в файле, пропущены"
|
||||
"instances inside their own main component were imported as frames" = "экземпляры внутри собственного основного компонента импортированы как фреймы"
|
||||
"instances of components that aren't in the file show no layers" = "экземпляры компонентов, которых нет в файле, не показывают слоёв"
|
||||
"instances of components that aren't in the file were imported as frames" = "экземпляры компонентов, которых нет в файле, импортированы как фреймы"
|
||||
"instances that would nest a component inside itself were imported as frames" = "экземпляры, которые вложили бы компонент в самого себя, импортированы как фреймы"
|
||||
"instances without a main component were imported as empty frames" = "экземпляры без основного компонента импортированы как пустые фреймы"
|
||||
"invalid or oversized vector networks import as outlines" = "недопустимые или слишком большие векторные сети импортируются как контуры"
|
||||
"invalid or oversized vector path data was skipped" = "недопустимые или слишком большие данные векторных путей пропущены"
|
||||
"just now" = "только что"
|
||||
"key triggers without a supported key were skipped" = "триггеры клавиш без поддерживаемой клавиши пропущены"
|
||||
"key up triggers import as key presses" = "триггеры отпускания клавиши импортируются как нажатия клавиши"
|
||||
"layers without a position were placed at their parent's origin" = "слои без положения размещены в начале координат родителя"
|
||||
"layers without a type were skipped" = "слои без типа пропущены"
|
||||
"locked" = "заблокировано"
|
||||
"no bleed" = "без вылета"
|
||||
"nobody else here yet" = "здесь пока больше никого нет"
|
||||
"now" = "сейчас"
|
||||
"or" = "или"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "соединения обводки в точках сохранены; пунктирные обводки и обводки переменной ширины рисуются с соединением слоя"
|
||||
"prototype actions of unknown types were skipped" = "действия прототипа неизвестных типов пропущены"
|
||||
"prototype actions without a destination in the file were skipped" = "действия прототипа без цели в файле пропущены"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "выражения прототипа с переменными, которых нет в JSON переменных, пропущены"
|
||||
"prototype expressions with unsupported functions were skipped" = "выражения прототипа с неподдерживаемыми функциями пропущены"
|
||||
"prototype interactions without supported actions were skipped" = "взаимодействия прототипа без поддерживаемых действий пропущены"
|
||||
"prototype navigations of unknown types were skipped" = "действия навигации прототипа неизвестных типов пропущены"
|
||||
"prototype transitions of unknown types play instantly" = "переходы прототипа неизвестных типов выполняются мгновенно"
|
||||
"prototype triggers of unknown types were skipped" = "триггеры прототипа неизвестных типов пропущены"
|
||||
"put a widget on the canvas" = "размещать виджет на холсте"
|
||||
"read and edit the document" = "читать и редактировать документ"
|
||||
"read the document" = "читать документ"
|
||||
"reads the document" = "читает документ"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "действия «Задать переменную» для переменных, которых нет в JSON переменных, пропущены"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "действия «Задать переменную» со значениями, которые нельзя вычислить, пропущены"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "действия «Задать переменную» со значениями, не подходящими переменной, пропущены"
|
||||
"set-variable actions without a value were skipped" = "действия «Задать переменную» без значения пропущены"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "действия смены режима переменных для коллекций или режимов, которых нет в JSON переменных, пропущены"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "некоторые векторные точки ссылаются на настройки точек, которых нет в файле; они используют соединения слоя и обычные углы"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "звёзды и многоугольники без числа вершин или геометрии используют число вершин по умолчанию"
|
||||
"text style overrides refer to missing table entries" = "переопределения текстовых стилей ссылаются на отсутствующие записи таблицы"
|
||||
"the file has no pages" = "в файле нет страниц"
|
||||
"the plugin" = "плагин"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "в JSON переменных нет `variableCollections` (ожидался ответ с локальными переменными), он проигнорирован"
|
||||
"this session only" = "только эта сессия"
|
||||
"unknown component property types were skipped" = "неизвестные типы свойств компонентов пропущены"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "псевдонимы переменных, которых нет в JSON переменных (или другого типа), пропущены"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "привязки переменных к свойствам, которых нет у слоя, пропущены"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "привязки к переменным, которых нет в JSON переменных (переменные библиотеки), пропущены"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "привязки переменных пропущены: в JSON файла нет определений переменных (передайте JSON локальных переменных как `variables` или вложите его `meta` рядом с JSON файла)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "привязки переменных, тип которых не подходит свойству, пропущены"
|
||||
"variable collections without modes were skipped" = "коллекции переменных без режимов пропущены"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "обращения к режимам переменных в выражениях прототипа, не состоящие из переменной и одного из её режимов, пропущены"
|
||||
"variable modes of collections that aren't in the file were skipped" = "режимы переменных коллекций, которых нет в файле, пропущены"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "режимы переменных коллекций, которых нет в JSON переменных, пропущены"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "значения переменных, не соответствующие своему типу (или составные цвета), пропущены"
|
||||
"variables of unknown types were skipped" = "переменные неизвестных типов пропущены"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "переменные, коллекции которых нет в JSON переменных, пропущены"
|
||||
"variables without readable values were skipped" = "переменные без читаемых значений пропущены"
|
||||
"vector points with unknown stroke joins use the layer join" = "векторные точки с неизвестными соединениями обводки используют соединение слоя"
|
||||
"vectors without geometry are empty" = "векторы без геометрии пусты"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "векторы без геометрии пусты (экспортируйте JSON файла с geometry=paths)"
|
||||
"very long text was cut to 1,048,576 characters" = "очень длинный текст обрезан до 1 048 576 символов"
|
||||
"very long vector paths were skipped" = "очень длинные векторные пути пропущены"
|
||||
"video actions are not supported and were skipped" = "действия с видео не поддерживаются и пропущены"
|
||||
"video triggers are not supported and were skipped" = "триггеры видео не поддерживаются и пропущены"
|
||||
"yesterday" = "вчера"
|
||||
"{instances} in {files}" = "{instances} в {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: черновой перевод, ожидает проверки носителем языка."
|
||||
@@ -1550,6 +1625,7 @@ name = "Русский"
|
||||
"{n} matches" = { one = "{n} совпадение", few = "{n} совпадения", many = "{n} совпадений", other = "{n} совпадения" }
|
||||
"{n} minutes ago" = { one = "{n} минуту назад", few = "{n} минуты назад", many = "{n} минут назад", other = "{n} минуты назад" }
|
||||
"{n} months ago" = { one = "{n} месяц назад", few = "{n} месяца назад", many = "{n} месяцев назад", other = "{n} месяца назад" }
|
||||
"{n} more notes omitted from this report" = { one = "Ещё {n} замечание не включено в этот отчёт", few = "Ещё {n} замечания не включены в этот отчёт", many = "Ещё {n} замечаний не включено в этот отчёт", other = "Ещё {n} замечания не включено в этот отчёт" }
|
||||
"{n} of {total}" = "{n} из {total}"
|
||||
"{n} people joined" = { one = "Подключился {n} участник", few = "Подключились {n} участника", many = "Подключились {n} участников", other = "Подключилось {n} участника" }
|
||||
"{n} preferred components" = { one = "{n} предпочтительный компонент", few = "{n} предпочтительных компонента", many = "{n} предпочтительных компонентов", other = "{n} предпочтительного компонента" }
|
||||
@@ -1709,6 +1785,7 @@ name = "Русский"
|
||||
"edit.duplicate" = "Дублировать"
|
||||
"edit.findReplace" = "Найти и заменить"
|
||||
"edit.paste" = "Вставить"
|
||||
"edit.pasteDesignLayers" = "Вставить слои дизайна"
|
||||
"edit.pasteOver" = "Вставить поверх выделения"
|
||||
"edit.pasteProperties" = "Вставить свойства"
|
||||
"edit.pasteReplace" = "Вставить с заменой"
|
||||
|
||||
@@ -16,6 +16,7 @@ name = "Svenska"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{bin_added} / −{bin_deleted} binära tillgångar, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "En samling i den här filen med dessa lägen och variabler: åsidosätt deras värden per läge och lägg till egna variabler"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Det finns redan en .figcraft-fil med det här namnet. Välj dess fullständiga namn för att bekräfta att den ersätts."
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "En responsiv produktsida: toppsektion, funktioner, figurer, ett citat och en sidfot"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "En fjäder spelas tills den stannar: dess massa, styvhet och dämpning avgör hur länge"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "En titel, en agenda, en stor figur, ett diagram och en avslutande bild"
|
||||
@@ -268,6 +269,7 @@ name = "Svenska"
|
||||
"Commits" = "Commits"
|
||||
"Company" = "Företag"
|
||||
"Compare changes" = "Jämför ändringar"
|
||||
"Compatibility notes" = "Kompatibilitetsanteckningar"
|
||||
"Completed" = "Slutförd"
|
||||
"Component" = "Komponent"
|
||||
"Component name" = "Komponentnamn"
|
||||
@@ -474,6 +476,7 @@ name = "Svenska"
|
||||
"False" = "Falskt"
|
||||
"Feb" = "feb"
|
||||
"Fetching from the server…" = "Hämtar från servern…"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft stöder ännu inte alla funktioner i de här filerna. Anteckningarna visar vad som utelämnades eller ändrades i den importerade kopian; originalfilen är oförändrad."
|
||||
"File" = "Fil"
|
||||
"File name" = "Filnamn"
|
||||
"File not found" = "Filen hittades inte"
|
||||
@@ -597,11 +600,14 @@ name = "Svenska"
|
||||
"If {condition}" = "Om {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Ignorera automatisk layout (absolut position)"
|
||||
"Image" = "Bild"
|
||||
"Images" = "Bilder"
|
||||
"Import" = "Importera"
|
||||
"Import path" = "Importsökväg"
|
||||
"Import plugin from manifest" = "Importera plugin från manifest"
|
||||
"Import plugin from manifest…" = "Importera plugin från manifest…"
|
||||
"Import report" = "Importrapport"
|
||||
"Import variables" = "Importera variabler"
|
||||
"Imported document" = "Importerat dokument"
|
||||
"Imported {name}" = "Importerade {name}"
|
||||
"Imported {n} new variables, updated {updated}" = { one = "Importerade {n} ny variabel, uppdaterade {updated}", other = "Importerade {n} nya variabler, uppdaterade {updated}" }
|
||||
"Import…" = "Importera…"
|
||||
@@ -803,6 +809,7 @@ name = "Svenska"
|
||||
"No colour variables fit here." = "Inga färgvariabler passar här."
|
||||
"No colour variables yet. Create them in Local variables." = "Inga färgvariabler än. Skapa dem i Lokala variabler."
|
||||
"No comments yet" = "Inga kommentarer än"
|
||||
"No compatibility warnings reported" = "Inga kompatibilitetsvarningar rapporterades"
|
||||
"No components yet. Select layers and press {keys}." = "Inga komponenter än. Markera lager och tryck på {keys}."
|
||||
"No conflicts." = "Inga konflikter."
|
||||
"No contributor data was built into this copy." = "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian."
|
||||
@@ -894,6 +901,7 @@ name = "Svenska"
|
||||
"Open overlay" = "Öppna överlägg"
|
||||
"Open prototype preview" = "Öppna prototypförhandsgranskning"
|
||||
"Open variables ({count})" = "Öppna variabler ({count})"
|
||||
"Opened as a new document" = "Öppnat som ett nytt dokument"
|
||||
"Opened “{name}” in a new tab" = "Öppnade ”{name}” i en ny flik"
|
||||
"Open|comments" = "Öppna"
|
||||
"Options" = "Alternativ"
|
||||
@@ -918,6 +926,8 @@ name = "Svenska"
|
||||
"Padding" = "Utfyllnad"
|
||||
"Page" = "Sida"
|
||||
"Pages" = "Sidor"
|
||||
"Pages added to the document" = "Sidor tillagda i dokumentet"
|
||||
"Placed on the current page" = "Placerat på den aktuella sidan"
|
||||
"Pages: {pages}" = "Sidor: {pages}"
|
||||
"Paint bucket" = "Färghink"
|
||||
"Pan the canvas" = "Panorera arbetsytan"
|
||||
@@ -932,6 +942,7 @@ name = "Svenska"
|
||||
"Paste a team library link (a view link)" = "Klistra in en länk till ett teambibliotek (en visningslänk)"
|
||||
"Paste here" = "Klistra in här"
|
||||
"Paste to replace" = "Klistra in och ersätt"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { one = "Designlager inklistrade; {n} typ av innehåll utelämnades", other = "Designlager inklistrade; {n} typer av innehåll utelämnades" }
|
||||
"Pattern" = "Mönster"
|
||||
"Pattern alignment" = "Mönsterjustering"
|
||||
"Pattern scale" = "Mönsterskala"
|
||||
@@ -1395,6 +1406,7 @@ name = "Svenska"
|
||||
"Use as mask" = "Använd som mask"
|
||||
"Use auto layout" = "Använd automatisk layout"
|
||||
"Use number keys for opacity" = "Använd siffertangenterna för opacitet"
|
||||
"Use the .figcraft or .fig extension." = "Använd filändelsen .figcraft eller .fig."
|
||||
"Use the layer's fill" = "Använd lagrets fyllning"
|
||||
"Used in {files} · {instances}" = "Används i {files} · {instances}"
|
||||
"Username" = "Användarnamn"
|
||||
@@ -1487,26 +1499,89 @@ name = "Svenska"
|
||||
"call any command its access allows" = "anropa alla kommandon som dess åtkomst tillåter"
|
||||
"call only: {commands}" = "anropa endast: {commands}"
|
||||
"can edit the document" = "kan redigera dokumentet"
|
||||
"conditional prototype actions nested too deeply were skipped" = "villkorliga prototypåtgärder som var nästlade för djupt hoppades över"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "villkorliga prototypåtgärder med villkor som inte kan utvärderas hoppades över"
|
||||
"connector labels were skipped" = "etiketter på kopplingslinjer hoppades över"
|
||||
"connectors whose ends can't be placed are empty" = "kopplingslinjer vars ändar inte kan placeras är tomma"
|
||||
"crop marks" = "skärmärken"
|
||||
"damaged vector geometry was skipped" = "skadad vektorgeometri hoppades över"
|
||||
"damaged vector networks import as outlines" = "skadade vektornätverk importeras som former"
|
||||
"development: {path}" = "utveckling: {path}"
|
||||
"document children other than pages were skipped" = "underordnade objekt i dokumentet som inte är sidor hoppades över"
|
||||
"e.g. count + 1" = "t.ex. count + 1"
|
||||
"e.g. count >= 3 and not done" = "t.ex. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "vinklade och böjda kopplingslinjer utan sparad bana importeras som raka linjer"
|
||||
"entries without a node type were skipped" = "poster utan nodtyp hoppades över"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-länkar från en webbläsare, chatt eller e-post öppnar den här appen och ansluter till sessionen."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "fyllda områden i en vektor med egen fyllning använder lagrets fyllning"
|
||||
"from {server}" = "från {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "spelkontrollsutlösare utan exakt en känd kontrollknapp hoppades över"
|
||||
"hidden" = "dold"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "egenskaper för instansbyte vars standardkomponent inte finns i filen hoppades över"
|
||||
"instances inside their own main component were imported as frames" = "instanser inuti sin egen huvudkomponent importerades som ramar"
|
||||
"instances of components that aren't in the file show no layers" = "instanser av komponenter som inte finns i filen visar inga lager"
|
||||
"instances of components that aren't in the file were imported as frames" = "instanser av komponenter som inte finns i filen importerades som ramar"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instanser som skulle nästla en komponent i sig själv importerades som ramar"
|
||||
"instances without a main component were imported as empty frames" = "instanser utan huvudkomponent importerades som tomma ramar"
|
||||
"invalid or oversized vector networks import as outlines" = "ogiltiga eller för stora vektornätverk importeras som former"
|
||||
"invalid or oversized vector path data was skipped" = "ogiltiga eller för stora vektorbanedata hoppades över"
|
||||
"just now" = "nyss"
|
||||
"key triggers without a supported key were skipped" = "tangentutlösare utan en tangent som stöds hoppades över"
|
||||
"key up triggers import as key presses" = "utlösare för släppt tangent importeras som tangenttryckningar"
|
||||
"layers without a position were placed at their parent's origin" = "lager utan position placerades i det överordnade objektets origo"
|
||||
"layers without a type were skipped" = "lager utan typ hoppades över"
|
||||
"locked" = "låst"
|
||||
"no bleed" = "inget utfall"
|
||||
"nobody else here yet" = "ingen annan här än"
|
||||
"now" = "nu"
|
||||
"or" = "eller"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "punkternas hörntyper behölls; streckade konturer och konturer med variabel bredd ritas med lagrets hörntyp"
|
||||
"prototype actions of unknown types were skipped" = "prototypåtgärder av okända typer hoppades över"
|
||||
"prototype actions without a destination in the file were skipped" = "prototypåtgärder utan mål i filen hoppades över"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "prototyputtryck med variabler som inte finns i variabel-JSON:en hoppades över"
|
||||
"prototype expressions with unsupported functions were skipped" = "prototyputtryck med funktioner som inte stöds hoppades över"
|
||||
"prototype interactions without supported actions were skipped" = "prototypinteraktioner utan åtgärder som stöds hoppades över"
|
||||
"prototype navigations of unknown types were skipped" = "prototypnavigeringar av okända typer hoppades över"
|
||||
"prototype transitions of unknown types play instantly" = "prototypövergångar av okända typer spelas upp direkt"
|
||||
"prototype triggers of unknown types were skipped" = "prototyputlösare av okända typer hoppades över"
|
||||
"put a widget on the canvas" = "placera en widget på arbetsytan"
|
||||
"read and edit the document" = "läsa och redigera dokumentet"
|
||||
"read the document" = "läsa dokumentet"
|
||||
"reads the document" = "läser dokumentet"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "åtgärder för att ange variabel på variabler som inte finns i variabel-JSON:en hoppades över"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "åtgärder för att ange variabel med värden som inte kan utvärderas hoppades över"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "åtgärder för att ange variabel med värden som inte passar variabeln hoppades över"
|
||||
"set-variable actions without a value were skipped" = "åtgärder för att ange variabel utan värde hoppades över"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "åtgärder för att ange variabelläge på samlingar eller lägen som inte finns i variabel-JSON:en hoppades över"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "vissa vektorpunkter hänvisar till punktinställningar som saknas i filen; de använder lagrets hörntyper och vanliga hörn"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "stjärnor och polygoner utan antal punkter eller geometri använder standardantalet punkter"
|
||||
"text style overrides refer to missing table entries" = "åsidosättningar av textstilar hänvisar till tabellposter som saknas"
|
||||
"the file has no pages" = "filen har inga sidor"
|
||||
"the plugin" = "pluginet"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "variabel-JSON:en har inga `variableCollections` (svaret med lokala variabler väntades) och ignorerades"
|
||||
"this session only" = "endast den här sessionen"
|
||||
"unknown component property types were skipped" = "okända typer av komponentegenskaper hoppades över"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "alias till variabler som inte finns i variabel-JSON:en (eller är av en annan typ) hoppades över"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "variabelbindningar till egenskaper som lagret inte har hoppades över"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "bindningar till variabler som inte finns i variabel-JSON:en (biblioteksvariabler) hoppades över"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "variabelbindningar hoppades över: fil-JSON:en har inga variabeldefinitioner (skicka den lokala variabel-JSON:en som `variables`, eller bädda in dess `meta` bredvid fil-JSON:en)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "variabelbindningar vars variabeltyp inte passar egenskapen hoppades över"
|
||||
"variable collections without modes were skipped" = "variabelsamlingar utan lägen hoppades över"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "uppslag av variabellägen i prototyputtryck som inte är en variabel och ett av dess lägen hoppades över"
|
||||
"variable modes of collections that aren't in the file were skipped" = "variabellägen för samlingar som inte finns i filen hoppades över"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "variabellägen för samlingar som inte finns i variabel-JSON:en hoppades över"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "variabelvärden som inte matchar sin typ (eller sammansatta färger) hoppades över"
|
||||
"variables of unknown types were skipped" = "variabler av okända typer hoppades över"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "variabler vars samling inte finns i variabel-JSON:en hoppades över"
|
||||
"variables without readable values were skipped" = "variabler utan läsbara värden hoppades över"
|
||||
"vector points with unknown stroke joins use the layer join" = "vektorpunkter med okända hörntyper använder lagrets hörntyp"
|
||||
"vectors without geometry are empty" = "vektorer utan geometri är tomma"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vektorer utan geometri är tomma (exportera fil-JSON:en med geometry=paths)"
|
||||
"very long text was cut to 1,048,576 characters" = "mycket lång text kortades till 1 048 576 tecken"
|
||||
"very long vector paths were skipped" = "mycket långa vektorbanor hoppades över"
|
||||
"video actions are not supported and were skipped" = "videoåtgärder stöds inte och hoppades över"
|
||||
"video triggers are not supported and were skipped" = "videoutlösare stöds inte och hoppades över"
|
||||
"yesterday" = "i går"
|
||||
"{instances} in {files}" = "{instances} i {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: utkast till översättning, väntar på granskning av en modersmålstalare."
|
||||
@@ -1551,6 +1626,7 @@ name = "Svenska"
|
||||
"{n} matches" = { one = "{n} träff", other = "{n} träffar" }
|
||||
"{n} minutes ago" = { one = "för {n} minut sedan", other = "för {n} minuter sedan" }
|
||||
"{n} months ago" = { one = "för {n} månad sedan", other = "för {n} månader sedan" }
|
||||
"{n} more notes omitted from this report" = { one = "{n} anteckning till utelämnades i den här rapporten", other = "{n} anteckningar till utelämnades i den här rapporten" }
|
||||
"{n} of {total}" = "{n} av {total}"
|
||||
"{n} people joined" = { one = "{n} person anslöt", other = "{n} personer anslöt" }
|
||||
"{n} preferred components" = { one = "{n} föredragen komponent", other = "{n} föredragna komponenter" }
|
||||
@@ -1591,6 +1667,7 @@ name = "Svenska"
|
||||
"edit.duplicate" = "Duplicera"
|
||||
"edit.findReplace" = "Sök och ersätt"
|
||||
"edit.paste" = "Klistra in"
|
||||
"edit.pasteDesignLayers" = "Klistra in designlager"
|
||||
"edit.pasteOver" = "Klistra in över"
|
||||
"edit.pasteProperties" = "Klistra in egenskaper"
|
||||
"edit.pasteReplace" = "Klistra in och ersätt"
|
||||
|
||||
@@ -18,6 +18,7 @@ name = "Türkçe"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} satır (Δ {delta}), +{bin_added} / −{bin_deleted} ikili varlık, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Bu dosyanın bu modları ve değişkenleri içeren bir koleksiyonu: değerlerini mod başına geçersiz kılın ve kendi değişkenlerinizi ekleyin"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Bu adda bir .figcraft dosyası zaten var. Değiştirmeyi onaylamak için tam adını seçin."
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Duyarlı bir ürün sayfası: giriş bölümü, özellikler, rakamlar, bir alıntı ve bir alt bilgi"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Yay, durulana kadar oynar: kütlesi, sertliği ve sönümü ne kadar süreceğini belirler"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Bir başlık, bir gündem, büyük bir rakam, bir grafik ve bir kapanış slaydı"
|
||||
@@ -270,6 +271,7 @@ name = "Türkçe"
|
||||
"Commits" = "Commit'ler"
|
||||
"Company" = "Şirket"
|
||||
"Compare changes" = "Değişiklikleri karşılaştır"
|
||||
"Compatibility notes" = "Uyumluluk notları"
|
||||
"Completed" = "Tamamlandı"
|
||||
"Component" = "Bileşen"
|
||||
"Component name" = "Bileşen adı"
|
||||
@@ -476,6 +478,7 @@ name = "Türkçe"
|
||||
"False" = "Yanlış"
|
||||
"Feb" = "Şub"
|
||||
"Fetching from the server…" = "Sunucudan getiriliyor…"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft bu dosyaların tüm özelliklerini henüz desteklemiyor. Notlar, içe aktarılan kopyada nelerin dışarıda bırakıldığını veya değiştirildiğini listeler; özgün dosya değişmez."
|
||||
"File" = "Dosya"
|
||||
"File name" = "Dosya adı"
|
||||
"File not found" = "Dosya bulunamadı"
|
||||
@@ -599,11 +602,14 @@ name = "Türkçe"
|
||||
"If {condition}" = "Eğer {condition}"
|
||||
"Ignore auto layout (absolute position)" = "Otomatik yerleşimi yok say (mutlak konum)"
|
||||
"Image" = "Görüntü"
|
||||
"Images" = "Görüntüler"
|
||||
"Import" = "İçe aktar"
|
||||
"Import path" = "İçe aktarma yolu"
|
||||
"Import plugin from manifest" = "Eklentiyi manifestten içe aktar"
|
||||
"Import plugin from manifest…" = "Eklentiyi manifestten içe aktar…"
|
||||
"Import report" = "İçe aktarma raporu"
|
||||
"Import variables" = "Değişkenleri içe aktar"
|
||||
"Imported document" = "İçe aktarılan belge"
|
||||
"Imported {name}" = "İçe aktarıldı: {name}"
|
||||
"Imported {n} new variables, updated {updated}" = { other = "{n} yeni değişken içe aktarıldı, {updated} güncellendi" }
|
||||
"Import…" = "İçe aktar…"
|
||||
@@ -805,6 +811,7 @@ name = "Türkçe"
|
||||
"No colour variables fit here." = "Buraya uyan renk değişkeni yok."
|
||||
"No colour variables yet. Create them in Local variables." = "Henüz renk değişkeni yok. Onları Yerel değişkenler'de oluşturun."
|
||||
"No comments yet" = "Henüz yorum yok"
|
||||
"No compatibility warnings reported" = "Uyumluluk uyarısı bildirilmedi"
|
||||
"No components yet. Select layers and press {keys}." = "Henüz bileşen yok. Katmanları seçip {keys} tuşlarına basın."
|
||||
"No conflicts." = "Çakışma yok."
|
||||
"No contributor data was built into this copy." = "Bu kopyaya katkıda bulunan verisi eklenmemiş."
|
||||
@@ -896,6 +903,7 @@ name = "Türkçe"
|
||||
"Open overlay" = "Bindirmeyi aç"
|
||||
"Open prototype preview" = "Prototip önizlemesini aç"
|
||||
"Open variables ({count})" = "Açık değişkenler ({count})"
|
||||
"Opened as a new document" = "Yeni belge olarak açıldı"
|
||||
"Opened “{name}” in a new tab" = "“{name}” yeni sekmede açıldı"
|
||||
"Open|comments" = "Açık"
|
||||
"Options" = "Seçenekler"
|
||||
@@ -920,6 +928,8 @@ name = "Türkçe"
|
||||
"Padding" = "İç boşluk"
|
||||
"Page" = "Sayfa"
|
||||
"Pages" = "Sayfalar"
|
||||
"Pages added to the document" = "Sayfalar belgeye eklendi"
|
||||
"Placed on the current page" = "Geçerli sayfaya yerleştirildi"
|
||||
"Pages: {pages}" = "Sayfalar: {pages}"
|
||||
"Paint bucket" = "Boya kovası"
|
||||
"Pan the canvas" = "Tuvali kaydır"
|
||||
@@ -934,6 +944,7 @@ name = "Türkçe"
|
||||
"Paste a team library link (a view link)" = "Bir ekip kitaplığı bağlantısı yapıştırın (görüntüleme bağlantısı)"
|
||||
"Paste here" = "Buraya yapıştır"
|
||||
"Paste to replace" = "Değiştirmek için yapıştır"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { other = "Tasarım katmanları yapıştırıldı; {n} tür içerik dışarıda bırakıldı" }
|
||||
"Pattern" = "Desen"
|
||||
"Pattern alignment" = "Desen hizalaması"
|
||||
"Pattern scale" = "Desen ölçeği"
|
||||
@@ -1397,6 +1408,7 @@ name = "Türkçe"
|
||||
"Use as mask" = "Maske olarak kullan"
|
||||
"Use auto layout" = "Otomatik yerleşim kullan"
|
||||
"Use number keys for opacity" = "Opaklık için rakam tuşlarını kullan"
|
||||
"Use the .figcraft or .fig extension." = ".figcraft veya .fig uzantısını kullanın."
|
||||
"Use the layer's fill" = "Katmanın dolgusunu kullan"
|
||||
"Used in {files} · {instances}" = "Kullanıldığı yer: {files} · {instances}"
|
||||
"Username" = "Kullanıcı adı"
|
||||
@@ -1489,26 +1501,89 @@ name = "Türkçe"
|
||||
"call any command its access allows" = "erişiminin izin verdiği her komutu çağırmak"
|
||||
"call only: {commands}" = "yalnızca şunları çağırmak: {commands}"
|
||||
"can edit the document" = "belgeyi düzenleyebilir"
|
||||
"conditional prototype actions nested too deeply were skipped" = "çok derin iç içe geçmiş koşullu prototip eylemleri atlandı"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "değerlendirilemeyen koşullara sahip koşullu prototip eylemleri atlandı"
|
||||
"connector labels were skipped" = "bağlayıcı etiketleri atlandı"
|
||||
"connectors whose ends can't be placed are empty" = "uçları yerleştirilemeyen bağlayıcılar boş"
|
||||
"crop marks" = "kesim işaretleri"
|
||||
"damaged vector geometry was skipped" = "hasarlı vektör geometrisi atlandı"
|
||||
"damaged vector networks import as outlines" = "hasarlı vektör ağları ana hat olarak içe aktarılır"
|
||||
"development: {path}" = "geliştirme: {path}"
|
||||
"document children other than pages were skipped" = "belgenin sayfa olmayan alt öğeleri atlandı"
|
||||
"e.g. count + 1" = "ör. count + 1"
|
||||
"e.g. count >= 3 and not done" = "ör. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "kaydedilmiş yolu olmayan dirsekli ve eğri bağlayıcılar düz çizgi olarak içe aktarılır"
|
||||
"entries without a node type were skipped" = "düğüm türü olmayan girdiler atlandı"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Bir tarayıcıdan, sohbetten veya e-postadan gelen figcraft:// bağlantıları bu uygulamayı açar ve oturuma katılır."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "kendi dolgusu olan vektör dolgu alanları katmanın dolgusunu kullanır"
|
||||
"from {server}" = "kaynak: {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "tam olarak bir bilinen kumanda düğmesi olmayan oyun kumandası tetikleyicileri atlandı"
|
||||
"hidden" = "gizli"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "varsayılan bileşeni dosyada olmayan örnek değiştirme özellikleri atlandı"
|
||||
"instances inside their own main component were imported as frames" = "kendi ana bileşeninin içindeki örnekler çerçeve olarak içe aktarıldı"
|
||||
"instances of components that aren't in the file show no layers" = "dosyada olmayan bileşenlerin örnekleri katman göstermez"
|
||||
"instances of components that aren't in the file were imported as frames" = "dosyada olmayan bileşenlerin örnekleri çerçeve olarak içe aktarıldı"
|
||||
"instances that would nest a component inside itself were imported as frames" = "bir bileşeni kendi içine yerleştirecek örnekler çerçeve olarak içe aktarıldı"
|
||||
"instances without a main component were imported as empty frames" = "ana bileşeni olmayan örnekler boş çerçeve olarak içe aktarıldı"
|
||||
"invalid or oversized vector networks import as outlines" = "geçersiz veya çok büyük vektör ağları ana hat olarak içe aktarılır"
|
||||
"invalid or oversized vector path data was skipped" = "geçersiz veya çok büyük vektör yolu verileri atlandı"
|
||||
"just now" = "az önce"
|
||||
"key triggers without a supported key were skipped" = "desteklenen bir tuşu olmayan tuş tetikleyicileri atlandı"
|
||||
"key up triggers import as key presses" = "tuş bırakma tetikleyicileri tuşa basma olarak içe aktarılır"
|
||||
"layers without a position were placed at their parent's origin" = "konumu olmayan katmanlar üst öğelerinin başlangıç noktasına yerleştirildi"
|
||||
"layers without a type were skipped" = "türü olmayan katmanlar atlandı"
|
||||
"locked" = "kilitli"
|
||||
"no bleed" = "taşma payı yok"
|
||||
"nobody else here yet" = "burada henüz başka kimse yok"
|
||||
"now" = "şimdi"
|
||||
"or" = "veya"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "nokta başına kontur birleşimleri korundu; kesikli veya değişken genişlikli konturlar katmanın birleşimiyle çizilir"
|
||||
"prototype actions of unknown types were skipped" = "bilinmeyen türdeki prototip eylemleri atlandı"
|
||||
"prototype actions without a destination in the file were skipped" = "dosyada hedefi olmayan prototip eylemleri atlandı"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan değişkenler üzerindeki prototip ifadeleri atlandı"
|
||||
"prototype expressions with unsupported functions were skipped" = "desteklenmeyen işlevler içeren prototip ifadeleri atlandı"
|
||||
"prototype interactions without supported actions were skipped" = "desteklenen eylemi olmayan prototip etkileşimleri atlandı"
|
||||
"prototype navigations of unknown types were skipped" = "bilinmeyen türdeki prototip gezinmeleri atlandı"
|
||||
"prototype transitions of unknown types play instantly" = "bilinmeyen türdeki prototip geçişleri anında oynatılır"
|
||||
"prototype triggers of unknown types were skipped" = "bilinmeyen türdeki prototip tetikleyicileri atlandı"
|
||||
"put a widget on the canvas" = "tuvale bir widget koymak"
|
||||
"read and edit the document" = "belgeyi okumak ve düzenlemek"
|
||||
"read the document" = "belgeyi okumak"
|
||||
"reads the document" = "belgeyi okur"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan değişkenler üzerindeki değişken ayarlama eylemleri atlandı"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "değerlendirilemeyen değerlere sahip değişken ayarlama eylemleri atlandı"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "değişkene uymayan değerlere sahip değişken ayarlama eylemleri atlandı"
|
||||
"set-variable actions without a value were skipped" = "değeri olmayan değişken ayarlama eylemleri atlandı"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan koleksiyonlar veya modlar üzerindeki değişken modu ayarlama eylemleri atlandı"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "bazı vektör noktaları dosyada eksik olan nokta ayarlarına başvuruyor; bu noktalar katmanın birleşimlerini ve düz köşeleri kullanır"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "nokta sayısı veya geometrisi olmayan yıldızlar ve çokgenler varsayılan nokta sayısını kullanır"
|
||||
"text style overrides refer to missing table entries" = "metin stili geçersiz kılmaları eksik tablo girdilerine başvuruyor"
|
||||
"the file has no pages" = "dosyada sayfa yok"
|
||||
"the plugin" = "eklenti"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "değişken JSON'unda `variableCollections` yok (yerel değişkenler yanıtı bekleniyordu) ve yok sayıldı"
|
||||
"this session only" = "yalnızca bu oturum"
|
||||
"unknown component property types were skipped" = "bilinmeyen bileşen özelliği türleri atlandı"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "değişken JSON'unda olmayan (veya başka türdeki) değişkenlere yönelik takma adlar atlandı"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "katmanda olmayan özelliklere yönelik değişken bağlamaları atlandı"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "değişken JSON'unda olmayan değişkenlere (kitaplık değişkenleri) yönelik bağlamalar atlandı"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "değişken bağlamaları atlandı: dosya JSON'unda değişken tanımı yok (yerel değişken JSON'unu `variables` olarak verin veya `meta` alanını dosya JSON'unun yanına gömün)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "değişken türü özelliğe uymayan değişken bağlamaları atlandı"
|
||||
"variable collections without modes were skipped" = "modu olmayan değişken koleksiyonları atlandı"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "prototip ifadelerinde bir değişken ve onun modlarından biri olmayan değişken modu aramaları atlandı"
|
||||
"variable modes of collections that aren't in the file were skipped" = "dosyada olmayan koleksiyonların değişken modları atlandı"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan koleksiyonların değişken modları atlandı"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "türüyle eşleşmeyen değişken değerleri (veya birleşik renkler) atlandı"
|
||||
"variables of unknown types were skipped" = "bilinmeyen türdeki değişkenler atlandı"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "koleksiyonu değişken JSON'unda olmayan değişkenler atlandı"
|
||||
"variables without readable values were skipped" = "okunabilir değeri olmayan değişkenler atlandı"
|
||||
"vector points with unknown stroke joins use the layer join" = "bilinmeyen kontur birleşimleri olan vektör noktaları katmanın birleşimini kullanır"
|
||||
"vectors without geometry are empty" = "geometrisi olmayan vektörler boş"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "geometrisi olmayan vektörler boş (dosya JSON'unu geometry=paths ile dışa aktarın)"
|
||||
"very long text was cut to 1,048,576 characters" = "çok uzun metin 1.048.576 karaktere kısaltıldı"
|
||||
"very long vector paths were skipped" = "çok uzun vektör yolları atlandı"
|
||||
"video actions are not supported and were skipped" = "video eylemleri desteklenmiyor ve atlandı"
|
||||
"video triggers are not supported and were skipped" = "video tetikleyicileri desteklenmiyor ve atlandı"
|
||||
"yesterday" = "dün"
|
||||
"{instances} in {files}" = "{instances}, {files} içinde"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}: taslak çeviri, ana dili konuşan birinin incelemesini bekliyor."
|
||||
@@ -1553,6 +1628,7 @@ name = "Türkçe"
|
||||
"{n} matches" = { other = "{n} eşleşme" }
|
||||
"{n} minutes ago" = { other = "{n} dakika önce" }
|
||||
"{n} months ago" = { other = "{n} ay önce" }
|
||||
"{n} more notes omitted from this report" = { other = "{n} not daha bu rapora alınmadı" }
|
||||
"{n} of {total}" = "{n} / {total}"
|
||||
"{n} people joined" = { other = "{n} kişi katıldı" }
|
||||
"{n} preferred components" = { other = "{n} tercih edilen bileşen" }
|
||||
@@ -1712,6 +1788,7 @@ name = "Türkçe"
|
||||
"edit.duplicate" = "Çoğalt"
|
||||
"edit.findReplace" = "Bul ve değiştir"
|
||||
"edit.paste" = "Yapıştır"
|
||||
"edit.pasteDesignLayers" = "Tasarım katmanlarını yapıştır"
|
||||
"edit.pasteOver" = "Seçimin üzerine yapıştır"
|
||||
"edit.pasteProperties" = "Özellikleri yapıştır"
|
||||
"edit.pasteReplace" = "Değiştirmek için yapıştır"
|
||||
|
||||
@@ -19,6 +19,7 @@ status = "draft — needs native review"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:{prs} 个 PR,{commits} 次提交,+{added} / −{deleted} 行(Δ {delta}),二进制资源 +{bin_added} / −{bin_deleted},{first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "本文件中带有这些模式和变量的集合:可按模式覆盖其值,并添加自己的变量"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "已存在同名的 .figcraft 文件。请选择其完整名称以确认替换。"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "单个图层与其所在画框对齐:请再选择其他图层,或选择画框内的图层"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "弹簧动画播放至静止,时长由质量、刚度和阻尼决定"
|
||||
"About" = "关于"
|
||||
@@ -262,6 +263,7 @@ status = "draft — needs native review"
|
||||
"Commits" = "提交"
|
||||
"Company" = "公司"
|
||||
"Compare changes" = "比较更改"
|
||||
"Compatibility notes" = "兼容性说明"
|
||||
"Completed" = "已完成"
|
||||
"Component" = "组件"
|
||||
"Component name" = "组件名称"
|
||||
@@ -461,6 +463,7 @@ status = "draft — needs native review"
|
||||
"False" = "假"
|
||||
"Fetching from the server…" = "正在从服务器获取…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft 无法打开窗口:{error}"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft 尚不支持这些文件的全部功能。说明中列出了导入副本中省略或更改的内容;原始文件保持不变。"
|
||||
"File" = "文件"
|
||||
"File name" = "文件名"
|
||||
"File published" = "文件已发布"
|
||||
@@ -577,11 +580,14 @@ status = "draft — needs native review"
|
||||
"If {condition}" = "如果 {condition}"
|
||||
"Ignore auto layout (absolute position)" = "忽略自动布局(绝对定位)"
|
||||
"Image" = "图片"
|
||||
"Images" = "图片"
|
||||
"Import" = "导入"
|
||||
"Import path" = "导入路径"
|
||||
"Import plugin from manifest" = "从清单导入插件"
|
||||
"Import plugin from manifest…" = "从清单导入插件…"
|
||||
"Import report" = "导入报告"
|
||||
"Import variables" = "导入变量"
|
||||
"Imported document" = "导入的文档"
|
||||
"Imported {name}" = "已导入 {name}"
|
||||
"Imported {n} new variables, updated {updated}" = { other = "已导入 {n} 个新变量,更新 {updated} 个" }
|
||||
"Import…" = "导入…"
|
||||
@@ -771,6 +777,7 @@ status = "draft — needs native review"
|
||||
"No colour variables fit here." = "没有适用于此处的颜色变量。"
|
||||
"No colour variables yet. Create them in Local variables." = "还没有颜色变量。请在“本地变量”中创建。"
|
||||
"No comments yet" = "还没有评论"
|
||||
"No compatibility warnings reported" = "未报告兼容性警告"
|
||||
"No components yet. Select layers and press {keys}." = "还没有组件。选择图层并按 {keys}。"
|
||||
"No conflicts." = "没有冲突。"
|
||||
"No contributor data was built into this copy." = "此副本未内置贡献者数据。"
|
||||
@@ -857,6 +864,7 @@ status = "draft — needs native review"
|
||||
"Open overlay" = "打开浮层"
|
||||
"Open prototype preview" = "打开原型预览"
|
||||
"Open variables ({count})" = "打开变量({count})"
|
||||
"Opened as a new document" = "已作为新文档打开"
|
||||
"Opened “{name}” in a new tab" = "已在新标签页中打开“{name}”"
|
||||
"Open|comments" = "未解决"
|
||||
"Options" = "选项"
|
||||
@@ -880,6 +888,8 @@ status = "draft — needs native review"
|
||||
"Padding" = "内边距"
|
||||
"Page" = "页面"
|
||||
"Pages" = "页面"
|
||||
"Pages added to the document" = "页面已添加到文档"
|
||||
"Placed on the current page" = "已放置在当前页面"
|
||||
"Pages: {pages}" = "页面:{pages}"
|
||||
"Paint bucket" = "油漆桶"
|
||||
"Pan the canvas" = "平移画布"
|
||||
@@ -894,6 +904,7 @@ status = "draft — needs native review"
|
||||
"Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)"
|
||||
"Paste here" = "粘贴到此处"
|
||||
"Paste to replace" = "粘贴以替换"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { other = "已粘贴设计图层;有 {n} 类内容未包含" }
|
||||
"Pattern" = "图案"
|
||||
"Pattern alignment" = "图案对齐"
|
||||
"Pattern scale" = "图案缩放"
|
||||
@@ -1339,6 +1350,7 @@ status = "draft — needs native review"
|
||||
"Use as mask" = "用作蒙版"
|
||||
"Use auto layout" = "使用自动布局"
|
||||
"Use number keys for opacity" = "用数字键设置不透明度"
|
||||
"Use the .figcraft or .fig extension." = "请使用 .figcraft 或 .fig 扩展名。"
|
||||
"Use the layer's fill" = "使用图层的填充"
|
||||
"Used in {files} · {instances}" = "用于 {files} · {instances}"
|
||||
"Username" = "用户名"
|
||||
@@ -1427,25 +1439,88 @@ status = "draft — needs native review"
|
||||
"call any command its access allows" = "调用其访问权限允许的任何命令"
|
||||
"call only: {commands}" = "仅调用:{commands}"
|
||||
"can edit the document" = "可编辑文档"
|
||||
"conditional prototype actions nested too deeply were skipped" = "已跳过嵌套过深的条件原型操作"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "已跳过条件无法求值的条件原型操作"
|
||||
"connector labels were skipped" = "已跳过连接线标签"
|
||||
"connectors whose ends can't be placed are empty" = "无法放置端点的连接线为空"
|
||||
"crop marks" = "裁切标记"
|
||||
"damaged vector geometry was skipped" = "已跳过损坏的矢量几何"
|
||||
"damaged vector networks import as outlines" = "损坏的矢量网络导入为轮廓"
|
||||
"development: {path}" = "开发:{path}"
|
||||
"document children other than pages were skipped" = "已跳过非页面的文档子项"
|
||||
"e.g. count + 1" = "例如 count + 1"
|
||||
"e.g. count >= 3 and not done" = "例如 count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "没有已保存路径的折线和曲线连接线导入为直线"
|
||||
"entries without a node type were skipped" = "已跳过没有节点类型的条目"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "来自浏览器、聊天或邮件的 figcraft:// 链接会打开此应用并加入会话。"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "具有自身填充的矢量填充区域使用图层的填充"
|
||||
"from {server}" = "来自 {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "已跳过没有恰好一个已知手柄按钮的游戏手柄触发器"
|
||||
"hidden" = "已隐藏"
|
||||
"iOS" = "iOS"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "已跳过默认组件不在文件中的实例替换属性"
|
||||
"instances inside their own main component were imported as frames" = "位于自身主组件内的实例已导入为画框"
|
||||
"instances of components that aren't in the file show no layers" = "文件中没有的组件的实例不显示图层"
|
||||
"instances of components that aren't in the file were imported as frames" = "文件中没有的组件的实例已导入为画框"
|
||||
"instances that would nest a component inside itself were imported as frames" = "会将组件嵌套到自身内部的实例已导入为画框"
|
||||
"instances without a main component were imported as empty frames" = "没有主组件的实例已导入为空画框"
|
||||
"invalid or oversized vector networks import as outlines" = "无效或过大的矢量网络导入为轮廓"
|
||||
"invalid or oversized vector path data was skipped" = "已跳过无效或过大的矢量路径数据"
|
||||
"key triggers without a supported key were skipped" = "已跳过没有受支持按键的按键触发器"
|
||||
"key up triggers import as key presses" = "松开按键触发器导入为按下按键"
|
||||
"layers without a position were placed at their parent's origin" = "没有位置的图层已放在其父级的原点"
|
||||
"layers without a type were skipped" = "已跳过没有类型的图层"
|
||||
"locked" = "已锁定"
|
||||
"no bleed" = "无出血"
|
||||
"nobody else here yet" = "这里还没有其他人"
|
||||
"now" = "刚刚"
|
||||
"or" = "或"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "已保留各点的描边转角;虚线描边或可变宽度描边以图层的转角绘制"
|
||||
"prototype actions of unknown types were skipped" = "已跳过未知类型的原型操作"
|
||||
"prototype actions without a destination in the file were skipped" = "已跳过在文件中没有目标的原型操作"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "已跳过使用变量 JSON 中没有的变量的原型表达式"
|
||||
"prototype expressions with unsupported functions were skipped" = "已跳过使用不受支持函数的原型表达式"
|
||||
"prototype interactions without supported actions were skipped" = "已跳过没有受支持操作的原型交互"
|
||||
"prototype navigations of unknown types were skipped" = "已跳过未知类型的原型导航"
|
||||
"prototype transitions of unknown types play instantly" = "未知类型的原型过渡即时播放"
|
||||
"prototype triggers of unknown types were skipped" = "已跳过未知类型的原型触发器"
|
||||
"put a widget on the canvas" = "在画布上放置小组件"
|
||||
"read and edit the document" = "读取和编辑文档"
|
||||
"read the document" = "读取文档"
|
||||
"reads the document" = "读取文档"
|
||||
"set-variable actions on variables that aren't in the variables JSON were skipped" = "已跳过针对变量 JSON 中没有的变量的设置变量操作"
|
||||
"set-variable actions with values that can't be evaluated were skipped" = "已跳过值无法求值的设置变量操作"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "已跳过值与变量不符的设置变量操作"
|
||||
"set-variable actions without a value were skipped" = "已跳过没有值的设置变量操作"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "已跳过针对变量 JSON 中没有的集合或模式的设置变量模式操作"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "部分矢量点引用了文件中缺失的点设置;这些点使用图层的转角和普通角"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "没有点数或几何的星形和多边形使用默认点数"
|
||||
"text style overrides refer to missing table entries" = "文本样式覆盖引用了缺失的表条目"
|
||||
"the file has no pages" = "文件没有页面"
|
||||
"the plugin" = "插件"
|
||||
"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "变量 JSON 中没有 `variableCollections`(应为本地变量响应),已忽略"
|
||||
"this session only" = "仅限本次会话"
|
||||
"unknown component property types were skipped" = "已跳过未知的组件属性类型"
|
||||
"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "已跳过指向变量 JSON 中没有的(或其他类型的)变量的别名"
|
||||
"variable bindings to properties the layer doesn't have were skipped" = "已跳过绑定到图层没有的属性的变量绑定"
|
||||
"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "已跳过绑定到变量 JSON 中没有的变量(库变量)的绑定"
|
||||
"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "已跳过变量绑定:文件 JSON 中没有变量定义(将本地变量 JSON 作为 `variables` 传入,或将其 `meta` 嵌入文件 JSON 旁)"
|
||||
"variable bindings whose variable type doesn't fit the property were skipped" = "已跳过变量类型与属性不符的变量绑定"
|
||||
"variable collections without modes were skipped" = "已跳过没有模式的变量集合"
|
||||
"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "已跳过原型表达式中不是变量及其某个模式的变量模式查找"
|
||||
"variable modes of collections that aren't in the file were skipped" = "已跳过文件中没有的集合的变量模式"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "已跳过变量 JSON 中没有的集合的变量模式"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "已跳过与其类型不符的变量值(或组合颜色)"
|
||||
"variables of unknown types were skipped" = "已跳过未知类型的变量"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "已跳过其集合不在变量 JSON 中的变量"
|
||||
"variables without readable values were skipped" = "已跳过没有可读值的变量"
|
||||
"vector points with unknown stroke joins use the layer join" = "转角未知的矢量点使用图层的转角"
|
||||
"vectors without geometry are empty" = "没有几何的矢量为空"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "没有几何的矢量为空(使用 geometry=paths 导出文件 JSON)"
|
||||
"very long text was cut to 1,048,576 characters" = "过长的文本已截断为 1,048,576 个字符"
|
||||
"very long vector paths were skipped" = "已跳过过长的矢量路径"
|
||||
"video actions are not supported and were skipped" = "不支持视频操作,已跳过"
|
||||
"video triggers are not supported and were skipped" = "不支持视频触发器,已跳过"
|
||||
"{instances} in {files}" = "{files} 中的 {instances}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language}:翻译草稿,等待母语者审校。"
|
||||
"{length} bleed" = "出血 {length}"
|
||||
@@ -1488,6 +1563,7 @@ status = "draft — needs native review"
|
||||
"{n} matches" = { other = "{n} 个匹配项" }
|
||||
"{n} minutes ago" = { other = "{n} 分钟前" }
|
||||
"{n} months ago" = { other = "{n} 个月前" }
|
||||
"{n} more notes omitted from this report" = { other = "另有 {n} 条说明未列入此报告" }
|
||||
"{n} of {total}" = "第 {n} 个,共 {total} 个"
|
||||
"{n} people joined" = { other = "{n} 人已加入" }
|
||||
"{n} preferred components" = { other = "{n} 个首选组件" }
|
||||
@@ -1646,6 +1722,7 @@ status = "draft — needs native review"
|
||||
"edit.duplicate" = "创建副本"
|
||||
"edit.findReplace" = "查找和替换"
|
||||
"edit.paste" = "粘贴"
|
||||
"edit.pasteDesignLayers" = "粘贴设计图层"
|
||||
"edit.pasteOver" = "粘贴到所选项上方"
|
||||
"edit.pasteProperties" = "粘贴属性"
|
||||
"edit.pasteReplace" = "粘贴以替换"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::cmd::edit::{CLIP_CLOSE, CLIP_OPEN};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{FigApp, Services};
|
||||
|
||||
struct Clip {
|
||||
html: String,
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
impl Services for Clip {
|
||||
fn clipboard_get_html(&mut self) -> Option<String> {
|
||||
Some(self.html.clone())
|
||||
}
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
self.text.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The HTML another design app puts on the clipboard for copied layers (a `.fig` canvas inside).
|
||||
fn copied_html() -> String {
|
||||
let mut s = Session::new();
|
||||
s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20, "name": "Copied layer"})).unwrap();
|
||||
let r = s.execute("file.export", &json!({"format": "fig"})).unwrap();
|
||||
format!(r#"<span data-buffer="<!--{CLIP_OPEN}{}{CLIP_CLOSE}-->"></span>"#, r["data"].as_str().unwrap())
|
||||
}
|
||||
|
||||
fn selected_names(app: &FigApp) -> Vec<String> {
|
||||
let doc = app.session.doc().unwrap();
|
||||
app.session.selection().iter().map(|i| doc.node(*i).unwrap().name.clone()).collect()
|
||||
}
|
||||
|
||||
fn pasted_text(app: &FigApp) -> String {
|
||||
let doc = app.session.doc().unwrap();
|
||||
let id = app.session.selection()[0];
|
||||
doc.node(id).unwrap().kind.text().unwrap().characters.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_takes_layers_copied_in_another_design_app() {
|
||||
let html = copied_html();
|
||||
let mut app = FigApp::new(Session::new(), Box::new(Clip { html, text: Some(String::new()) }));
|
||||
crate::menus::paste(&mut app, None);
|
||||
assert_eq!(selected_names(&app), ["Copied layer"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_from_other_apps_pastes_as_before() {
|
||||
let clip = Clip { html: "<b>Hello</b>".into(), text: Some("Hello".into()) };
|
||||
let mut app = FigApp::new(Session::new(), Box::new(clip));
|
||||
crate::menus::paste(&mut app, None);
|
||||
assert_eq!(pasted_text(&app), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_that_only_mentions_the_marker_pastes_its_text() {
|
||||
let text = format!("Markers {CLIP_OPEN} and {CLIP_CLOSE} explained");
|
||||
let clip = Clip { html: format!("<p>{text}</p>"), text: Some(text.clone()) };
|
||||
let mut app = FigApp::new(Session::new(), Box::new(clip));
|
||||
crate::menus::paste(&mut app, None);
|
||||
assert_eq!(pasted_text(&app), text);
|
||||
assert!(app.ui.last_error.is_none(), "{:?}", app.ui.last_error);
|
||||
}
|
||||
|
||||
/// A clipboard whose contents the test replaces between pastes.
|
||||
struct SharedClip(std::rc::Rc<std::cell::RefCell<Clip>>);
|
||||
|
||||
impl Services for SharedClip {
|
||||
fn clipboard_get_html(&mut self) -> Option<String> {
|
||||
Some(self.0.borrow().html.clone())
|
||||
}
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
self.0.borrow().text.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_copied_after_a_design_paste_pastes_as_text() {
|
||||
let clip = std::rc::Rc::new(std::cell::RefCell::new(Clip { html: copied_html(), text: None }));
|
||||
let mut app = FigApp::new(Session::new(), Box::new(SharedClip(clip.clone())));
|
||||
crate::menus::paste(&mut app, None);
|
||||
assert_eq!(selected_names(&app), ["Copied layer"]);
|
||||
*clip.borrow_mut() = Clip { html: String::new(), text: Some("Fresh text".into()) };
|
||||
crate::menus::paste(&mut app, None);
|
||||
assert_eq!(pasted_text(&app), "Fresh text");
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Dialogs: About, Preferences, keyboard shortcuts, find & replace, rename layers, variables,
|
||||
//! export.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2};
|
||||
use figcraft_color::ColorProfile;
|
||||
use figcraft_doc::{ExportFormat, ExportSetting, ExportSize, NodeId};
|
||||
use figcraft_engine::cmd::file::{export_jobs, exportable_layers};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::export_panel::{export_layers, save_one, suggested_name};
|
||||
@@ -139,6 +141,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) {
|
||||
let open = u.color_profile.is_some()
|
||||
|| u.find.is_some()
|
||||
|| u.batch_rename.is_some()
|
||||
|| u.import_report.is_some()
|
||||
|| u.confirm_close
|
||||
|| u.export_preview
|
||||
|| u.shortcuts_panel
|
||||
@@ -147,7 +150,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) {
|
||||
if !open || !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
return;
|
||||
}
|
||||
if u.color_profile.take().is_some() || u.find.take().is_some() || u.batch_rename.take().is_some() {
|
||||
if u.color_profile.take().is_some() || u.find.take().is_some() || u.batch_rename.take().is_some() || u.import_report.take().is_some() {
|
||||
return;
|
||||
}
|
||||
for flag in [&mut u.confirm_close, &mut u.export_preview, &mut u.shortcuts_panel, &mut u.preferences, &mut u.about] {
|
||||
@@ -160,6 +163,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) {
|
||||
pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
// Dropped files waiting to open: their card, then (once it has shown) the files.
|
||||
crate::pending_open::pending_opens(app, ctx);
|
||||
crate::import_report::show(app, ctx);
|
||||
let t = Tokens::get(ctx);
|
||||
if app.ui.about {
|
||||
let mut open = true;
|
||||
@@ -505,17 +509,35 @@ fn color_profile_dialog(app: &mut FigApp, ctx: &Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.fig` Save as result is an export; only native saves report that the document was saved.
|
||||
pub(crate) fn file_saved(app: &mut FigApp, result: &Value) {
|
||||
if result["format"] == "fig" {
|
||||
fig_exported(app, result);
|
||||
} else {
|
||||
app.toast(trf("Saved {name}", &[("name", &result_file_name(result))]));
|
||||
}
|
||||
}
|
||||
|
||||
fn result_file_name(result: &Value) -> String {
|
||||
result["path"].as_str().and_then(|p| Path::new(p).file_name()).map(|f| f.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The `.fig` result stays an export: native save state is unchanged, and omissions are reported.
|
||||
pub(crate) fn fig_exported(app: &mut FigApp, result: &Value) {
|
||||
let file = result_file_name(result);
|
||||
let left_out = result["warnings"].as_array().map_or(0, Vec::len);
|
||||
app.toast(match left_out {
|
||||
0 => trf("Exported {name}", &[("name", &file)]),
|
||||
n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]),
|
||||
});
|
||||
}
|
||||
|
||||
/// Save the whole file as `.fig`; the toast says how much was left out.
|
||||
fn save_fig(app: &mut FigApp, name: &str) {
|
||||
let filename = format!("{name}.fig");
|
||||
app.pick_save(tr("Export"), &filename, &["fig"], |app, p| {
|
||||
let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
||||
if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) {
|
||||
let left_out = r["warnings"].as_array().map_or(0, Vec::len);
|
||||
app.toast(match left_out {
|
||||
0 => trf("Exported {name}", &[("name", &file)]),
|
||||
n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]),
|
||||
});
|
||||
fig_exported(app, &r);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -568,7 +568,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
action = action.or(chosen);
|
||||
match action {
|
||||
Some(Action::Open(path)) => {
|
||||
if app.run("file.open", json!({"path": path})).is_some() {
|
||||
if app.run_user("file.open", json!({"path": path})).is_some() {
|
||||
app.ui.home = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,9 +108,14 @@ fn lookup(lang: usize, key: &str) -> Option<&'static Text> {
|
||||
/// The UI string for `key` (its English text) in the current language. One English text that needs
|
||||
/// two translations gets a context after `|` ("Fill|sizing"); the English catalog maps it back.
|
||||
pub fn tr(key: &str) -> &str {
|
||||
match lookup(CURRENT.load(Ordering::Relaxed), key) {
|
||||
Some(Text::Plain(s) | Text::Plural { other: s, .. }) => s,
|
||||
None => key,
|
||||
try_tr(key).unwrap_or(key)
|
||||
}
|
||||
|
||||
/// [`tr`] for text that comes from data and may have no catalog entry (an importer's notes, say):
|
||||
/// `None` when the catalog doesn't have it.
|
||||
pub fn try_tr(key: &str) -> Option<&str> {
|
||||
match lookup(CURRENT.load(Ordering::Relaxed), key)? {
|
||||
Text::Plain(s) | Text::Plural { other: s, .. } => Some(s),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,15 +160,38 @@ fn used_keys() -> BTreeSet<String> {
|
||||
if !name.ends_with(".rs") || name.ends_with("_tests.rs") || name == "i18n.rs" {
|
||||
continue;
|
||||
}
|
||||
keys.extend(literals(&std::fs::read_to_string(&path).unwrap()));
|
||||
keys.extend(literals(&std::fs::read_to_string(&path).unwrap(), LOOKUPS));
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// The first-argument string literals of the lookup functions in `src`.
|
||||
fn literals(src: &str) -> Vec<String> {
|
||||
/// The importers' fixed notes: the string literals passed to `warn` in the `.fig` and REST import
|
||||
/// sources. The import report shows a note translated when the catalog has it, so an entry for one
|
||||
/// counts as used, and an entry for a note the importers no longer give shows up as unused.
|
||||
fn importer_notes() -> BTreeSet<String> {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../format/src");
|
||||
let mut keys = BTreeSet::new();
|
||||
for dir in ["fig", "rest"] {
|
||||
for entry in std::fs::read_dir(src.join(dir)).unwrap().flatten() {
|
||||
let path = entry.path();
|
||||
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
||||
// export.rs warns about what a `.fig` export leaves out, not about an import.
|
||||
if !name.ends_with(".rs") || name.contains("tests") || name == "export.rs" {
|
||||
continue;
|
||||
}
|
||||
keys.extend(literals(&std::fs::read_to_string(&path).unwrap(), &["warn("]));
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// The lookup functions whose first argument is a catalog key.
|
||||
const LOOKUPS: &[&str] = &["tr(", "trf(", "trn(", "tr_noop("];
|
||||
|
||||
/// The first-argument string literals of the functions `fns` (each with its `(`) in `src`.
|
||||
fn literals(src: &str, fns: &[&str]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for f in ["tr(", "trf(", "trn(", "tr_noop("] {
|
||||
for f in fns {
|
||||
let mut from = 0;
|
||||
while let Some(i) = src.get(from..).and_then(|s| s.find(f)) {
|
||||
let at = from + i;
|
||||
@@ -229,7 +252,8 @@ fn every_key_in_the_code_is_in_the_english_catalog_and_no_other() {
|
||||
let used: BTreeSet<String> = used_keys().into_iter().chain(dynamic_keys()).collect();
|
||||
let en: BTreeSet<String> = english().ui.iter().map(|(k, _)| k.to_string()).collect();
|
||||
let missing: Vec<_> = used.difference(&en).collect();
|
||||
let unused: Vec<_> = en.difference(&used).collect();
|
||||
let notes = importer_notes();
|
||||
let unused: Vec<_> = en.difference(&used).filter(|k| !notes.contains(*k)).collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"keys missing from locales/en.toml:\n{}",
|
||||
@@ -416,7 +440,8 @@ fn the_key_scanner_reads_rust_literals() {
|
||||
let src = r#"x(tr("A \"b\"")); trf(
|
||||
"C {n}", &[]); attr("no"); tr_noop("D\
|
||||
E"); tr(label);"#;
|
||||
assert_eq!(literals(src), vec!["A \"b\"", "C {n}", "DE"]);
|
||||
assert_eq!(literals(src, LOOKUPS), vec!["A \"b\"", "C {n}", "DE"]);
|
||||
assert_eq!(literals(r#"self.warn("a note"); s.warn(format!("{n}")); forewarn("no")"#, &["warn("]), vec!["a note"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! The import report: what a successful `.fig`, REST JSON or SVG import left out or changed, shown
|
||||
//! in a dialog window after the file opens, so the notes don't vanish with a toast.
|
||||
|
||||
use egui::RichText;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::i18n::{tr, trn, try_tr};
|
||||
use crate::theme::{Tokens, regular};
|
||||
|
||||
/// A report stays small even when a damaged input produces many or very long notes.
|
||||
pub const MAX_NOTES: usize = 500;
|
||||
pub const MAX_NOTE_CHARS: usize = 2_000;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Report {
|
||||
pub name: String,
|
||||
pub pages: Option<u64>,
|
||||
pub layers: Option<u64>,
|
||||
pub images: Option<u64>,
|
||||
/// The pages of an imported file were added to the open document.
|
||||
pub appended: bool,
|
||||
/// An SVG was placed on the current page of the open document.
|
||||
pub placed: bool,
|
||||
/// The document the report belongs to: it goes when that document closes.
|
||||
pub uid: u64,
|
||||
pub notes: Vec<String>,
|
||||
/// `notes` as the window shows them, in the UI language (worked out once, not every frame).
|
||||
pub shown: Vec<String>,
|
||||
pub note_count: usize,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// Only successful `file.open` and `file.import` results with import `warnings` create a report;
|
||||
/// a native file's `repairs` are shown by the engine's own message, and an SVG opened without
|
||||
/// notes has nothing to report. `params` are the command's, for the file's name.
|
||||
pub fn from_result(command: &str, params: &Value, result: &Value) -> Option<Self> {
|
||||
if !matches!(command, "file.open" | "file.openRecent" | "file.import") {
|
||||
return None;
|
||||
}
|
||||
let warnings = result.get("warnings").and_then(Value::as_array)?;
|
||||
let file_name = params.get("path").and_then(Value::as_str).and_then(|p| std::path::Path::new(p).file_name()).and_then(|n| n.to_str());
|
||||
if command != "file.import" && warnings.is_empty() && file_name.is_some_and(|n| n.to_ascii_lowercase().ends_with(".svg")) {
|
||||
return None;
|
||||
}
|
||||
// `file.import` of an SVG places a frame on the current page (no `pages`): its notes
|
||||
// are reported, but no pages were appended.
|
||||
let appended = command == "file.import" && result.get("pages").is_some();
|
||||
if command == "file.import" && !appended && warnings.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let summary = result.get("summary");
|
||||
let count = |key: &str| summary.and_then(|s| s.get(key)).and_then(Value::as_u64);
|
||||
let name = summary
|
||||
.and_then(|s| s.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.or(file_name)
|
||||
.or_else(|| params.get("name").and_then(Value::as_str))
|
||||
.unwrap_or(tr("Imported document"));
|
||||
let mut report = Self {
|
||||
name: name.chars().take(512).collect(),
|
||||
pages: count("pages"),
|
||||
layers: count("layers"),
|
||||
images: count("images"),
|
||||
appended,
|
||||
placed: command == "file.import" && !appended,
|
||||
uid: 0,
|
||||
notes: Vec::new(),
|
||||
shown: Vec::new(),
|
||||
note_count: 0,
|
||||
};
|
||||
for note in warnings.iter().filter_map(Value::as_str) {
|
||||
report.add_note(note);
|
||||
}
|
||||
Some(report)
|
||||
}
|
||||
|
||||
fn add_note(&mut self, note: &str) {
|
||||
self.note_count = self.note_count.saturating_add(1);
|
||||
if self.notes.len() < MAX_NOTES {
|
||||
let mut text: String = note.chars().take(MAX_NOTE_CHARS).collect();
|
||||
if note.chars().nth(MAX_NOTE_CHARS).is_some() {
|
||||
text.push('…');
|
||||
}
|
||||
self.shown.push(note_text(&text));
|
||||
self.notes.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inspect(&self) -> Value {
|
||||
json!({"name": self.name, "pages": self.pages, "layers": self.layers, "images": self.images,
|
||||
"appended": self.appended, "placed": self.placed, "warnings": self.notes, "warningCount": self.note_count})
|
||||
}
|
||||
}
|
||||
|
||||
/// `note` in the UI language when the catalog has it (the importers' fixed notes: `.fig` imports
|
||||
/// name "the file" where REST imports name the JSON they read); a repeat count (` (×N)`) is kept.
|
||||
/// The stored notes are lower-case fragments; a known note is shown as a sentence, so its first
|
||||
/// letter is capitalised here. Other notes (SVG's, say) are shown as they are.
|
||||
pub(crate) fn note_text(note: &str) -> String {
|
||||
let translated = |base: &str| {
|
||||
let Some(text) = try_tr(base) else { return base.to_owned() };
|
||||
let mut chars = text.chars();
|
||||
chars.next().map_or_else(String::new, |first| first.to_uppercase().chain(chars).collect())
|
||||
};
|
||||
match note.rsplit_once(" (×") {
|
||||
Some((base, count)) if count.strip_suffix(')').is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) => {
|
||||
format!("{} (×{count}", translated(base))
|
||||
}
|
||||
_ => translated(note),
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the report for a file the user opened or imported (menus, drops, pastes, recents, the
|
||||
/// system's open event); commands from the control channel, MCP or scripts never call this.
|
||||
pub fn capture(app: &mut FigApp, command: &str, params: &Value, result: &Value) {
|
||||
if let Some(mut report) = Report::from_result(command, params, result) {
|
||||
report.uid = app.session.ds().map_or(0, |d| d.uid);
|
||||
app.ui.import_report = Some(report);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
// The report goes with its document.
|
||||
if app.ui.import_report.as_ref().is_some_and(|r| !app.session.docs.iter().any(|d| d.uid == r.uid)) {
|
||||
app.ui.import_report = None;
|
||||
}
|
||||
let Some(report) = &app.ui.import_report else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let mut open = true;
|
||||
let mut close_clicked = false;
|
||||
crate::dialogs::window(tr("Import report"), ctx).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(420.0);
|
||||
ui.label(RichText::new(&report.name).strong());
|
||||
ui.label(if report.appended {
|
||||
tr("Pages added to the document")
|
||||
} else if report.placed {
|
||||
tr("Placed on the current page")
|
||||
} else {
|
||||
tr("Opened as a new document")
|
||||
});
|
||||
let counts: Vec<String> = [(tr("Pages"), report.pages), (tr("Layers"), report.layers), (tr("Images"), report.images)]
|
||||
.into_iter()
|
||||
.filter_map(|(label, count)| count.map(|n| format!("{label}: {n}")))
|
||||
.collect();
|
||||
if !counts.is_empty() {
|
||||
ui.label(counts.join(" · "));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if report.notes.is_empty() {
|
||||
ui.label(tr("No compatibility warnings reported"));
|
||||
} else {
|
||||
ui.label(RichText::new(tr("Compatibility notes")).strong());
|
||||
ui.label(
|
||||
RichText::new(tr(
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged.",
|
||||
))
|
||||
.color(t.text_secondary)
|
||||
.font(regular(11.0)),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
egui::ScrollArea::vertical().id_salt("import_report_notes").max_height(260.0).show(ui, |ui| {
|
||||
for note in &report.shown {
|
||||
ui.label(note);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
let omitted = report.note_count.saturating_sub(report.notes.len());
|
||||
if omitted > 0 {
|
||||
ui.label(trn("{n} more notes omitted from this report", omitted, &[]));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
close_clicked = crate::widgets::button(ui, tr("Close"), true, true).clicked();
|
||||
});
|
||||
if !open || close_clicked {
|
||||
app.ui.import_report = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::import_report::{MAX_NOTE_CHARS, MAX_NOTES, Report, note_text};
|
||||
use crate::{FigApp, NoServices};
|
||||
|
||||
#[test]
|
||||
fn import_results_with_warnings_become_reports_with_counts() {
|
||||
let result =
|
||||
json!({"warnings": ["layers without a type were skipped"], "summary": {"name": "Synthetic design", "pages": 2, "layers": 12, "images": 3}});
|
||||
let report = Report::from_result("file.import", &json!({}), &result).unwrap();
|
||||
assert_eq!(report.name, "Synthetic design");
|
||||
assert_eq!((report.pages, report.layers, report.images), (Some(2), Some(12), Some(3)));
|
||||
assert_eq!(report.notes, ["layers without a type were skipped"]);
|
||||
assert_eq!(report.note_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_import_commands_with_warnings_create_reports() {
|
||||
// A warning-free `.fig` or REST open still has a summary to show.
|
||||
let report = Report::from_result("file.open", &json!({}), &json!({"warnings": [], "summary": {"name": "Local", "pages": 1}})).unwrap();
|
||||
assert!(!report.appended && report.notes.is_empty());
|
||||
// A native file's repairs have their own message; an SVG without notes has no `warnings`.
|
||||
assert!(Report::from_result("file.open", &json!({}), &json!({"repairs": ["Repaired parent"]})).is_none());
|
||||
assert!(Report::from_result("file.open", &json!({}), &json!({"index": 0})).is_none());
|
||||
assert!(Report::from_result("file.export", &json!({}), &json!({"warnings": ["Export warning"]})).is_none());
|
||||
assert!(Report::from_result("shape.rectangle", &json!({}), &json!({})).is_none());
|
||||
// File ▸ Open recent opens by path and reports like File ▸ Open.
|
||||
let recent = json!({"warnings": ["layers without a type were skipped"], "summary": {"name": "Recent", "pages": 1}});
|
||||
assert!(Report::from_result("file.openRecent", &json!({"path": "Recent.json"}), &recent).is_some());
|
||||
assert!(Report::from_result("file.openRecent", &json!({"path": "Clean.svg"}), &json!({"warnings": []})).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_placements_are_not_reported_as_appended_pages() {
|
||||
assert!(Report::from_result("file.import", &json!({}), &json!({"id": 7})).is_none());
|
||||
let report = Report::from_result("file.import", &json!({}), &json!({"id": 7, "warnings": ["Filter skipped"]})).unwrap();
|
||||
assert!(!report.appended);
|
||||
assert!(Report::from_result("file.import", &json!({}), &json!({"pages": [1], "summary": {"pages": 1}, "warnings": []})).unwrap().appended);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_retention_is_bounded_and_truncates_only_at_character_boundaries() {
|
||||
let long_note = "🦎".repeat(MAX_NOTE_CHARS + 20);
|
||||
let notes: Vec<_> = std::iter::repeat_n(long_note, MAX_NOTES + 37).collect();
|
||||
let report = Report::from_result("file.open", &json!({}), &json!({"warnings": notes})).unwrap();
|
||||
assert_eq!(report.note_count, MAX_NOTES + 37);
|
||||
assert_eq!(report.notes.len(), MAX_NOTES);
|
||||
assert!(report.notes.iter().all(|note| note.chars().count() == MAX_NOTE_CHARS + 1 && note.ends_with('…')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_text_keeps_the_repeat_suffix_and_unknown_notes() {
|
||||
let base = "instances of components that aren't in the file were imported as frames";
|
||||
let repeated = note_text(&format!("{base} (×122)"));
|
||||
assert_eq!(repeated, "Instances of components that aren't in the file were imported as frames (×122)");
|
||||
assert_eq!(note_text("some unknown note"), "some unknown note");
|
||||
assert_eq!(note_text("weird (×x)"), "weird (×x)");
|
||||
assert_eq!(note_text("weird (×12) tail"), "weird (×12) tail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_style_notes_are_known_import_notes() {
|
||||
for note in [
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners",
|
||||
"vector points with unknown stroke joins use the layer join",
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join",
|
||||
] {
|
||||
let shown = note_text(note);
|
||||
assert!(shown.chars().next().is_some_and(char::is_uppercase), "{shown}");
|
||||
assert_eq!(shown[shown.chars().next().unwrap().len_utf8()..], note[note.chars().next().unwrap().len_utf8()..]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_rest_json_file_reports_what_was_skipped() {
|
||||
let path = std::env::temp_dir().join(format!("figcraft-import-report-{}.json", std::process::id()));
|
||||
let file = json!({"document": {"id": "0:0", "name": "Synthetic", "type": "DOCUMENT", "children": [
|
||||
{"id": "0:1", "name": "Page 1", "type": "CANVAS", "children": [
|
||||
{"id": "1:1", "name": "Card", "type": "FRAME", "size": {"x": 200, "y": 100}, "relativeTransform": [[1, 0, 10], [0, 1, 20]],
|
||||
"children": [{"id": "1:2", "name": "No type"}]}]}]}});
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let mut app = FigApp::new(Session::new(), Box::new(NoServices));
|
||||
// A programmatic open (control channel, MCP) never opens the window.
|
||||
assert!(app.run("file.open", json!({"path": path.to_string_lossy()})).is_some());
|
||||
assert!(app.ui.import_report.is_none());
|
||||
let opened = app.run_user("file.open", json!({"path": path.to_string_lossy()}));
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert!(opened.is_some());
|
||||
let report = app.ui.import_report.as_ref().expect("a report for the REST import");
|
||||
assert!(!report.appended);
|
||||
assert_eq!(report.pages, Some(1));
|
||||
assert!(report.layers.is_some_and(|n| n >= 1));
|
||||
assert!(report.notes.iter().any(|n| n == "layers without a type were skipped"), "{:?}", report.notes);
|
||||
assert_eq!(app.inspect()["importReport"]["warningCount"], report.note_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_closes_the_import_report_like_other_simple_dialogs() {
|
||||
let mut u = crate::layers_drag_tests::Ui::new(Session::new());
|
||||
let mut report = Report::from_result("file.open", &json!({}), &json!({"warnings": ["Synthetic note"], "summary": {"name": "Synthetic design"}}));
|
||||
report.as_mut().unwrap().uid = u.app.session.ds().unwrap().uid;
|
||||
u.app.ui.import_report = report;
|
||||
u.frame(vec![]);
|
||||
let escape =
|
||||
vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }];
|
||||
u.frame(escape);
|
||||
assert!(u.app.ui.import_report.is_none(), "Escape closes the report");
|
||||
}
|
||||
|
||||
fn shown_texts(shapes: Vec<egui::epaint::ClippedShape>) -> Vec<String> {
|
||||
fn walk(shape: &egui::Shape, out: &mut Vec<String>) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
shapes.iter().for_each(|c| walk(&c.shape, &mut out));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_svg_open_has_no_report_and_a_placed_svg_says_where_it_went() {
|
||||
let svg_open = Report::from_result("file.open", &json!({"path": "icons/Logo.SVG"}), &json!({"warnings": []}));
|
||||
assert!(svg_open.is_none(), "a clean SVG opened with file.open has nothing to report");
|
||||
let noted = Report::from_result("file.open", &json!({"path": "Logo.svg"}), &json!({"warnings": ["Filter skipped"]})).unwrap();
|
||||
assert_eq!(noted.name, "Logo.svg");
|
||||
|
||||
let placed = Report::from_result("file.import", &json!({"path": "dir/Logo.svg"}), &json!({"id": 7, "warnings": ["Filter skipped"]})).unwrap();
|
||||
assert!(placed.placed && !placed.appended);
|
||||
assert_eq!(placed.name, "Logo.svg");
|
||||
let mut u = crate::layers_drag_tests::Ui::new(Session::new());
|
||||
let mut placed = placed;
|
||||
placed.uid = u.app.session.ds().unwrap().uid;
|
||||
u.app.ui.import_report = Some(placed);
|
||||
let texts = shown_texts({
|
||||
u.frame(vec![]);
|
||||
u.frame_output(vec![]).shapes
|
||||
});
|
||||
assert!(texts.iter().any(|t| t == "Placed on the current page"), "{texts:?}");
|
||||
assert!(!texts.iter().any(|t| t == "Opened as a new document"), "{texts:?}");
|
||||
assert!(texts.iter().any(|t| t == "Logo.svg"), "{texts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_goes_when_its_document_closes_and_counts_omitted_notes_in_the_singular() {
|
||||
let mut u = crate::layers_drag_tests::Ui::new(Session::new());
|
||||
let mut report = Report::from_result("file.open", &json!({}), &json!({"warnings": ["One note"], "summary": {"name": "Synthetic"}})).unwrap();
|
||||
report.uid = u.app.session.ds().unwrap().uid;
|
||||
report.note_count = 2;
|
||||
u.app.ui.import_report = Some(report);
|
||||
let texts = shown_texts({
|
||||
u.frame(vec![]);
|
||||
u.frame_output(vec![]).shapes
|
||||
});
|
||||
assert!(texts.iter().any(|t| t == "1 more note omitted from this report"), "{texts:?}");
|
||||
|
||||
u.app.session.execute("file.new", &json!({"name": "Other"})).unwrap();
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.ui.import_report.is_some(), "another tab was added; the report's document is still open");
|
||||
u.app.session.execute("file.close", &json!({"index": 0, "force": true})).unwrap();
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.ui.import_report.is_none(), "the report left with its document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_file_shows_its_report_once_it_opens() {
|
||||
let path = std::env::temp_dir().join(format!("figcraft-import-report-drop-{}.json", std::process::id()));
|
||||
let file = json!({"document": {"id": "0:0", "name": "Dropped", "type": "DOCUMENT", "children": [
|
||||
{"id": "0:1", "name": "Page 1", "type": "CANVAS", "children": [{"id": "1:1", "name": "No type"}]}]}});
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let mut u = crate::layers_drag_tests::Ui::new(Session::new());
|
||||
u.app.ui.pending_opens.push(crate::pending_open::PendingOpen {
|
||||
label: "Dropped.json".into(),
|
||||
params: json!({"path": path.to_string_lossy()}),
|
||||
command: "file.open",
|
||||
target: None,
|
||||
});
|
||||
for _ in 0..6 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let report = u.app.ui.import_report.as_ref().expect("the dropped file's report");
|
||||
assert_eq!(report.uid, u.app.session.ds().unwrap().uid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_opened_from_base64_data_has_a_summary_too() {
|
||||
use base64::Engine as _;
|
||||
let file = json!({"document": {"id": "0:0", "name": "From data", "type": "DOCUMENT", "children": [
|
||||
{"id": "0:1", "name": "Page 1", "type": "CANVAS", "children": [{"id": "1:1", "name": "No type"}]}]}});
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(file.to_string());
|
||||
let mut app = FigApp::new(Session::new(), Box::new(NoServices));
|
||||
let opened = app.run_user("file.open", json!({"data": data, "name": "Dropped.json"})).unwrap();
|
||||
assert_eq!(opened["summary"]["pages"], 1);
|
||||
let report = app.ui.import_report.as_ref().expect("a report for the REST import");
|
||||
assert_eq!(report.pages, Some(1));
|
||||
assert!(report.notes.iter().any(|n| n == "layers without a type were skipped"), "{:?}", report.notes);
|
||||
// Shown as a sentence, worked out once when the report opens.
|
||||
assert!(report.shown.iter().any(|n| n == "Layers without a type were skipped"), "{:?}", report.shown);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ pub mod home;
|
||||
pub mod home_recents;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod import_report;
|
||||
pub mod layers;
|
||||
pub mod layout_panel;
|
||||
pub mod list_drag;
|
||||
@@ -86,6 +87,8 @@ mod comments_tests;
|
||||
#[cfg(test)]
|
||||
mod cursors_tests;
|
||||
#[cfg(test)]
|
||||
mod design_paste_tests;
|
||||
#[cfg(test)]
|
||||
mod dev_mode_tests;
|
||||
#[cfg(test)]
|
||||
mod duplicate_paint_tests;
|
||||
@@ -108,6 +111,8 @@ mod home_tests;
|
||||
#[cfg(test)]
|
||||
mod idle_tests;
|
||||
#[cfg(test)]
|
||||
mod import_report_tests;
|
||||
#[cfg(test)]
|
||||
mod layers_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod layers_drag_tests;
|
||||
@@ -162,6 +167,8 @@ mod proto_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_responsive_tests;
|
||||
#[cfg(test)]
|
||||
mod save_as_tests;
|
||||
#[cfg(test)]
|
||||
mod select_all_tests;
|
||||
#[cfg(test)]
|
||||
mod shortcut_keys_tests;
|
||||
@@ -226,6 +233,9 @@ pub trait Services {
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn clipboard_get_html(&mut self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) {}
|
||||
/// An image on the system clipboard as (width, height, straight RGBA).
|
||||
fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec<u8>)> {
|
||||
@@ -456,6 +466,8 @@ pub struct UiState {
|
||||
/// ([`pending_open::pending_opens`]), and the frames it has been drawn.
|
||||
pub pending_opens: Vec<pending_open::PendingOpen>,
|
||||
pub pending_open_frames: u8,
|
||||
/// The latest successful file import's summary and compatibility notes.
|
||||
pub import_report: Option<import_report::Report>,
|
||||
pub screenshot_requests: Vec<(u64, String)>,
|
||||
pub frame_ms: f64,
|
||||
pub render_ms: f64,
|
||||
@@ -564,6 +576,7 @@ impl Default for UiState {
|
||||
last_error: None,
|
||||
pending_opens: Vec::new(),
|
||||
pending_open_frames: 0,
|
||||
import_report: None,
|
||||
screenshot_requests: Vec::new(),
|
||||
frame_ms: 0.0,
|
||||
render_ms: 0.0,
|
||||
@@ -676,6 +689,16 @@ impl FigApp {
|
||||
r.map_err(|e| self.failed(id, &e)).ok()
|
||||
}
|
||||
|
||||
/// [`run`](Self::run) for something the user did (a dialog, a drop, a paste, a recent file):
|
||||
/// a file it imports shows its import report. Programmatic calls use `run` and never open one.
|
||||
pub fn run_user(&mut self, id: &str, params: Value) -> Option<Value> {
|
||||
let result = self.run(id, params.clone());
|
||||
if let Some(result) = &result {
|
||||
import_report::capture(self, id, ¶ms, result);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Show command `id`'s error.
|
||||
fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) {
|
||||
let msg = e.to_string();
|
||||
@@ -938,11 +961,31 @@ impl FigApp {
|
||||
}
|
||||
|
||||
pub fn save_as_dialog(&mut self) {
|
||||
let name = self.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into());
|
||||
self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"], |app, p| {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
if app.run("file.saveAs", json!({"path": ps})).is_some() {
|
||||
app.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())]));
|
||||
let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return };
|
||||
self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| {
|
||||
if p.extension().is_none_or(|e| e.is_empty()) {
|
||||
if !p.set_extension("figcraft") {
|
||||
app.toast(i18n::tr("Use the .figcraft or .fig extension."));
|
||||
return;
|
||||
}
|
||||
// This name wasn't confirmed by the system dialog: don't replace an existing file.
|
||||
match p.try_exists() {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
app.toast(i18n::tr("A .figcraft file with this name already exists. Choose its full name to confirm replacement."));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
app.toast(e.to_string());
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if !p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("figcraft") || e.eq_ignore_ascii_case("fig")) {
|
||||
app.toast(i18n::tr("Use the .figcraft or .fig extension."));
|
||||
return;
|
||||
}
|
||||
if let Some(r) = app.run("file.saveAs", json!({"path": p.to_string_lossy()})) {
|
||||
dialogs::file_saved(app, &r);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -950,7 +993,14 @@ impl FigApp {
|
||||
pub fn save(&mut self) {
|
||||
// A branch tab saves its main file.
|
||||
let tab = figcraft_engine::branches::file_tab(&self.session);
|
||||
if self.session.docs.get(tab).is_some_and(|d| d.path.is_some()) {
|
||||
// A path ending in .fig is never saved over silently: a .fig destination is an export copy.
|
||||
let native_path = self
|
||||
.session
|
||||
.docs
|
||||
.get(tab)
|
||||
.and_then(|d| d.path.as_deref())
|
||||
.is_some_and(|path| !Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("fig")));
|
||||
if native_path {
|
||||
if self.run("file.save", json!({})).is_some() {
|
||||
self.toast(i18n::tr("Saved"));
|
||||
}
|
||||
@@ -966,7 +1016,7 @@ impl FigApp {
|
||||
false,
|
||||
|app, p| {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
if app.run("file.open", json!({"path": ps})).is_some() {
|
||||
if app.run_user("file.open", json!({"path": ps})).is_some() {
|
||||
app.ui.home = false;
|
||||
}
|
||||
},
|
||||
@@ -982,7 +1032,7 @@ impl FigApp {
|
||||
let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") };
|
||||
self.pick_open(title, exts, move |app, p| {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
app.run("file.import", json!({"path": ps}));
|
||||
app.run_user("file.import", json!({"path": ps}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1527,6 +1577,7 @@ impl FigApp {
|
||||
"panels": {"left": self.ui.left_width, "right": self.ui.right_width, "leftCollapsed": self.ui.left_collapsed},
|
||||
"status": self.session.status(),
|
||||
"lastError": self.ui.last_error,
|
||||
"importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect),
|
||||
});
|
||||
state["pendingOpens"] = json!(self.ui.pending_opens.len());
|
||||
state
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::a11y;
|
||||
use crate::i18n::{self, tr, tr_noop, trf};
|
||||
use crate::i18n::{self, tr, tr_noop, trf, trn};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{ACCENT, Tokens, regular};
|
||||
use crate::toolbar::pretty_shortcut;
|
||||
@@ -30,10 +30,23 @@ pub const UI_COMMANDS: &[&str] = &[
|
||||
];
|
||||
|
||||
/// Run a menu item: some open dialogs or use platform services, the rest go to the engine.
|
||||
/// Paste: the system clipboard wins when it holds something newer than our own copy (an image, SVG
|
||||
/// or text from another app); otherwise our copied layers. `event_text` is the text the platform
|
||||
/// handed over with the paste event (the browser has no other way to read it).
|
||||
/// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied
|
||||
/// from another design app, an image, SVG or text from another app); otherwise our copied layers.
|
||||
/// `event_text` is the text the platform handed over with the paste event (the browser has no
|
||||
/// other way to read it).
|
||||
pub fn paste(app: &mut FigApp, event_text: Option<&str>) {
|
||||
// Only HTML that holds readable design data takes this path; other HTML pastes as before.
|
||||
if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) {
|
||||
if let Some(r) = app.run("edit.pasteDesignLayers", json!({"html": html})) {
|
||||
// The pasted layers are now our copy: text another app puts on the clipboard later is newer.
|
||||
app.ui.last_copy = Some(app.services.clipboard_get_text().unwrap_or_default());
|
||||
let left_out = r["warnings"].as_array().map_or(0, Vec::len);
|
||||
if left_out > 0 {
|
||||
app.toast(trn("Pasted design layers; {n} kinds of content were left out", left_out, &[]));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty());
|
||||
let ours = sys_text.is_some() && sys_text == app.ui.last_copy;
|
||||
let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0));
|
||||
@@ -54,7 +67,7 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>) {
|
||||
} else if figcraft_engine::looks_like_svg(&text) {
|
||||
use base64::Engine as _;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
|
||||
app.run("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": true}));
|
||||
app.run_user("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": true}));
|
||||
} else {
|
||||
app.run("text.create", json!({"text": text, "x": cx, "y": cy}));
|
||||
}
|
||||
@@ -234,12 +247,12 @@ fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &s
|
||||
let picked = submenu(ui, prefix, &format!("{prefix}/{key}"), label, t, |ui| {
|
||||
let recents = &app.session.prefs.recent_files;
|
||||
let mut picked = None;
|
||||
for (i, p) in recents.iter().enumerate() {
|
||||
for p in recents.iter() {
|
||||
let path = std::path::Path::new(p);
|
||||
let name = path.file_name().map_or_else(|| p.clone(), |f| f.to_string_lossy().into_owned());
|
||||
let exists = path.exists();
|
||||
if item(ui, &name, "", exists, false, t) && exists {
|
||||
picked = Some(json!({"index": i}));
|
||||
picked = Some(json!({"path": p}));
|
||||
}
|
||||
}
|
||||
if !recents.is_empty() {
|
||||
@@ -251,7 +264,8 @@ fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &s
|
||||
picked
|
||||
});
|
||||
let Some(params) = picked else { return false };
|
||||
if app.run("file.openRecent", params).is_some_and(|r| r.get("index").is_some()) {
|
||||
// Through the user path, so an import report shows; `file.openRecent` also drops a file that is gone.
|
||||
if app.run_user("file.openRecent", params).is_some_and(|r| r.get("index").is_some()) {
|
||||
app.ui.home = false;
|
||||
}
|
||||
true
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct ImportTarget {
|
||||
fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option<Value> {
|
||||
let target = match pending.target {
|
||||
Some(target) => target,
|
||||
None if pending.command != "file.import" => return app.run(pending.command, pending.params),
|
||||
None if pending.command != "file.import" => return app.run_user(pending.command, pending.params),
|
||||
None => {
|
||||
cancel(app, tr("The destination document is unavailable. Nothing was imported.").to_string());
|
||||
return None;
|
||||
@@ -49,7 +49,7 @@ fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option<Value> {
|
||||
let previous_selection = app.session.docs[index].selection.clone();
|
||||
app.session.active = index;
|
||||
app.session.docs[index].page = target.page;
|
||||
let result = app.run(pending.command, pending.params);
|
||||
let result = app.run_user(pending.command, pending.params);
|
||||
if previous_page != target.page
|
||||
&& let Some(doc) = app.session.docs.iter_mut().find(|doc| doc.uid == target.uid)
|
||||
&& doc.doc.pages.contains(&previous_page)
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Native Save as accepts both file formats; .fig copies preserve native save state.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::dialogs::file_saved;
|
||||
use crate::{DialogFuture, FigApp, Services};
|
||||
|
||||
type Picks = Arc<Mutex<Vec<(String, String, Vec<String>)>>>;
|
||||
|
||||
struct Picker {
|
||||
path: Option<PathBuf>,
|
||||
picks: Picks,
|
||||
}
|
||||
|
||||
impl Services for Picker {
|
||||
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
|
||||
self.picks.lock().unwrap().push((title.into(), name.into(), exts.iter().map(|e| (*e).into()).collect()));
|
||||
Box::pin(std::future::ready(self.path.take()))
|
||||
}
|
||||
}
|
||||
|
||||
struct Temp(PathBuf);
|
||||
|
||||
impl Temp {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("figcraft-save-as-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
self.0.join(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Temp {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(files) = std::fs::read_dir(&self.0) {
|
||||
for file in files.flatten() {
|
||||
let _ = std::fs::remove_file(file.path());
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().name = "Prototype".into();
|
||||
s.execute("shape.rectangle", &json!({"width": 100, "height": 80})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn picker(path: Option<PathBuf>) -> (Picker, Picks) {
|
||||
let picks = Picks::default();
|
||||
(Picker { path, picks: picks.clone() }, picks)
|
||||
}
|
||||
|
||||
fn app(path: Option<PathBuf>) -> (FigApp, Picks) {
|
||||
let (picker, picks) = picker(path);
|
||||
(FigApp::new(session(), Box::new(picker)), picks)
|
||||
}
|
||||
|
||||
fn toast(app: &FigApp) -> &str {
|
||||
&app.ui.toast.as_ref().unwrap().0
|
||||
}
|
||||
|
||||
fn expected_picker() -> Vec<(String, String, Vec<String>)> {
|
||||
vec![("Save as".into(), "Prototype.figcraft".into(), vec!["figcraft".into(), "fig".into()])]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_menu_and_first_save_open_the_platform_picker_directly_with_both_extensions() {
|
||||
for action in ["direct", "menu", "first"] {
|
||||
let (mut app, picks) = app(None);
|
||||
let before = app.session.doc().unwrap().clone();
|
||||
match action {
|
||||
"direct" => app.save_as_dialog(),
|
||||
"menu" => crate::menus::invoke(&mut app, "file.saveAs"),
|
||||
"first" => app.save(),
|
||||
_ => panic!("unknown save action {action}"),
|
||||
}
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(*picks.lock().unwrap(), expected_picker());
|
||||
assert_eq!(app.session.doc().unwrap(), &before);
|
||||
assert!(app.session.ds().unwrap().path.is_none());
|
||||
assert!(app.session.ds().unwrap().is_dirty());
|
||||
assert!(app.ui.toast.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pathless_engine_request_uses_the_same_platform_picker() {
|
||||
let (mut app, picks) = app(None);
|
||||
let before = app.session.doc().unwrap().clone();
|
||||
assert!(app.run("file.saveAs", json!({})).is_none(), "the engine requests the UI and refuses a pathless programmatic save");
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(*picks.lock().unwrap(), expected_picker());
|
||||
assert_eq!(app.session.doc().unwrap(), &before);
|
||||
assert!(app.session.ds().unwrap().path.is_none());
|
||||
assert!(app.session.ds().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_paths_and_an_extensionless_default_save_native_files_and_show_the_written_name() {
|
||||
let temp = Temp::new();
|
||||
for (chosen, written) in [("native", "native.figcraft"), ("saved.figcraft", "saved.figcraft"), ("upper.FIGCRAFT", "upper.FIGCRAFT")] {
|
||||
let (mut app, picks) = app(Some(temp.path(chosen)));
|
||||
app.save_as_dialog();
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
let path = temp.path(written);
|
||||
assert_eq!(*picks.lock().unwrap(), expected_picker());
|
||||
assert!(path.is_file());
|
||||
assert_eq!(app.session.ds().unwrap().path.as_deref(), path.to_str());
|
||||
assert!(!app.session.ds().unwrap().is_dirty());
|
||||
assert_eq!(toast(&app), format!("Saved {written}"));
|
||||
let mut reopened = Session::empty();
|
||||
reopened.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(reopened.doc().unwrap().nodes().count(), app.session.doc().unwrap().nodes().count());
|
||||
assert_eq!(reopened.doc().unwrap().name, "Prototype");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returned_fig_paths_export_real_copies_without_changing_native_save_state() {
|
||||
let temp = Temp::new();
|
||||
for name in ["copy.fig", "upper.FIG"] {
|
||||
let path = temp.path(name);
|
||||
let (mut app, picks) = app(Some(path.clone()));
|
||||
let native = temp.path("working.figcraft").to_string_lossy().into_owned();
|
||||
app.session.ds_mut().unwrap().path = Some(native.clone());
|
||||
let before = app.session.doc().unwrap().clone();
|
||||
app.save_as_dialog();
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(*picks.lock().unwrap(), expected_picker());
|
||||
assert!(path.is_file());
|
||||
assert_eq!(app.session.doc().unwrap(), &before);
|
||||
assert_eq!(app.session.ds().unwrap().path.as_deref(), Some(native.as_str()));
|
||||
assert!(app.session.ds().unwrap().is_dirty());
|
||||
assert_eq!(toast(&app), format!("Exported {name}"));
|
||||
let mut reopened = Session::empty();
|
||||
reopened.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(reopened.doc().unwrap().nodes().count(), before.nodes().count());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fig_copy_reports_actual_engine_warnings_and_keeps_the_original_content() {
|
||||
let temp = Temp::new();
|
||||
let (mut app, _) = app(Some(temp.path("comments.fig")));
|
||||
app.run("comment.add", json!({"x": 10, "y": 20, "text": "Synthetic comment"})).unwrap();
|
||||
let before = app.session.doc().unwrap().clone();
|
||||
app.save_as_dialog();
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert!(temp.path("comments.fig").is_file());
|
||||
assert_eq!(toast(&app), "Exported comments.fig; 1 kind of content was left out");
|
||||
assert_eq!(app.session.doc().unwrap(), &before);
|
||||
assert_eq!(app.session.doc().unwrap().comments.len(), 1);
|
||||
assert!(app.session.ds().unwrap().path.is_none());
|
||||
assert!(app.session.ds().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_the_platform_dialog_preserves_document_selection_path_and_dirty_state() {
|
||||
let (mut app, picks) = app(None);
|
||||
app.session.ds_mut().unwrap().path = Some("working.figcraft".into());
|
||||
let before = app.session.doc().unwrap().clone();
|
||||
let selection = app.session.ds().unwrap().selection.clone();
|
||||
app.save_as_dialog();
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(*picks.lock().unwrap(), expected_picker());
|
||||
assert_eq!(app.session.doc().unwrap(), &before);
|
||||
assert_eq!(app.session.ds().unwrap().selection, selection);
|
||||
assert_eq!(app.session.ds().unwrap().path.as_deref(), Some("working.figcraft"));
|
||||
assert!(app.session.ds().unwrap().is_dirty());
|
||||
assert!(app.ui.toast.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_extensions_are_rejected_without_overwriting_the_chosen_file() {
|
||||
let temp = Temp::new();
|
||||
for name in ["wrong.svg", "wrong.png"] {
|
||||
let path = temp.path(name);
|
||||
std::fs::write(&path, b"existing document").unwrap();
|
||||
let (mut app, _) = app(Some(path.clone()));
|
||||
let before = app.session.doc().unwrap().clone();
|
||||
app.save_as_dialog();
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"existing document");
|
||||
assert_eq!(app.session.doc().unwrap(), &before);
|
||||
assert!(app.session.ds().unwrap().path.is_none());
|
||||
assert!(app.session.ds().unwrap().is_dirty());
|
||||
assert_eq!(toast(&app), "Use the .figcraft or .fig extension.");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_the_default_extension_never_overwrites_another_existing_path_without_confirmation() {
|
||||
let temp = Temp::new();
|
||||
let path = temp.path("existing.figcraft");
|
||||
std::fs::write(&path, b"existing document").unwrap();
|
||||
let (mut app, _) = app(Some(temp.path("existing")));
|
||||
app.save_as_dialog();
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"existing document");
|
||||
assert!(!temp.path("existing").exists());
|
||||
assert!(app.session.ds().unwrap().path.is_none());
|
||||
assert!(app.session.ds().unwrap().is_dirty());
|
||||
assert_eq!(toast(&app), "A .figcraft file with this name already exists. Choose its full name to confirm replacement.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_save_results_report_the_actual_name_and_plural_warning_count_without_marking_native_state_saved() {
|
||||
let (mut app, _) = app(None);
|
||||
let before = app.session.doc().unwrap().clone();
|
||||
app.session.ds_mut().unwrap().path = Some("working.figcraft".into());
|
||||
file_saved(&mut app, &json!({"format": "fig", "path": "written.fig", "warnings": ["synthetic warning", "another synthetic warning"]}));
|
||||
assert_eq!(toast(&app), "Exported written.fig; 2 kinds of content were left out");
|
||||
assert_eq!(app.session.doc().unwrap(), &before);
|
||||
assert_eq!(app.session.ds().unwrap().path.as_deref(), Some("working.figcraft"));
|
||||
assert!(app.session.ds().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_with_a_native_path_keeps_using_it_without_a_dialog() {
|
||||
let temp = Temp::new();
|
||||
let (mut app, picks) = app(None);
|
||||
let path = temp.path("working.figcraft");
|
||||
app.run("file.saveAs", json!({"path": path})).unwrap();
|
||||
app.run("shape.rectangle", json!({"width": 10, "height": 20})).unwrap();
|
||||
assert!(app.session.ds().unwrap().is_dirty());
|
||||
app.save();
|
||||
assert!(picks.lock().unwrap().is_empty());
|
||||
assert_eq!(app.session.ds().unwrap().path.as_deref(), path.to_str());
|
||||
assert!(!app.session.ds().unwrap().is_dirty());
|
||||
assert_eq!(toast(&app), "Saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_legacy_fig_paths_keeps_the_success_toast_and_original_source_bytes() {
|
||||
for legacy_name in ["legacy.fig", "legacy.FIG"] {
|
||||
for destination in ["corrected.figcraft", "copy.fig"] {
|
||||
let temp = Temp::new();
|
||||
let target = temp.path(destination);
|
||||
let legacy = temp.path(legacy_name);
|
||||
let (mut app, picks) = app(Some(target.clone()));
|
||||
let original = temp.path("original.figcraft");
|
||||
app.run("file.saveAs", json!({"path": original})).unwrap();
|
||||
std::fs::rename(&original, &legacy).unwrap();
|
||||
let legacy_path = legacy.to_string_lossy().into_owned();
|
||||
app.session.ds_mut().unwrap().path = Some(legacy_path.clone());
|
||||
app.run("shape.rectangle", json!({"width": 10, "height": 20})).unwrap();
|
||||
let before = app.session.doc().unwrap().clone();
|
||||
let legacy_bytes = std::fs::read(&legacy).unwrap();
|
||||
app.save();
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(*picks.lock().unwrap(), expected_picker());
|
||||
assert_eq!(std::fs::read(&legacy).unwrap(), legacy_bytes);
|
||||
assert!(target.is_file());
|
||||
assert!(app.ui.last_error.is_none());
|
||||
assert_eq!(app.session.doc().unwrap(), &before);
|
||||
if destination == "corrected.figcraft" {
|
||||
assert_eq!(toast(&app), "Saved corrected.figcraft");
|
||||
assert_eq!(app.session.ds().unwrap().path.as_deref(), target.to_str());
|
||||
assert!(!app.session.ds().unwrap().is_dirty());
|
||||
} else {
|
||||
assert_eq!(toast(&app), "Exported copy.fig");
|
||||
assert_eq!(app.session.ds().unwrap().path.as_deref(), Some(legacy_path.as_str()));
|
||||
assert!(app.session.ds().unwrap().is_dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_writes_surface_the_engine_error_and_keep_the_document_dirty() {
|
||||
let temp = Temp::new();
|
||||
for extension in ["figcraft", "fig"] {
|
||||
let path = temp.path(&format!("missing/copy.{extension}"));
|
||||
let (mut app, _) = app(Some(path.clone()));
|
||||
let before = app.session.doc().unwrap().clone();
|
||||
app.save_as_dialog();
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert!(!path.exists());
|
||||
assert_eq!(app.session.doc().unwrap(), &before);
|
||||
assert!(app.session.ds().unwrap().path.is_none());
|
||||
assert!(app.session.ds().unwrap().is_dirty());
|
||||
assert_eq!(app.ui.last_error.as_deref(), Some(toast(&app)));
|
||||
assert!(!toast(&app).starts_with("Saved") && !toast(&app).starts_with("Exported"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_destination_is_rejected_without_writing_or_changing_save_state() {
|
||||
let (mut app, _) = app(Some(PathBuf::new()));
|
||||
let before = app.session.doc().unwrap().clone();
|
||||
app.save_as_dialog();
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(app.session.doc().unwrap(), &before);
|
||||
assert!(app.session.ds().unwrap().path.is_none());
|
||||
assert!(app.session.ds().unwrap().is_dirty());
|
||||
assert_eq!(toast(&app), "Use the .figcraft or .fig extension.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_with_no_document_never_opens_a_dialog() {
|
||||
let (picker, picks) = picker(None);
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(picker));
|
||||
app.save_as_dialog();
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert!(picks.lock().unwrap().is_empty());
|
||||
assert!(app.ui.toast.is_none());
|
||||
}
|
||||
@@ -117,7 +117,13 @@ zoom preview or the refined image (`canvas.quality`: `preview` or `refined`) and
|
||||
asking for a render to its preview and its refined image being handed to egui as the canvas
|
||||
texture (`canvas.previewLatencyMs`, `canvas.refineLatencyMs`, on a monotonic clock, rendering
|
||||
included in the browser too; the GPU upload and presenting the frame come after; `null` until
|
||||
there was one). While a prototype plays, `presenter`
|
||||
there was one). `importReport` holds the Import report window's content (`{name, pages, layers, images, appended, placed, warnings, warningCount}`,
|
||||
`null` while none is open). The window opens only for a file the user opened or imported in the app
|
||||
(File ▸ Open or Import, Open recent, a recent file on the home screen, a drop, a paste, the system's open event).
|
||||
Emulated user input counts as the user's action: `ui.input` and `ui.key` (for example a Cmd+V paste or a click
|
||||
on a menu item) can open it. Commands sent through this channel with `engine.execute`, MCP, `window.new {path}`
|
||||
and files named on the command line never open it.
|
||||
`file.open` and `file.import` return the counts as `summary`. While a prototype plays, `presenter`
|
||||
(full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its
|
||||
`name`, `start`, `history`, open `overlays`, how many delayed mouse triggers are `waiting`, the screen's `scroll` offset and `scrollRange`, the
|
||||
`device` (and whether it is turned to `landscape`, its `deviceColor`) and the `viewport` of the frame
|
||||
|
||||
+3
-2
@@ -179,7 +179,8 @@ Or in a project's `.mcp.json`:
|
||||
- **Lists** are per paragraph: `listStyle` (`none`, `bulleted`, `numbered`) and `indentation` (the
|
||||
nesting level, 0–8) apply to the paragraphs chars `start`..`end` touch (all of them without a
|
||||
range); `get_node` reports one `listStyle` for the layer, or `lineTypes` and `lineIndentations`
|
||||
per paragraph. `text.bulletList`, `text.numberList` and `text.listStyle` act on the selected
|
||||
per paragraph, plus `listStartOffsets` when a numbered list starts past 1 (from a `.fig` file;
|
||||
1 shows "2."). `text.bulletList`, `text.numberList` and `text.listStyle` act on the selected
|
||||
paragraphs while text is edited. Layer props: `listSpacing` (px between list items, in place of
|
||||
`paragraphSpacing`), `hangingList` (markers outside the box) and `hangingPunctuation`. While
|
||||
editing, `key` Tab / Shift+Tab nest an item or move it out, Enter on an empty item and Backspace
|
||||
@@ -229,7 +230,7 @@ Or in a project's `.mcp.json`:
|
||||
| `select {ids}` | Replace the selection (`[]` deselects). |
|
||||
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
|
||||
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
|
||||
| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. |
|
||||
| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. |
|
||||
| `undo` / `redo` | Step through history. |
|
||||
| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). |
|
||||
| `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity, arrows that pan the view when nothing is selected; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. |
|
||||
|
||||
Reference in New Issue
Block a user