mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 03:16:53 -06:00
Batch r2-2 review: zip export cap, Dev Mode refuses grouped edits, path sizes fix their axis, single-call Tailwind variables, rem only where it converts, .fig pages keep room past library copies, MCP pages past an oversized child, catalog and doc fixes
This commit is contained in:
@@ -221,3 +221,15 @@ fn canvas_stacking_paints_and_exports_the_first_child_on_top() {
|
||||
s.undo().unwrap();
|
||||
assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_given_with_a_new_path_fixes_that_axis() {
|
||||
let mut s = Session::new();
|
||||
let f = al_frame(&mut s, json!({}));
|
||||
let v = id(s.execute("node.create", &json!({"type": "vector", "path": "M0 0 L10 10", "closed": false, "parent": f})).unwrap());
|
||||
set(&mut s, v, json!({"sizingH": "fill"}));
|
||||
assert_eq!(sizing(&s, v).0, Sizing::Fill);
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30}));
|
||||
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
|
||||
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
}
|
||||
|
||||
@@ -1166,6 +1166,20 @@ fn size_param(cmd: &str, p: &Value) -> Result<Option<ExportSize>> {
|
||||
|
||||
/// Most files one `file.exportLayers` writes.
|
||||
const MAX_EXPORT_FILES: usize = 1000;
|
||||
/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy
|
||||
/// are all in memory at once (in the browser too).
|
||||
const MAX_ZIP_BYTES: usize = 256 << 20;
|
||||
|
||||
/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together.
|
||||
fn add_member<'a>(members: &mut Vec<(&'a str, Vec<u8>)>, name: &'a str, bytes: Vec<u8>, limit: usize) -> Result<()> {
|
||||
let total = members.iter().map(|(_, b)| b.len()).sum::<usize>().saturating_add(bytes.len());
|
||||
if total > limit {
|
||||
let mb = limit >> 20;
|
||||
return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder")));
|
||||
}
|
||||
members.push((name, bytes));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One file of a layer export: the layer, its setting and the file name.
|
||||
pub struct ExportJob {
|
||||
@@ -1259,7 +1273,7 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
write(&path, &bytes)?;
|
||||
f["path"] = json!(path);
|
||||
}
|
||||
None if zip => members.push((job.file.as_str(), bytes)),
|
||||
None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?,
|
||||
None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)),
|
||||
}
|
||||
files.push(f);
|
||||
@@ -1803,3 +1817,18 @@ mod staging_write_tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod zip_cap_tests {
|
||||
use super::add_member;
|
||||
|
||||
#[test]
|
||||
fn zip_members_stop_at_the_limit_and_keep_what_fit() {
|
||||
let mut members = Vec::new();
|
||||
add_member(&mut members, "a.png", vec![0; 6], 10).unwrap();
|
||||
add_member(&mut members, "b.png", vec![0; 4], 10).unwrap();
|
||||
let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string();
|
||||
assert!(err.contains("as one zip"), "{err}");
|
||||
assert_eq!(members.iter().map(|(n, _)| *n).collect::<Vec<_>>(), ["a.png", "b.png"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +393,18 @@ fn apply_order(k: &str) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
|
||||
/// into W or H.
|
||||
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
|
||||
let n = doc.get_mut(id)?;
|
||||
if is_w {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
} else {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a property map to one node. `x`/`y` are relative to the parent.
|
||||
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
|
||||
let mut unknown = Vec::new();
|
||||
@@ -404,8 +416,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
let k = k.as_str();
|
||||
match k {
|
||||
"path" => path_offset = set_path(cmd, doc, id, v, props)?,
|
||||
// A new path takes the size asked for as its scale (`set_path`).
|
||||
"width" | "height" | "w" | "h" if props.contains_key("path") => {}
|
||||
// A new path takes the size asked for as its scale (`set_path`); the size still fixes
|
||||
// its auto layout axis.
|
||||
"width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?,
|
||||
"x" | "y" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
let val = val + if k == "x" { path_offset.0 } else { path_offset.1 };
|
||||
@@ -450,13 +463,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
TextAutoResize::None
|
||||
};
|
||||
}
|
||||
// Explicit width/height makes hugging/filling fixed (like typing into W/H).
|
||||
let n = doc.get_mut(id)?;
|
||||
if is_w {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
} else {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
}
|
||||
fix_axis(doc, id, is_w)?;
|
||||
}
|
||||
"rotation" => {
|
||||
let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?;
|
||||
|
||||
@@ -99,9 +99,10 @@ impl CodeLang {
|
||||
CodeLang::Compose => "Compose",
|
||||
}
|
||||
}
|
||||
/// Languages whose lengths are CSS pixels (they can be shown in rem).
|
||||
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
|
||||
/// are utility classes).
|
||||
pub fn is_web(self) -> bool {
|
||||
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Tailwind)
|
||||
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<CodeLang> {
|
||||
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
|
||||
|
||||
@@ -224,9 +224,11 @@ pub struct DevItem {
|
||||
/// Revert to saved, which isn't an undo step but throws the design's unsaved edits away.
|
||||
pub fn dev_mode_allows(id: &str) -> bool {
|
||||
const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."];
|
||||
// Edits recorded as one grouped undo step, so their spec says no history of its own.
|
||||
const GROUPED: [&str; 2] = ["command.batch", "data.fill"];
|
||||
match id.strip_prefix("tool.") {
|
||||
Some(t) => matches!(t, "move" | "hand" | "comment"),
|
||||
None if id == "file.revert" => false,
|
||||
None if id == "file.revert" || GROUPED.contains(&id) => false,
|
||||
None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,8 @@ fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts
|
||||
"tool.place",
|
||||
"file.import",
|
||||
"file.revert",
|
||||
"data.fill",
|
||||
"command.batch",
|
||||
] {
|
||||
assert!(!dev_mode_allows(id), "{id} edits the design");
|
||||
}
|
||||
|
||||
@@ -105,9 +105,22 @@ fn arb(v: &str) -> String {
|
||||
format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_"))
|
||||
}
|
||||
|
||||
/// The custom property a `var(--name, fallback)` value reads.
|
||||
/// The custom property a `var(--name, fallback)` value reads, when that one call is the whole
|
||||
/// value (not `var(--a, 4px) 8px`, four corners of which only the first is a variable).
|
||||
fn var_name(v: &str) -> Option<&str> {
|
||||
let inner = v.trim().strip_prefix("var(")?;
|
||||
let mut depth = 1usize;
|
||||
let close = inner.char_indices().find_map(|(i, c)| {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
(depth == 0).then_some(i)
|
||||
})?;
|
||||
if close + 1 != inner.len() {
|
||||
return None;
|
||||
}
|
||||
Some(inner.split([',', ')']).next()?.trim())
|
||||
}
|
||||
|
||||
|
||||
@@ -333,3 +333,10 @@ fn review_desktop_without_fill_clears_a_mobile_image_or_gradient() {
|
||||
let got = merge(Some(&[("background".to_string(), "[background:url(image.png)_center_/_cover]".to_string())]), &[]);
|
||||
assert!(got.contains(&"lg:bg-none".to_string()) && got.contains(&"lg:bg-transparent".to_string()), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_corner_radii_with_a_variable_first_keep_every_corner() {
|
||||
let got: Vec<String> =
|
||||
classes("border-radius: var(--radius-a, 4px) 8px 8px 8px;\nborder-radius: var(--radius-card, 4px);\n").into_iter().map(|(_, c)| c).collect();
|
||||
assert_eq!(got, ["rounded-[var(--radius-a,_4px)_8px_8px_8px]", "rounded-card"]);
|
||||
}
|
||||
|
||||
@@ -136,3 +136,13 @@ fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() {
|
||||
assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}");
|
||||
assert!(linked_to(&doc, "Library main") >= 190, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_main_larger_than_the_cap_leaves_the_pages_room() {
|
||||
// A main of 300 layers with room for 100: mapped first, it took the whole budget and the page
|
||||
// came out empty.
|
||||
let (doc, warnings) = import_at(&library_message(5, 300), 100);
|
||||
let page = doc.nodes().find(|n| n.name == "Screens").expect("the page");
|
||||
let top = page.children.len();
|
||||
assert!(top >= 1, "the page kept {top} layers: {warnings:?}");
|
||||
}
|
||||
|
||||
@@ -404,6 +404,9 @@ struct Mapper<'a> {
|
||||
/// Most layers produced or scanned, instance expansion included; the one place the cap comes
|
||||
/// from (see [`super::layer_budget`]).
|
||||
max_nodes: usize,
|
||||
/// Layers kept back for the visible pages' top level while library copies are mapped, so the
|
||||
/// copies can't take the whole budget and leave the pages empty.
|
||||
reserved: 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.
|
||||
@@ -481,6 +484,7 @@ impl<'a> Mapper<'a> {
|
||||
referenced_mains: BTreeSet::new(),
|
||||
mapped_mains: BTreeSet::new(),
|
||||
max_nodes,
|
||||
reserved: 0,
|
||||
networks: Vec::new(),
|
||||
network_ids: HashMap::new(),
|
||||
}
|
||||
@@ -565,6 +569,11 @@ impl<'a> Mapper<'a> {
|
||||
scope
|
||||
}
|
||||
|
||||
/// How many layers may be produced now: the cap, less what is kept for the visible pages.
|
||||
fn budget(&self) -> usize {
|
||||
self.max_nodes.saturating_sub(self.reserved)
|
||||
}
|
||||
|
||||
/// The DOCUMENT with its pages.
|
||||
fn document(&mut self) -> Value {
|
||||
let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone());
|
||||
@@ -591,11 +600,15 @@ impl<'a> Mapper<'a> {
|
||||
// last layers are skipped rather than the mains every instance of them needs (#981).
|
||||
let visible_pages: Vec<String> = visible.iter().map(|(p, _)| p.clone()).collect();
|
||||
let required_mains = self.referenced_component_mains(&visible_pages);
|
||||
// The pages and their top-level layers, up to half the cap, stay for the pages.
|
||||
let top: usize = visible_pages.iter().map(|p| 1 + self.children(p).len()).sum();
|
||||
self.reserved = top.min(self.max_nodes / 2);
|
||||
for (id, canvas) in internal {
|
||||
if let Some(context) = self.library_context(&id, canvas, &required_mains) {
|
||||
self.library_contexts.push(context);
|
||||
}
|
||||
}
|
||||
self.reserved = 0;
|
||||
let out: Vec<Value> = visible.into_iter().map(|(p, n)| self.page(&p, n)).collect();
|
||||
if out.is_empty() {
|
||||
self.warn("the file has no pages");
|
||||
@@ -840,7 +853,7 @@ impl<'a> Mapper<'a> {
|
||||
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
|
||||
return None;
|
||||
}
|
||||
if self.count >= self.max_nodes {
|
||||
if self.count >= self.budget() {
|
||||
let max = self.max_nodes;
|
||||
self.warn(format!("the file has more than {max} layers; the rest were skipped"));
|
||||
return None;
|
||||
@@ -877,7 +890,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) >= self.max_nodes);
|
||||
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget());
|
||||
// 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 {
|
||||
@@ -1025,7 +1038,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 >= self.max_nodes {
|
||||
if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.budget() {
|
||||
return None;
|
||||
}
|
||||
let mut s = Obj::new();
|
||||
@@ -1057,7 +1070,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 >= self.max_nodes {
|
||||
if self.count >= self.budget() {
|
||||
return None;
|
||||
}
|
||||
self.count += 1;
|
||||
@@ -1192,7 +1205,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 >= self.max_nodes { "node budget" } else { "depth limit" };
|
||||
let reason = if self.count >= self.budget() { "node budget" } else { "depth limit" };
|
||||
self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children"));
|
||||
}
|
||||
}
|
||||
@@ -1222,7 +1235,7 @@ 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 >= self.max_nodes {
|
||||
if self.count >= self.budget() {
|
||||
self.warn(format!("the file has more than {} layers; connector snapshot children were skipped", self.max_nodes));
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -1285,7 +1298,7 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
let background_view = View::of(background).with([background_style]);
|
||||
if !background_view.list("fillPaints").is_empty() {
|
||||
if self.count >= self.max_nodes {
|
||||
if self.count >= self.budget() {
|
||||
self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes));
|
||||
} else {
|
||||
let mut rect = Obj::new();
|
||||
@@ -1313,7 +1326,7 @@ impl<'a> Mapper<'a> {
|
||||
transform: Affine,
|
||||
view: &View<'a>,
|
||||
) {
|
||||
if self.count >= self.max_nodes {
|
||||
if self.count >= self.budget() {
|
||||
self.warn(format!("the file has more than {} layers; connector label was skipped", self.max_nodes));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -770,7 +770,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
|
||||
"nextOffset": next,
|
||||
})),
|
||||
);
|
||||
if window && next.is_none() && offset == 0 && used == depth {
|
||||
if window && next.is_none() && offset == 0 && used == depth && used_wire + MARKER_RESERVE <= budget {
|
||||
// Nothing was left out after all (an offset of zero that fitted whole).
|
||||
out.as_object_mut().map(|o| o.remove("truncated"));
|
||||
}
|
||||
@@ -792,8 +792,9 @@ fn children_window(value: &mut Value, offset: usize, budget: usize) -> bool {
|
||||
if fits(value, rest.len()) {
|
||||
return rest.len() != all.len();
|
||||
}
|
||||
// The largest window that fits, between none and all of the rest.
|
||||
let (mut lo, mut hi) = (0usize, rest.len());
|
||||
// The largest window that fits, but at least one child, so paging moves on past a child too
|
||||
// large to fit (the marker then says so).
|
||||
let (mut lo, mut hi) = (usize::from(!rest.is_empty()), rest.len());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo).div_ceil(2);
|
||||
if fits(value, mid) { lo = mid } else { hi = mid - 1 }
|
||||
|
||||
@@ -152,3 +152,26 @@ fn get_node(c: &mut Client, args: Value) -> (Value, usize) {
|
||||
let wire = serde_json::to_string(&text).unwrap().len() + ENVELOPE;
|
||||
(serde_json::from_str(text).expect("the block is JSON"), wire)
|
||||
}
|
||||
|
||||
/// A child too large for the limit on its own is still shown, one per page, so paging moves on,
|
||||
/// and the marker says the answer was cut.
|
||||
#[test]
|
||||
fn children_too_large_to_fit_page_one_at_a_time() {
|
||||
let mut c = Client::new();
|
||||
let frame = c.create(json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 400}));
|
||||
for i in 0..3 {
|
||||
c.create(json!({"type": "rectangle", "parent": frame, "x": i * 5, "y": 5, "width": 20, "height": 20, "name": "n".repeat(3000)}));
|
||||
}
|
||||
let mut offset = Some(0u64);
|
||||
let mut pages = 0;
|
||||
while let Some(at) = offset {
|
||||
let (page, _) = get_node(&mut c, json!({"id": frame, "depth": 1, "maxBytes": 1024, "offset": at}));
|
||||
assert_eq!(page["children"].as_array().map(Vec::len), Some(1), "{}", page["truncated"]);
|
||||
assert!(!page["truncated"].is_null(), "the marker stays: page at {at}");
|
||||
offset = page["truncated"]["children"]["nextOffset"].as_u64();
|
||||
assert!(offset.is_none_or(|o| o > at), "paging moves on from {at}");
|
||||
pages += 1;
|
||||
assert!(pages <= 3);
|
||||
}
|
||||
assert_eq!(pages, 3);
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ name = "Polski"
|
||||
"Both directions" = "W obu kierunkach"
|
||||
"Bottom" = "Dół"
|
||||
"Bottom left radius" = "Promień lewego dolnego narożnika"
|
||||
"Bottom padding" = "Margines dolny"
|
||||
"Bottom padding" = "Margines wewnętrzny dolny"
|
||||
"Bottom right radius" = "Promień prawego dolnego narożnika"
|
||||
"Branch" = "Gałąź"
|
||||
"Branch archived" = "Gałąź zarchiwizowana"
|
||||
@@ -714,7 +714,7 @@ name = "Polski"
|
||||
"Leaves" = "Opuszcza"
|
||||
"Left" = "Do lewej"
|
||||
"Left & right" = "Lewa i prawa"
|
||||
"Left padding" = "Margines lewy"
|
||||
"Left padding" = "Margines wewnętrzny lewy"
|
||||
"Left side" = "Lewa strona"
|
||||
"Left side width" = "Szerokość lewej strony"
|
||||
"Letter case" = "Wielkość liter"
|
||||
@@ -1156,7 +1156,7 @@ name = "Polski"
|
||||
"Review and merge" = "Przejrzyj i scal"
|
||||
"Ribbon" = "Wstążka"
|
||||
"Right" = "Do prawej"
|
||||
"Right padding" = "Margines prawy"
|
||||
"Right padding" = "Margines wewnętrzny prawy"
|
||||
"Right side" = "Prawa strona"
|
||||
"Right side width" = "Szerokość prawej strony"
|
||||
"Rotate 90°" = "Obróć o 90°"
|
||||
@@ -1440,7 +1440,7 @@ name = "Polski"
|
||||
"Top" = "Góra"
|
||||
"Top & bottom" = "Góra i dół"
|
||||
"Top left radius" = "Promień górnego lewego narożnika"
|
||||
"Top padding" = "Margines górny"
|
||||
"Top padding" = "Margines wewnętrzny górny"
|
||||
"Top right radius" = "Promień górnego prawego narożnika"
|
||||
"Transform" = "Przekształć"
|
||||
"Triangle" = "Trójkąt"
|
||||
|
||||
@@ -1417,7 +1417,7 @@ name = "Svenska"
|
||||
"This condition is edited as an expression" = "Det här villkoret redigeras som ett uttryck"
|
||||
"This file" = "Den här filen"
|
||||
"This file has unsaved changes" = "Filen har osparade ändringar"
|
||||
"This file is too large to join here (up to {mb} MB)" = "Den här filen är för stor för att läggas till här (högst {mb} MB)"
|
||||
"This file is too large to join here (up to {mb} MB)" = "Den här filen är för stor för att ansluta till här (högst {mb} MB)"
|
||||
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Den här filen använder typsnitt som inte är installerade på den här datorn. Deras text ritas med ett reservtypsnitt tills du installerar dem."
|
||||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Filen ligger på {server} och är bara öppen för dig. Dela den för att släppa in andra med en ny kod."
|
||||
"This frame is new on the branch." = "Den här framen är ny på grenen."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; macOS finds its downloadable CJK system fonts (#406); Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; macOS finds its downloadable CJK system fonts (#406); Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
|
||||
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
|
||||
@@ -122,6 +122,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Vietnamese (`vi`) draft catalog (#1026), assessed as a machine draft awaiting review like the others; string counts re-measured: 2,352 entries. |
|
||||
| 2026-10-23 | minor | A missing translation fails no check: Spanish is no longer held to full coverage, and the tests of one language's wording, names and plural forms are gone (the plural rules and language-tag tests stay). `cargo xtask i18n-report`, run by `cargo xtask ci`, lists each language's missing UI strings, command labels and menu wordings. Grade unchanged |
|
||||
| 2026-10-15 | minor | Community catalogs (#257, #263, #469, #477, #288): Russian (`ru`, Русский, reviewed and approved by VanTan) and Turkish (`tr`, Türkçe, reviewed by a native speaker), Czech (`cs`, Čeština) and Indonesian (`id`, Bahasa Indonesia) drafts, and the Japanese draft caught up; Preferences ▸ Language lists them and System maps their regional tags. Catalogs may give few and many plural forms; Russian picks one, few or many by its integer rule (11–14 take many), the catalog supplying the text choosing the rule. Only Spanish is held to completeness. Counts re-measured: 1,587 UI keys (38 plural), 559 command labels and 31 menu wordings, 2,177 entries; Spanish 100%, Japanese 2,176, Czech and Indonesian 2,166, Russian and Turkish 2,165, the five older drafts 2,088–2,089 (95%). Localization 39% and 29–51 h unchanged at this rounding |
|
||||
| 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (M12 Boards; Adobe Fonts found on macOS (#298); community batch #753: Selection colors, smart-selection spacing, a Present tab; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (M12 Boards; Adobe Fonts found on macOS (#298); community batch #753: Selection colors, smart-selection spacing, a Present tab; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ Simplified.
|
||||
The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay
|
||||
`es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese
|
||||
(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish
|
||||
(`fi`), Polish (`pl`), Bengali (`bn`) and Italian (`it`), machine-drafted and waiting for native speakers' review. Georgian
|
||||
(`fi`), Polish (`pl`), Bengali (`bn`), Italian (`it`) and Vietnamese (`vi`), machine-drafted and waiting for native speakers' review. Georgian
|
||||
(`ka`) was written with AI assistance and reviewed by a native speaker, and stays a draft until a
|
||||
maintainer changes its status. Russian (`ru`, Русский) was
|
||||
reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar
|
||||
|
||||
Reference in New Issue
Block a user