Merge main into #1873

This commit is contained in:
bflatastic
2026-11-07 11:57:34 +07:00
33 changed files with 3373 additions and 89 deletions
+6 -4
View File
@@ -101,6 +101,7 @@ native speakers' reviews of the nine language drafts need people.
|---|---|---|---|
| English | en | 100% | full |
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
| Traditional Chinese (Taiwan) | zh-Hant | 2,647 / 2,647 entries (100%, counted 2026-11-03) | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending |
@@ -114,9 +115,9 @@ native speakers' reviews of the nine language drafts need people.
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%), Czech (`cs`, 99%, draft) and Bosnian (`bs`, 100% on 2026-10-30, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
French, German, Brazilian Portuguese, Japanese, Korean, Simplified and Traditional Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸
Language; Traditional Chinese reads zh-TW, zh-HK, zh-MO and zh-Hant, while an explicit Hans stays
Simplified; CJK UI text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
@@ -127,7 +128,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)).
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, zh-Hant, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)).
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
## Progress log
@@ -363,6 +364,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`, 繁體中文(台灣)) draft catalog: every UI string (1,975), command label (641) and menu wording (31), translated clean-room from the English source; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant` read it while an explicit Hans stays Simplified; the desktop UI prefers an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC and Heiti TC; no font is bundled; native review pending. Numbers unchanged |
| 2026-11-06 | patch | M1, #2028: Edit ▸ Set default properties gives new rectangles, ellipses, polygons and stars drawn with the tools (and, set from a text layer, new text) the selected layer's properties; it only copied them for Paste properties. Tests: `default_props_tests` (3). Numbers unchanged |
| 2026-11-06 | patch | M5, #2027: `proto.settings` refuses an unknown device name with the list of devices (it saved any string, and the presenter showed no device), takes known names in any case, and answers the page's prototype settings, so `proto.settings {}` reads them back over MCP. Test: `proto_present_tests::unknown_devices_are_refused_and_settings_read_back`. Numbers unchanged |
| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes asks Don't Save / Cancel / Save, as closing the window does, instead of a toast that left the tab open; Alt+close still discards at once. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Numbers unchanged |
+47 -1
View File
@@ -1,6 +1,6 @@
//! Node property types: strokes, effects, corners, constraints, auto layout, grids, exports.
use figcraft_color::{BlendMode, Color};
use figcraft_color::{BlendMode, Color, ColorProfile};
use serde::{Deserialize, Serialize};
/// Where a stroke sits relative to the shape edge.
@@ -1535,11 +1535,56 @@ pub struct ExportSetting {
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
#[serde(skip_serializing_if = "VdCompat::is_default")]
pub vd_compat: VdCompat,
/// PNG, JPG and WebP: the color profile the file is written in. SVG, PDF and XML are always
/// sRGB, whatever this says.
#[serde(skip_serializing_if = "ExportColorProfile::is_default")]
pub color_profile: ExportColorProfile,
/// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]).
#[serde(skip_serializing_if = "is_default_quality")]
pub quality: u8,
}
/// The color profile an export setting writes PNG, JPG and WebP files in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ExportColorProfile {
/// The document's own profile at the time of the export.
#[default]
SameAsFile,
Srgb,
DisplayP3,
}
impl ExportColorProfile {
pub const ALL: [ExportColorProfile; 3] = [ExportColorProfile::SameAsFile, ExportColorProfile::Srgb, ExportColorProfile::DisplayP3];
fn is_default(&self) -> bool {
*self == ExportColorProfile::SameAsFile
}
/// The name commands and files use ("sameAsFile", "srgb", "displayP3").
pub fn key(self) -> &'static str {
match self {
ExportColorProfile::SameAsFile => "sameAsFile",
ExportColorProfile::Srgb => "srgb",
ExportColorProfile::DisplayP3 => "displayP3",
}
}
pub fn from_key(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.key() == s)
}
/// The profile the file is written in when the document is in `file`.
pub fn resolve(self, file: ColorProfile) -> ColorProfile {
match self {
ExportColorProfile::SameAsFile => file,
ExportColorProfile::Srgb => ColorProfile::Srgb,
ExportColorProfile::DisplayP3 => ColorProfile::DisplayP3,
}
}
}
/// How an Android Vector Drawable export handles what the format can't draw exactly (layer
/// opacity over overlapping shapes, even-odd fills, diamond gradients, alpha masks).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -1588,6 +1633,7 @@ impl Default for ExportSetting {
svg_id_attribute: false,
svg_simplify_stroke: true,
vd_compat: VdCompat::BestEffort,
color_profile: ExportColorProfile::SameAsFile,
quality: ExportSetting::DEFAULT_QUALITY,
}
}
+2 -2
View File
@@ -448,7 +448,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"export.add",
"Add export setting",
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100, default 90)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), colorProfile? (PNG, JPG and WebP: sameAsFile|srgb|displayP3), quality? (JPG: 1-100, default 90)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
true,
|s, p| {
let cmd = "export.add";
@@ -466,7 +466,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"export.update",
"Edit export setting",
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), colorProfile? (PNG, JPG and WebP: sameAsFile (the document's profile when it exports)|srgb|displayP3; kept when the format changes), quality? (JPG: 1-100), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
true,
|s, p| {
let cmd = "export.update";
+30 -6
View File
@@ -4,8 +4,8 @@ use std::sync::Arc;
use base64::Engine as _;
use figcraft_doc::{
BOARD_PAGE, Document, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings, StyleKind, TextStyle,
VdCompat,
BOARD_PAGE, Document, ExportColorProfile, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings,
StyleKind, TextStyle, VdCompat,
};
use figcraft_format::Version;
use serde_json::{Value, json};
@@ -120,14 +120,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.export",
"Export",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), quality? (JPG: 1-100, default 90)}",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), colorProfile? (PNG, JPG and WebP: sameAsFile (default: the document's profile)|srgb|displayP3; colors convert, out-of-gamut ones clip; SVG, PDF and XML are always sRGB), quality? (JPG: 1-100, default 90)}",
false,
export
),
spec!(
"file.exportLayers",
"Export layers",
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own)} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}",
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own), colorProfile? (sameAsFile|srgb|displayP3: for every PNG, JPG and WebP instead of each setting's own)} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}",
false,
export_layers
),
@@ -408,14 +408,38 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal
Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new()))
}
_ => {
let pix = figcraft_render::render_area(doc, &drawn, area, scale)
let mut pix = figcraft_render::render_area(doc, &drawn, area, scale)
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
let bytes = figcraft_render::encode_export_with(&pix, setting.format, doc.color_profile, setting.quality());
// The render is in the document's profile; the setting's profile converts the pixels
// (out-of-gamut colors clip) and tags the file.
let profile = setting.color_profile.resolve(doc.color_profile);
let conversion = doc.color_profile.conversion_to(profile);
// JPG has no alpha: the render is flattened over white in the document's profile
// before the conversion, so translucent pixels match what the document shows.
if setting.format == ExportFormat::Jpg && !conversion.is_identity() {
for [r, g, b, a] in pix.data_mut().as_chunks_mut::<4>().0.iter_mut() {
let under = 255 - *a;
*r = r.saturating_add(under);
*g = g.saturating_add(under);
*b = b.saturating_add(under);
*a = 255;
}
}
conversion.apply_pixels(pix.data_mut());
let bytes = figcraft_render::encode_export_with(&pix, setting.format, profile, setting.quality());
Ok((bytes.map_err(EngineError::Other)?, Vec::new()))
}
}
}
/// The `colorProfile` `p` passes: sameAsFile, srgb or displayP3 (anything else is an error).
fn color_profile_param(cmd: &str, p: &Value) -> Result<Option<ExportColorProfile>> {
match get(p, "colorProfile") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(ExportColorProfile::from_key).map(Some).ok_or_else(|| bad(cmd, "colorProfile: sameAsFile|srgb|displayP3")),
}
}
/// The `vdCompat` `p` passes: bestEffort, exact or ignore (anything else is an error).
fn vd_compat_param(cmd: &str, p: &Value) -> Result<Option<VdCompat>> {
match get(p, "vdCompat") {
+11 -2
View File
@@ -175,6 +175,9 @@ pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value)
if let Some(c) = vd_compat_param(cmd, p)? {
e.vd_compat = c;
}
if let Some(c) = color_profile_param(cmd, p)? {
e.color_profile = c;
}
if let Some(q) = quality_param(cmd, p)? {
e.quality = q;
}
@@ -342,10 +345,16 @@ pub(super) fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?;
// And a compatibility mode for every Android Vector Drawable.
let compat = vd_compat_param(cmd, p)?;
// And a color profile for every PNG, JPG and WebP.
let profile = color_profile_param(cmd, p)?;
let mut files = Vec::with_capacity(jobs.len());
for job in &jobs {
let setting =
ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() };
let setting = ExportSetting {
print: print.unwrap_or(job.setting.print),
vd_compat: compat.unwrap_or(job.setting.vd_compat),
color_profile: profile.unwrap_or(job.setting.color_profile),
..job.setting.clone()
};
let scale = export_scale(doc, &[job.id], &setting);
let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?;
let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()});
+11 -1
View File
@@ -8,10 +8,12 @@ use crate::params::{boolean, get, num, num_or, req_index, req_num, req_string, s
use crate::tools::ToolId;
use crate::{EngineError, Result, Session, ViewState, bad, spec};
/// The active document's view state, for changing it.
fn view(s: &mut Session) -> Result<&mut ViewState> {
Ok(&mut s.ds_mut()?.view)
}
/// Set one of a view's on/off settings from `on`, or toggle it when `on` is absent.
fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Result<Value> {
let v = view(s)?;
let slot = f(v);
@@ -90,6 +92,7 @@ fn pixel_preview(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"on": v.pixel_preview, "scale": v.pixel_scale}))
}
/// Zoom the active document to `z` about the view's centre, keeping that point in place.
fn zoom_to(s: &mut Session, z: f64) -> Result<Value> {
let v = view(s)?;
let c = (v.cx, v.cy);
@@ -114,6 +117,7 @@ fn pan(s: &mut Session, p: &Value) -> Result<Value> {
/// Figma's zoom steps.
const STEPS: &[f64] = &[0.02, 0.03, 0.06, 0.13, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0];
/// The next zoom step above (`up`) or below `z` in Figma's fixed steps.
fn step(z: f64, up: bool) -> f64 {
if up {
STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(256.0)
@@ -141,6 +145,7 @@ fn zoom_at(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"zoom": v.zoom, "cx": v.cx, "cy": v.cy}))
}
/// Select and fit the next (`dir` 1) or previous (-1) top-level frame on the page.
fn zoom_frame(s: &mut Session, dir: i64) -> Result<Value> {
let ds = s.ds()?;
let frames = ds.doc.top_frames(ds.page);
@@ -161,6 +166,7 @@ fn zoom_frame(s: &mut Session, dir: i64) -> Result<Value> {
Ok(json!({"frame": f}))
}
/// Move to the next (`dir` 1) or previous (-1) page, skipping dividers, and fit it.
fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
let ds = s.ds()?;
let pages = &ds.doc.pages;
@@ -183,6 +189,7 @@ fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
Ok(json!({"page": pg}))
}
/// Ask the UI to open or toggle the panel or dialog named `kind`.
fn ui(s: &mut Session, kind: &str) -> Result<Value> {
s.request_ui(kind, json!({}));
ok()
@@ -210,12 +217,14 @@ fn ui_scale_step(s: &mut Session, dir: i32) -> Result<Value> {
Ok(json!({"scale": s.prefs.ui_scale}))
}
/// Set one of the preferences' on/off settings from `on`, or toggle it when `on` is absent.
fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Result<Value> {
let slot = f(&mut s.prefs);
*slot = boolean(p, "on").unwrap_or(!*slot);
Ok(json!({"on": *slot}))
}
/// Switch the tool, unless the file is read-only and this tool needs editing.
pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
// A file you can't edit now (view only, or on its way to a server): moving around only, and
// commenting with a comment link.
@@ -226,6 +235,7 @@ pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
Ok(json!({"tool": t.id()}))
}
/// The View menu's commands and their parameters.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("view.zoomIn", "Zoom in", "{}", false, |s, _| {
@@ -480,7 +490,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"pref.language",
"Interface language",
"{language: system|en|bs|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|…; else English}",
"{language: system|en|bs|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|zh-Hant|…; else English}",
false,
|s, p| {
let lang = req_string("pref.language", p, "language")?;
+214
View File
@@ -0,0 +1,214 @@
//! The color profile an export setting writes PNG, JPG and WebP files in: same as the file (the
//! default, following the document's profile when it exports), sRGB or Display P3, with the pixels
//! converted and the file tagged; stored per setting, kept across a format switch, passed to
//! export layers, ignored by the always-sRGB SVG, PDF and XML, and bad values refused.
use base64::Engine as _;
use figcraft_color::{Color, ColorProfile};
use figcraft_doc::*;
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
/// A session with a red 20 × 10 rectangle in an sRGB document.
fn session() -> (Session, NodeId) {
let mut s = Session::new();
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 10, "name": "Red"})).unwrap()["id"]).unwrap();
s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
(s, r)
}
fn bytes(v: &Value) -> Vec<u8> {
base64::engine::general_purpose::STANDARD.decode(v["data"].as_str().unwrap()).unwrap()
}
fn export(s: &mut Session, params: Value) -> Vec<u8> {
bytes(&s.execute("file.export", &params).unwrap())
}
/// Whether `bytes` holds a PNG chunk of `kind` (with `data`, when given).
fn has_chunk(bytes: &[u8], kind: &[u8; 4], data: Option<&[u8]>) -> bool {
bytes.windows(4).enumerate().any(|(i, w)| w == kind && data.is_none_or(|d| bytes.get(i + 4..i + 4 + d.len()) == Some(d)))
}
/// An embedded ICC display profile (its device class).
fn has_icc(bytes: &[u8]) -> bool {
bytes.windows(4).any(|w| w == b"mntr")
}
/// The middle pixel's RGB.
fn middle(bytes: &[u8]) -> [u8; 3] {
let img = image::load_from_memory(bytes).unwrap().to_rgba8();
let [r, g, b, _] = img.get_pixel(img.width() / 2, img.height() / 2).0;
[r, g, b]
}
/// `c` as 8-bit RGB.
fn rgb8(c: Color) -> [u8; 3] {
[c.r, c.g, c.b].map(|v| (v * 255.0).round() as u8)
}
fn close(a: [u8; 3], b: [u8; 3], tolerance: u8) -> bool {
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= tolerance)
}
/// sRGB red in Display P3 values.
fn red_in_p3() -> [u8; 3] {
rgb8(ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3).apply(Color::new(1.0, 0.0, 0.0, 1.0)))
}
#[test]
fn a_display_p3_png_from_an_srgb_file_is_converted_and_tagged() {
let (mut s, r) = session();
let png = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": "displayP3"}));
assert!(has_chunk(&png, b"iCCP", None) && has_chunk(&png, b"cICP", Some(&[12, 13, 0, 1])) && !has_chunk(&png, b"sRGB", None));
let want = red_in_p3();
assert!(close(middle(&png), want, 1), "{:?} vs {want:?}", middle(&png));
// The default stays the file's own sRGB.
let png = export(&mut s, json!({"ids": [r], "format": "png"}));
assert!(has_chunk(&png, b"sRGB", None) && !has_chunk(&png, b"iCCP", None));
assert_eq!(middle(&png), [255, 0, 0]);
// The document is not changed by the export.
assert_eq!(s.doc().unwrap().color_profile, ColorProfile::Srgb);
assert_eq!(crate::paint_tests::fill(&s, r).to_hex(), "FF0000");
}
#[test]
fn an_srgb_export_of_a_display_p3_file_converts_back() {
let (mut s, r) = session();
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
let same = export(&mut s, json!({"ids": [r], "format": "png"}));
assert!(has_chunk(&same, b"iCCP", None));
assert!(close(middle(&same), red_in_p3(), 1), "{:?}", middle(&same));
for p in ["srgb", "displayP3", "sameAsFile"] {
let png = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": p}));
assert_eq!(has_chunk(&png, b"sRGB", None), p == "srgb", "{p}");
let want = if p == "srgb" { [255, 0, 0] } else { red_in_p3() };
assert!(close(middle(&png), want, 1), "{p}: {:?} vs {want:?}", middle(&png));
}
// A wide-gamut color clips in sRGB and keeps its values in Display P3.
s.execute("fill.set", &json!({"ids": [r], "color": "#00FF00"})).unwrap();
let srgb = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": "srgb"}));
let p3 = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": "displayP3"}));
assert_eq!(middle(&p3), [0, 255, 0]);
let [red, green, blue] = middle(&srgb);
assert!(red == 0 && green == 255 && blue == 0, "P3 green clips to sRGB green: {:?}", middle(&srgb));
}
#[test]
fn jpg_and_webp_follow_the_setting() {
let (mut s, r) = session();
for f in ["jpg", "webp"] {
// sRGB JPGs embed their profile and sRGB WebPs stay untagged (read as sRGB); Display P3 files
// embed the generated profile.
assert_eq!(has_icc(&export(&mut s, json!({"ids": [r], "format": f}))), f == "jpg", "{f}");
let p3 = export(&mut s, json!({"ids": [r], "format": f, "colorProfile": "displayP3"}));
assert!(has_icc(&p3), "{f}");
let want = red_in_p3();
assert!(close(middle(&p3), want, 8), "{f}: {:?} vs {want:?}", middle(&p3));
}
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
for f in ["jpg", "webp"] {
let srgb = export(&mut s, json!({"ids": [r], "format": f, "colorProfile": "srgb"}));
assert_eq!(has_icc(&srgb), f == "jpg", "{f}");
assert!(close(middle(&srgb), [255, 0, 0], 8), "{f}: {:?}", middle(&srgb));
}
}
#[test]
fn a_translucent_jpg_is_flattened_before_its_conversion() {
let (mut s, r) = session();
s.execute("fill.set", &json!({"ids": [r], "opacity": 0.5})).unwrap();
// The sRGB document shows 50% red over white; the Display P3 JPG holds that color converted.
let flat = export(&mut s, json!({"ids": [r], "format": "jpg", "colorProfile": "srgb"}));
let [fr, fg, fb] = middle(&flat);
let shown = Color::new(f32::from(fr) / 255.0, f32::from(fg) / 255.0, f32::from(fb) / 255.0, 1.0);
let want = rgb8(ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3).apply(shown));
let p3 = export(&mut s, json!({"ids": [r], "format": "jpg", "colorProfile": "displayP3"}));
assert!(close(middle(&p3), want, 2), "{:?} vs {want:?} (sRGB {:?})", middle(&p3), [fr, fg, fb]);
}
#[test]
fn svg_pdf_and_xml_stay_srgb() {
let (mut s, r) = session();
// A Display P3 file: its red converts back to sRGB #FF0000 whatever the setting says.
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
for p in ["displayP3", "sameAsFile", "srgb"] {
let svg = String::from_utf8(export(&mut s, json!({"ids": [r], "format": "svg", "colorProfile": p}))).unwrap().to_ascii_uppercase();
assert!(svg.contains("#FF0000"), "{p}: {svg}");
let xml = String::from_utf8(export(&mut s, json!({"ids": [r], "format": "xml", "colorProfile": p}))).unwrap().to_ascii_uppercase();
assert!(xml.contains("#FF0000"), "{p}: {xml}");
let pdf = export(&mut s, json!({"ids": [r], "format": "pdf", "colorProfile": p}));
assert!(pdf.starts_with(b"%PDF") && !has_icc(&pdf), "{p}");
}
}
#[test]
fn same_as_file_follows_the_document_and_a_choice_stays() {
let (mut s, r) = session();
s.execute("export.add", &json!({"ids": [r]})).unwrap();
s.execute("export.add", &json!({"ids": [r], "setting": {"suffix": "-srgb", "colorProfile": "srgb"}})).unwrap();
s.execute("export.add", &json!({"ids": [r], "setting": {"suffix": "-p3", "colorProfile": "displayP3"}})).unwrap();
let files = |s: &mut Session| -> Vec<(String, bool)> {
let v = s.execute("file.exportLayers", &json!({"ids": [r]})).unwrap();
v["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), has_chunk(&bytes(f), b"iCCP", None))).collect()
};
let names = |icc: [bool; 3]| -> Vec<(String, bool)> {
["Red.png", "Red-srgb.png", "Red-p3.png"].into_iter().zip(icc).map(|(n, i)| (n.to_string(), i)).collect()
};
assert_eq!(files(&mut s), names([false, false, true]));
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
assert_eq!(files(&mut s), names([true, false, true]));
// A profile passed to export layers stands in for every setting's own.
let v = s.execute("file.exportLayers", &json!({"ids": [r], "colorProfile": "srgb"})).unwrap();
assert!(v["files"].as_array().unwrap().iter().all(|f| has_chunk(&bytes(f), b"sRGB", None)), "{v}");
}
#[test]
fn export_settings_keep_their_color_profile() {
let (mut s, r) = session();
s.execute("export.add", &json!({"ids": [r], "setting": {"colorProfile": "displayP3"}})).unwrap();
let e = s.doc().unwrap().node(r).unwrap().exports[0].clone();
assert_eq!(e.color_profile, ExportColorProfile::DisplayP3);
// Stored camelCase, the default left out, and a row saved without it reads as same as file.
assert_eq!(serde_json::to_value(&e).unwrap()["colorProfile"], "displayP3");
assert!(serde_json::to_value(ExportSetting::default()).unwrap().get("colorProfile").is_none());
let old: ExportSetting = serde_json::from_value(json!({"size": {"by": "scale", "value": 2.0}, "suffix": "@2x", "format": "jpg"})).unwrap();
assert_eq!(old.color_profile, ExportColorProfile::SameAsFile);
// Saved and loaded.
let saved = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::load_bytes(&saved).unwrap();
assert_eq!(back.node(r).unwrap().exports[0], e);
// Edited and undone; a format switch keeps it.
s.execute("export.update", &json!({"ids": [r], "index": 0, "colorProfile": "srgb"})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::Srgb);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::DisplayP3);
for f in ["svg", "pdf", "png"] {
s.execute("export.update", &json!({"ids": [r], "index": 0, "format": f})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::DisplayP3, "{f}");
}
// `null` changes nothing.
s.execute("export.update", &json!({"ids": [r], "index": 0, "colorProfile": null})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::DisplayP3);
for p in ExportColorProfile::ALL {
assert_eq!(ExportColorProfile::from_key(p.key()), Some(p));
}
}
#[test]
fn bad_color_profiles_are_refused_and_change_nothing() {
let (mut s, r) = session();
s.execute("export.add", &json!({"ids": [r]})).unwrap();
for junk in [json!("cmyk"), json!(3), json!(true), json!("SRGB"), json!({})] {
assert!(s.execute("file.export", &json!({"ids": [r], "colorProfile": junk})).is_err(), "{junk}");
assert!(s.execute("export.update", &json!({"ids": [r], "index": 0, "colorProfile": junk})).is_err(), "{junk}");
assert!(s.execute("export.add", &json!({"ids": [r], "setting": {"colorProfile": junk}})).is_err(), "{junk}");
assert!(s.execute("file.exportLayers", &json!({"ids": [r], "colorProfile": junk})).is_err(), "{junk}");
}
let n = s.doc().unwrap().node(r).unwrap();
assert_eq!((n.exports.len(), n.exports[0].color_profile), (1, ExportColorProfile::SameAsFile));
let err = s.execute("file.export", &json!({"ids": [r], "colorProfile": "p3"})).unwrap_err().to_string();
assert!(err.contains("sameAsFile|srgb|displayP3"), "{err}");
}
+1
View File
@@ -372,6 +372,7 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
(Transform, "Opacity 10%…90%, 0: 100%", "1…9, 0", "1…9, 0"),
(Transform, "Exact opacity (4 then 5: 45%)", "Two digits", "Two digits"),
(Transform, "Suspend snapping while dragging", "Hold ⌃ or ⌘", "Hold Ctrl"),
(Arrange, "Edit padding on all sides", "⌘ click a padding field", "Ctrl+click a padding field"),
(Arrange, "Set hug contents", "Double-click an edge of an auto layout frame", "Double-click an edge of an auto layout frame"),
(Arrange, "Set fill container", "⌥ double-click an edge of an auto layout child", "Alt+double-click an edge of an auto layout child"),
(Prototype, "Presenting: next frame", "→, Space or N", "→, Space or N"),
+2
View File
@@ -317,6 +317,8 @@ mod export_bench_tests;
#[cfg(test)]
mod export_depth_tests;
#[cfg(test)]
mod export_profile_tests;
#[cfg(test)]
mod extended_chain_tests;
#[cfg(test)]
mod eyedropper_create_tests;
+2 -1
View File
@@ -279,6 +279,7 @@ static ROWS: &[Row] = &[
doc(Arrange, "Add auto layout", &["Shift+A"], &[], Bound("layout.add"), ""),
doc(Arrange, "Remove auto layout", &["Alt+Shift+A"], &[], Bound("layout.remove"), ""),
doc(Arrange, "Suggest auto layout", &["Ctrl+Shift+A"], &["Ctrl+Alt+Shift+A"], Bound("layout.suggest"), "was unbound"),
doc(Arrange, "Edit padding on all sides (from right panel)", &["Cmd"], &["Ctrl"], Gesture("Edit padding on all sides"), "held while clicking a padding field: the value typed goes to every side"),
pin(Arrange, "distribute.horizontal", &["Ctrl+Alt+H"], &["Alt+Shift+H"]),
pin(Arrange, "distribute.vertical", &["Ctrl+Alt+V"], &["Alt+Shift+V"]),
pin(Arrange, "arrange.tidy", &["Ctrl+Alt+T"], &["Ctrl+Alt+Shift+T"]),
@@ -413,7 +414,7 @@ fn counts_match_the_docs() {
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
let pinned = ROWS.len() - documented.len();
// docs/ui-parity.md quotes these.
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (134, 133, 1, 0, 2, 57));
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (135, 134, 1, 0, 2, 57));
}
#[test]
+3 -1
View File
@@ -262,7 +262,7 @@ fn tools() -> Vec<Value> {
tool(
"export",
"Export",
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp, pdf or xml (an Android Vector Drawable, with `warnings` for what it changed or left out, per `vdCompat`) at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`; for XML `svgIdAttribute` and `vdCompat`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, xml as text followed by `{format, warnings}`, other formats as base64 `data`.",
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp, pdf or xml (an Android Vector Drawable, with `warnings` for what it changed or left out, per `vdCompat`) at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`; for XML `svgIdAttribute` and `vdCompat`; for png, jpg and webp `colorProfile`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, xml as text followed by `{format, warnings}`, other formats as base64 `data`.",
obj(
json!({
"ids": ids_schema("Layers to export (several are composited into one image)"),
@@ -280,6 +280,7 @@ fn tools() -> Vec<Value> {
"svgIdAttribute": boolean("SVG: layer names as unique `id` attributes (default false)"),
"svgSimplifyStroke": boolean("SVG: strokes as `stroke` attributes where exact (default true); false writes outlines"),
"vdCompat": {"type": "string", "enum": ["bestEffort", "exact", "ignore"], "description": "XML: what a Vector Drawable can't draw exactly is approximated with a warning (bestEffort, default), left out with a warning (exact), or written as far as it maps without warnings (ignore: overlapping layer opacity dropped, even-odd fills kept as fillType=evenOdd, diamond gradients left out)"},
"colorProfile": {"type": "string", "enum": ["sameAsFile", "srgb", "displayP3"], "description": "PNG, JPG and WebP: the color profile of the file (default sameAsFile: the document's); colors are converted and out-of-gamut ones clip. SVG, PDF and XML are always sRGB"},
"quality": {"type": "integer", "minimum": 1, "maximum": 100, "description": "JPG: the encoder's quality, 1 (smallest file) to 100; default 90"},
}),
&[],
@@ -1060,6 +1061,7 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
"svgIdAttribute",
"svgSimplifyStroke",
"vdCompat",
"colorProfile",
"quality",
] {
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
+29
View File
@@ -0,0 +1,29 @@
//! The `export` tool's `colorProfile`: a PNG in another profile than the document's is tagged for
//! it, and a bad value is refused.
use base64::Engine as _;
use serde_json::json;
use crate::common::Client;
fn png(c: &mut Client, args: serde_json::Value) -> Vec<u8> {
let r = c.call("export", args);
assert_eq!(r["isError"], false, "{r}");
let image = r["content"].as_array().unwrap().iter().find(|block| block["type"] == "image").unwrap();
base64::engine::general_purpose::STANDARD.decode(image["data"].as_str().unwrap()).unwrap()
}
fn has_chunk(bytes: &[u8], kind: &[u8; 4]) -> bool {
bytes.windows(4).any(|w| w == kind)
}
#[test]
fn color_profile_tags_the_png() {
let mut c = Client::new();
let id = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 24, "height": 16}));
let srgb = png(&mut c, json!({"ids": [id], "format": "png"}));
assert!(has_chunk(&srgb, b"sRGB") && !has_chunk(&srgb, b"iCCP"));
let p3 = png(&mut c, json!({"ids": [id], "format": "png", "colorProfile": "displayP3"}));
assert!(has_chunk(&p3, b"iCCP") && has_chunk(&p3, b"cICP") && !has_chunk(&p3, b"sRGB"));
assert!(c.err("export", json!({"ids": [id], "format": "png", "colorProfile": "cmyk"})).contains("colorProfile"));
}
+2
View File
@@ -13,6 +13,8 @@ mod common;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_hidden_roots;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_profile;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_xml;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod garbage;
+4
View File
@@ -388,6 +388,8 @@ name = "English"
"Crop marks" = "Crop marks"
"Ctrl+arrows" = "Ctrl+arrows"
"Ctrl+click" = "Ctrl+click"
"Ctrl+click a padding field" = "Ctrl+click a padding field"
"⌘ click a padding field" = "⌘ click a padding field"
"Ctrl+scroll" = "Ctrl+scroll"
"Current name" = "Current name"
"Current version" = "Current version"
@@ -528,6 +530,7 @@ name = "English"
"Edit dev resource" = "Edit dev resource"
"Edit link" = "Edit link"
"Edit link copied" = "Edit link copied"
"Edit padding on all sides" = "Edit padding on all sides"
"Edit style" = "Edit style"
"Edit variable" = "Edit variable"
"Edit with the builder" = "Edit with the builder"
@@ -1333,6 +1336,7 @@ name = "English"
"See all templates" = "See all templates"
"Select layers and they space themselves as their content changes" = "Select layers and they space themselves as their content changes"
"SHA-256: {sha}…" = "SHA-256: {sha}…"
"Same as file ({profile})" = "Same as file ({profile})"
"Same as layer" = "Same as layer"
"Saturation" = "Saturation"
"Save" = "Save"
File diff suppressed because it is too large Load Diff
+49 -12
View File
@@ -59,6 +59,21 @@ const CHINESE: &[&str] = &[
"WenQuanYi Zen Hei",
"Droid Sans Fallback",
];
/// Traditional Chinese (Taiwan) faces, an installed Noto Sans CJK TC first, then Windows, macOS
/// and Linux names. Nothing is bundled: the desktop finds one of these among the installed fonts.
const TRADITIONAL_CHINESE: &[&str] = &[
"Noto Sans CJK TC",
"Microsoft JhengHei UI",
"Microsoft JhengHei",
"PingFang TC",
"Heiti TC",
"Noto Sans TC",
"Source Han Sans TC",
"LiHei Pro",
"Apple LiGothic",
"MingLiU",
"PMingLiU",
];
/// The CJK, Bengali and Thai scripts in `s`.
pub fn scripts(s: &str) -> u8 {
@@ -86,6 +101,16 @@ pub fn language(code: &str) -> u8 {
}
}
/// Whether `code` is Traditional Chinese: `zh-Hant`, or `zh` with a Taiwan, Hong Kong or Macau
/// region and no explicit `Hans` (`zh-TW`, `zh-HK`, `zh-MO`, `zh-Hant-TW`). `zh-Hans-TW` is
/// Simplified.
pub fn traditional(code: &str) -> bool {
let tag = code.trim().split(['.', '@']).next().unwrap_or("").replace('_', "-");
let base = tag.split('-').next().unwrap_or("");
let has = |subtags: &[&str]| tag.split('-').skip(1).any(|t| subtags.iter().any(|s| s.eq_ignore_ascii_case(t)));
base.eq_ignore_ascii_case("zh") && (has(&["Hant"]) || (!has(&["Hans"]) && has(&["TW", "HK", "MO"])))
}
/// Whether the UI can draw language `code`: always on desktop (a system face is found when it's
/// needed; tofu at worst), on the web only with built-in faces for its scripts (Georgian: craft-fonts'
/// Noto Sans Georgian). The language picker and the system language skip a language it can't.
@@ -107,19 +132,20 @@ fn shows(code: &str, web: bool) -> bool {
return figcraft_text::face_with_char('ع', &["Noto Sans Arabic"]).is_some();
}
let need = language(code);
need == 0 || samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some())
need == 0 || samples(need, 0, traditional(code)).iter().all(|(c, families)| figcraft_text::face_with_char(*c, families).is_some())
}
/// A character standing for each script in `need`, with the families to look for it in, the UI
/// language's script (`ui`, see [`language`]) first. Han reads as Chinese (a simplified character,
/// Chinese faces first) in a Chinese UI, and without kana in a UI that isn't Japanese or Korean;
/// else a Japanese or Korean face draws it.
fn samples(need: u8, ui: u8) -> Vec<(char, Vec<&'static str>)> {
/// Simplified or Traditional Chinese faces first as the UI language says) in a Chinese UI, and
/// without kana in a UI that isn't Japanese or Korean; else a Japanese or Korean face draws it.
fn samples(need: u8, ui: u8, hant: bool) -> Vec<(char, Vec<&'static str>)> {
let chinese = need & HAN != 0 && (ui == HAN || (ui == 0 && need & KANA == 0));
let han: (char, Vec<&str>) = match (chinese, ui) {
(true, _) => ('们', [CHINESE, JAPANESE, KOREAN].concat()),
(false, HANGUL) => ('漢', [KOREAN, JAPANESE, CHINESE].concat()),
(false, _) => ('漢', [JAPANESE, KOREAN, CHINESE].concat()),
let han: (char, Vec<&str>) = match (chinese, hant, ui) {
(true, true, _) => ('們', [TRADITIONAL_CHINESE, JAPANESE, KOREAN].concat()),
(true, false, _) => ('们', [CHINESE, JAPANESE, KOREAN].concat()),
(false, _, HANGUL) => ('漢', [KOREAN, JAPANESE, CHINESE].concat()),
(false, _, _) => ('漢', [JAPANESE, KOREAN, CHINESE].concat()),
};
let mut v = vec![
(KANA, ('あ', JAPANESE.to_vec())),
@@ -147,7 +173,7 @@ fn samples(need: u8, ui: u8) -> Vec<(char, Vec<&'static str>)> {
/// tried are read from disk.
pub fn faces(need: u8, lang: &str, have: &dyn Fn(char) -> bool) -> Vec<Arc<Face>> {
let mut out: Vec<Arc<Face>> = Vec::new();
for (c, families) in samples(need, language(lang)) {
for (c, families) in samples(need, language(lang), traditional(lang)) {
if have(c) || out.iter().any(|f| f.has_char(c)) {
continue;
}
@@ -177,6 +203,9 @@ mod tests {
assert_eq!(language("ko"), HANGUL);
assert_eq!(language("zh-Hant"), HAN);
assert_eq!(language("zh-Hans"), HAN);
assert!(traditional("zh-Hant") && traditional("zh-TW") && traditional("zh-HK") && traditional("zh-MO"));
assert!(traditional("zh-Hant-HK") && traditional("zh_TW.UTF-8"));
assert!(!traditional("zh-Hans") && !traditional("zh-CN") && !traditional("zh-Hans-TW") && !traditional("zh") && !traditional("ja"));
assert_eq!(language("bn"), BENGALI);
assert_eq!(language("fr"), 0);
assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id", "vi", "bs"].iter().all(|c| can_show(c)));
@@ -202,8 +231,8 @@ mod tests {
/// The UI language's script leads the fallback order; the others follow.
#[test]
fn the_ui_language_script_comes_first() {
let order = |need, ui| samples(need, ui).into_iter().map(|(c, _)| c).collect::<String>();
let first_face = |need, ui| samples(need, ui).first().and_then(|(_, f)| f.first()).copied();
let order = |need, ui| samples(need, ui, false).into_iter().map(|(c, _)| c).collect::<String>();
let first_face = |need, ui| samples(need, ui, false).first().and_then(|(_, f)| f.first()).copied();
assert_eq!(order(KANA | HAN | HANGUL, 0), "あ漢한");
assert_eq!(order(KANA | HAN | HANGUL | BENGALI, language("bn")), "অあ漢한");
assert_eq!(order(KANA | HAN | HANGUL, KANA | HAN), "あ漢한");
@@ -220,7 +249,15 @@ mod tests {
// A Chinese UI draws Han in a Chinese face first, Japanese names in it too.
assert_eq!(order(KANA | HAN | HANGUL, HAN), "们あ한");
assert_eq!(order(KANA | HAN, language("zh-Hans")), "们あ");
assert!(["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"].iter().all(|f| samples(HAN, HAN)[0].1[..4].contains(f)));
assert!(["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"].iter().all(|f| samples(HAN, HAN, false)[0].1[..4].contains(f)));
// Traditional Chinese puts an installed Noto Sans CJK TC, then Taiwan's faces, first.
let order_hant = |need, ui| samples(need, ui, true).into_iter().map(|(c, _)| c).collect::<String>();
assert_eq!(order_hant(KANA | HAN, language("zh-Hant")), "們あ");
assert_eq!(first_face(HAN, HAN), Some("Noto Sans CJK SC"));
let hant = samples(HAN, language("zh-Hant"), true);
assert_eq!(hant[0].0, '們');
assert_eq!(hant[0].1[0], "Noto Sans CJK TC");
assert!(["Microsoft JhengHei UI", "PingFang TC", "Noto Sans CJK TC"].iter().all(|f| hant[0].1.contains(f)));
assert!(faces(0, "en", &|_| false).is_empty());
assert!(faces(KANA | HANGUL | HAN | BENGALI, "ja", &|_| true).is_empty(), "nothing is looked up for scripts the UI draws");
}
@@ -181,3 +181,35 @@ fn advanced_options_offer_the_bounding_box_for_text_layers() {
assert!(shown.iter().any(|s| s == "Include bounding box"), "a rectangle that has it on shows it: {shown:?}");
assert!(u.app.session.doc().unwrap().node(r).unwrap().exports[0].include_bounding_box, "the setting is kept");
}
/// The advanced options of a PNG, JPG or WebP setting list its color profile: same as the file
/// (naming the document's profile), sRGB or Display P3. SVG, PDF and XML settings list none.
#[test]
fn advanced_options_offer_a_color_profile_for_images() {
let mut s = Session::new();
let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap());
s.execute("export.add", &json!({"ids": [r]})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = crate::layers_drag_tests::Ui::new(s);
let options = |u: &mut crate::layers_drag_tests::Ui| {
u.frame(vec![]);
egui::Popup::open_id(&u.ctx, egui::Id::unique(("export_more", 0)));
u.frame(vec![]);
crate::import_report_tests::shown_texts(u.frame_output(vec![]).shapes)
};
for f in ["png", "jpg", "webp"] {
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": f}));
let shown = options(&mut u);
for label in ["Color profile", "Same as file (sRGB)", "sRGB", "Display P3"] {
assert!(shown.iter().any(|s| s == label), "{f}: no {label:?} in {shown:?}");
}
}
u.app.run("file.colorProfile", json!({"profile": "displayP3"}));
let shown = options(&mut u);
assert!(shown.iter().any(|s| s == "Same as file (Display P3)"), "{shown:?}");
for f in ["svg", "pdf", "xml"] {
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": f}));
let shown = options(&mut u);
assert!(!shown.iter().any(|s| s == "Color profile" || s.starts_with("Same as file")), "{f}: {shown:?}");
}
}
+13 -6
View File
@@ -78,15 +78,19 @@ const LATIN_AMERICA: &[&str] =
/// The [`LANGS`] index for one tag: the exact tag ("pt-BR"), else its base language ("es" for
/// "es_ES.UTF-8"), else a regional catalog of that language ("pt-BR" for "pt-PT"); Latin American
/// Spanish ("es-MX") reads es-419, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) none: it
/// has no catalog and Simplified isn't a stand-in. A language the UI can't draw (CJK on the web
/// without CJK faces built in) doesn't count.
/// Spanish ("es-MX") reads es-419. A Chinese tag reads Traditional (zh-Hant) for `Hant`, `TW`, `HK`
/// or `MO` and Simplified (zh-Hans) otherwise; an explicit `Hans` always reads Simplified. A
/// language the UI can't draw (CJK on the web without CJK faces built in) doesn't count.
fn resolve_one(want: &str) -> Option<usize> {
let tag = want.trim().split(['.', '@']).next().unwrap_or("").replace('_', "-");
let base = tag.split('-').next().unwrap_or("");
let has = |subtags: &[&str]| tag.split('-').skip(1).any(|t| subtags.iter().any(|s| s.eq_ignore_ascii_case(t)));
if base.eq_ignore_ascii_case("zh") && (has(&["Hant"]) || (!has(&["Hans"]) && has(&["TW", "HK", "MO"]))) {
return None;
let zh = |code: &str| {
LANGS.iter().position(|l| l.code.eq_ignore_ascii_case(code)).filter(|i| LANGS.get(*i).is_some_and(|l| crate::cjk::can_show(l.code)))
};
if base.eq_ignore_ascii_case("zh") {
let traditional = has(&["Hant"]) || (!has(&["Hans"]) && has(&["TW", "HK", "MO"]));
return zh(if traditional { "zh-Hant" } else { "zh-Hans" });
}
let find = |t: &str| LANGS.iter().position(|l| l.code.eq_ignore_ascii_case(t));
let alias = (base.eq_ignore_ascii_case("es") && has(LATIN_AMERICA)).then(|| find("es-419")).flatten();
@@ -94,6 +98,7 @@ fn resolve_one(want: &str) -> Option<usize> {
alias.or_else(|| find(&tag)).or_else(|| find(base)).or_else(regional).filter(|i| LANGS.get(*i).is_some_and(|l| crate::cjk::can_show(l.code)))
}
/// Look `key` up in one of the sorted catalog tables.
fn find(table: &'static [(&'static str, Text)], key: &str) -> Option<&'static Text> {
table.binary_search_by(|(k, _)| (*k).cmp(key)).ok().and_then(|i| table.get(i)).map(|(_, t)| t)
}
@@ -182,7 +187,7 @@ fn plural(code: &str, n: usize) -> Plural {
"bs" if n % 10 == 1 && n % 100 != 11 => Plural::One,
"bs" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few,
"bs" => Plural::Other,
"ja" | "ko" | "th" | "zh-Hans" | "vi" => Plural::Other,
"ja" | "ko" | "th" | "zh-Hans" | "zh-Hant" | "vi" => Plural::Other,
"fr" | "pt-BR" if n <= 1 => Plural::One,
_ if n == 1 => Plural::One,
_ => Plural::Other,
@@ -238,6 +243,8 @@ pub fn menu_item<'a>(id: &str, english: &'a str) -> Cow<'a, str> {
label(true, id, english)
}
/// A command's or menu item's label: the catalog wording from `chain`, keeping a dialog's trailing
/// ellipsis when the English label has one.
fn label<'a>(menu: bool, id: &str, english: &'a str) -> Cow<'a, str> {
let text = chain(CURRENT.load(Ordering::Relaxed)).find_map(|l| menu.then(|| find(l.menu, id)).flatten().or_else(|| find(l.commands, id)));
match text {
+46 -7
View File
@@ -514,7 +514,7 @@ fn japanese_korean_chinese_and_vietnamese_have_no_plural_forms() {
assert_eq!(code("system", Some("ja,en-US")), "ja");
assert_eq!(LANGS[resolve("vi", None)].name, "Tiếng Việt");
assert!(LANGS[resolve("vi", None)].draft);
for code in ["ja", "ko", "th", "zh-Hans", "vi"] {
for code in ["ja", "ko", "th", "zh-Hans", "zh-Hant", "vi"] {
let lang = LANGS.iter().position(|l| l.code == code).unwrap();
assert!(!plural_one(lang, 0) && !plural_one(lang, 1) && !plural_one(lang, 2), "{code}");
let ones: Vec<_> = LANGS[lang].ui.iter().filter(|(_, t)| matches!(t, Text::Plural(Forms { one: Some(_), .. }))).map(|(k, _)| k).collect();
@@ -594,17 +594,56 @@ fn the_built_in_georgian_face_draws_the_georgian_catalog() {
}
#[test]
fn chinese_reads_simplified_and_traditional_has_no_catalog() {
fn chinese_tags_pick_simplified_or_traditional() {
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
for tag in ["zh-CN", "zh_CN.UTF-8", "zh-SG", "zh-Hans", "zh-Hans-CN", "zh-Hans-HK", "zh"] {
assert_eq!(code("system", Some(tag)), "zh-Hans", "{tag}");
}
// No Traditional Chinese catalog: English, never Simplified; else the next OS language.
for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW", "zh_TW.UTF-8"] {
assert_eq!(code("system", Some(tag)), "en", "{tag}");
assert_eq!(code(tag, None), "en", "{tag}");
// Taiwan, Hong Kong, Macau and Hant read Traditional Chinese, never Simplified.
for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW", "zh_TW.UTF-8", "zh-Hant-HK"] {
assert_eq!(code("system", Some(tag)), "zh-Hant", "{tag}");
assert_eq!(code(tag, None), "zh-Hant", "{tag}");
}
assert_eq!(code("system", Some("zh-TW,ja-JP")), "ja");
// An explicit Hans still reads Simplified, whatever the region.
assert_eq!(code("system", Some("zh-Hans-TW")), "zh-Hans");
assert_eq!(code("system", Some("zh-TW,ja-JP")), "zh-Hant");
assert_eq!(code("system", Some("ja-JP,zh-TW")), "ja");
}
#[test]
fn traditional_chinese_is_a_draft_in_the_taiwan_design_terms() {
let zh = LANGS.iter().position(|l| l.code == "zh-Hant").expect("locales/zh-Hant.toml is baked in");
assert_eq!(LANGS[zh].name, "繁體中文(台灣)");
assert!(LANGS[zh].draft, "a machine-drafted catalog stays a draft until a native speaker reviews it");
// Spot checks for the Taiwan design terms the catalog is written in.
assert!(matches!(lookup(zh, "Canvas"), Some(Text::Plain("畫布"))));
assert!(matches!(lookup(zh, "Layers"), Some(Text::Plain("圖層"))));
assert!(matches!(lookup(zh, "Frame"), Some(Text::Plain("框架"))));
assert!(matches!(lookup(zh, "Component"), Some(Text::Plain("元件"))));
assert!(matches!(lookup(zh, "Constraints"), Some(Text::Plain("約束"))));
assert!(matches!(lookup(zh, "Fill"), Some(Text::Plain("填色"))));
assert!(matches!(lookup(zh, "Stroke"), Some(Text::Plain("邊框"))));
assert!(matches!(lookup(zh, "Export"), Some(Text::Plain("匯出"))));
assert!(matches!(lookup(zh, "Version"), Some(Text::Plain("版本"))));
assert!(matches!(lookup(zh, "Prototype"), Some(Text::Plain("原型"))));
}
/// Changing the preference and reading it back keeps the Chinese tag; the tag still resolves to
/// the same catalog after the preferences are saved and loaded (a restart).
#[test]
fn traditional_chinese_switches_and_the_preference_persists() {
let mut s = figcraft_engine::Session::new();
s.execute("pref.language", &serde_json::json!({"language": "zh-TW"})).unwrap();
assert_eq!(s.prefs.language, "zh-TW");
assert_eq!(LANGS[resolve(&s.prefs.language, None)].code, "zh-Hant");
let saved = serde_json::to_string(&s.prefs).unwrap();
let loaded: figcraft_engine::Prefs = serde_json::from_str(&saved).unwrap();
assert_eq!(loaded.language, "zh-TW");
assert_eq!(LANGS[resolve(&loaded.language, None)].code, "zh-Hant");
s.execute("pref.language", &serde_json::json!({"language": "zh-Hans"})).unwrap();
assert_eq!(LANGS[resolve(&s.prefs.language, None)].code, "zh-Hans");
s.execute("pref.language", &serde_json::json!({"language": "zh-Hant"})).unwrap();
assert_eq!(LANGS[resolve(&s.prefs.language, None)].code, "zh-Hant");
}
#[test]
@@ -112,3 +112,61 @@ fn shut_pair_fields_edit_their_own_axis() {
u.frame(vec![]);
assert_eq!(padding(&u, f), [7.0, 20.0, 7.0, 20.0]);
}
/// A press and release at `p` with Cmd/Ctrl held (Alt is the field's own scrub modifier).
fn command_click(u: &mut Ui, p: Pos2) {
let m = Modifiers::COMMAND;
let press = |pressed: bool| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: m };
u.frame_with(vec![Event::PointerMoved(p)], m);
u.frame_with(vec![press(true)], m);
u.frame_with(vec![press(false)], m);
u.frame(vec![]);
}
/// A value typed
/// after a Cmd/Ctrl-click on any padding field goes to all four sides in one undo step; a plain
/// click keeps editing that side or axis alone.
#[test]
fn a_command_click_on_a_padding_field_edits_all_sides() {
let (mut u, f) = scene();
u.app.run("node.set", json!({"ids": [f], "props": {"paddingLeft": 16.0, "paddingRight": 54.0}}));
assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0]);
// Cmd/Ctrl-click and Tab away without typing: nothing is committed, to any side.
let before = u.app.session.undo_depth().0;
let v = named_field(&mut u, "Vertical padding");
command_click(&mut u, v);
u.frame(vec![
Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE },
Event::Key { key: Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
]);
u.frame(vec![]);
assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0], "an untouched field commits nothing");
assert_eq!(u.app.session.undo_depth().0, before);
// The shut pair: Cmd/Ctrl-click Vertical padding and type 7: every side is 7.
let history = u.app.session.undo_depth().0;
let v = named_field(&mut u, "Vertical padding");
command_click(&mut u, v);
u.frame(vec![Event::Text("7".into())]);
enter(&mut u);
u.frame(vec![]);
assert_eq!(padding(&u, f), [7.0; 4]);
assert_eq!(u.app.session.undo_depth().0, history + 1, "one undo step");
// A plain click afterwards edits its own axis again.
let v = named_field(&mut u, "Vertical padding");
click(&mut u, v);
u.frame(vec![Event::Text("9".into())]);
enter(&mut u);
u.frame(vec![]);
assert_eq!(padding(&u, f), [9.0, 7.0, 9.0, 7.0]);
// Individual padding open: Cmd/Ctrl-click the left field and type 3: every side is 3.
let t = named_button(&mut u, "Individual padding");
click(&mut u, t);
let left = u.ctx.read_response(Id::unique(("padding", 3)).with("field")).expect("left field shows").rect.center();
command_click(&mut u, left);
u.frame(vec![Event::Text("3".into())]);
enter(&mut u);
u.frame(vec![]);
assert_eq!(padding(&u, f), [3.0; 4]);
u.app.run("edit.undo", json!({}));
assert_eq!(padding(&u, f), [9.0, 7.0, 9.0, 7.0], "undone as one step");
}
+28 -7
View File
@@ -7,7 +7,8 @@ use std::collections::BTreeSet;
use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{Document, ExportFormat, ExportSetting, ExportSize, NodeId, NodeKind, PrintSettings, VdCompat};
use figcraft_doc::color::ColorProfile;
use figcraft_doc::{Document, ExportColorProfile, ExportFormat, ExportSetting, ExportSize, NodeId, NodeKind, PrintSettings, VdCompat};
use figcraft_engine::cmd::file::export_jobs;
use serde_json::{Value, json};
@@ -69,7 +70,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId
widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary);
}
for (i, e) in list.iter().enumerate() {
setting_row(app, ui, &ids, i, e, text);
setting_row(app, ui, &ids, i, e, text, doc.color_profile);
}
let Some(first) = first else { return };
preview(app, ui, doc, first);
@@ -84,8 +85,8 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId
/// One export setting: size (typed or picked; images only, SVG and PDF are vector), suffix,
/// format, advanced options and remove; a PDF's print settings under it. `text`: every layer in
/// `ids` is a text layer.
fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting, text: bool) {
/// `ids` is a text layer; `file`: the document's color profile.
fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting, text: bool, file: ColorProfile) {
row(ui, 32.0, |ui| {
// Tighter spacing so the advanced options and remove buttons fit beside the fields.
ui.spacing_mut().item_spacing.x = 4.0;
@@ -129,7 +130,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo
.width(216.0)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
if let Some(mut params) = advanced_options(ui, e, text) {
if let Some(mut params) = advanced_options(ui, e, text, file) {
params["ids"] = json!(ids);
params["index"] = json!(i);
app.run("export.update", params);
@@ -161,8 +162,9 @@ const VD_COMPAT: [(VdCompat, &str); 3] =
/// A setting's advanced options: ignore overlapping layers (`contentsOnly`), include bounding box
/// for text layers (and for any setting that has it on, so it can be turned off); for SVG outline
/// text, the id attribute and simplified strokes; for XML (Android Vector Drawable) the id attribute
/// (as `android:name`) and the compatibility mode. Returns the changed option as command params.
fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option<Value> {
/// (as `android:name`) and the compatibility mode; for PNG, JPG and WebP the color profile (`file`
/// is the document's). Returns the changed option as command params.
fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool, file: ColorProfile) -> Option<Value> {
let mut out = None;
let mut option = |ui: &mut Ui, key: &str, on: bool, label: &str| {
if widgets::checkbox(ui, on, label) {
@@ -189,9 +191,28 @@ fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option<Value>
}
}
}
if e.format.is_raster() {
ui.add_space(4.0);
widgets::label(ui, tr("Color profile"), Tokens::get(ui.ctx()).text_secondary);
for p in ExportColorProfile::ALL {
let on = e.color_profile == p;
if widgets::radio(ui, on, &color_profile_label(p, file)) && !on {
out = Some(json!({"colorProfile": p.key()}));
}
}
}
out
}
/// How the advanced options name an export color profile: "Same as file (Display P3)" names the
/// profile of `file`, the document's.
fn color_profile_label(p: ExportColorProfile, file: ColorProfile) -> String {
match p {
ExportColorProfile::SameAsFile => trf("Same as file ({profile})", &[("profile", &file.label())]),
p => p.resolve(file).label().to_string(),
}
}
/// A PDF setting's print settings: a summary row that opens them.
fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintSettings) {
let t = Tokens::get(ui.ctx());
+15 -6
View File
@@ -16,7 +16,7 @@ use crate::i18n::{tr, tr_noop};
use crate::icons::{self, Icon};
use crate::right::{block, common, menu_item, menu_popup, row};
use crate::theme::{ACCENT, Tokens, regular};
use crate::widgets::{self, FIELD_H, Lead};
use crate::widgets::{self, FIELD_H, FieldEdit, Lead};
/// Most tracks listed in the panel (the rest are still laid out; edit them through commands).
const MAX_TRACK_ROWS: usize = 24;
@@ -416,6 +416,15 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
}
}
/// Commit a padding field's edit: a value typed after a Cmd/Ctrl-click goes to all four sides
/// (Guide to auto layout); otherwise to the field's own side or axis.
fn padding_field(app: &mut FigApp, ui: &Ui, ids: &[NodeId], key: &str, id: Id, e: Option<FieldEdit>) {
match e {
Some(FieldEdit::Set(v)) if widgets::edit_began_with_command(ui, id) => app.set_props(ids, json!({"padding": v.max(0.0)})),
e => app.field(ids, key, e, |v| json!(v.max(0.0))),
}
}
/// The Spacing row: the H/V padding pair, or, with Individual padding on, two rows of one field
/// per side (like Individual corners).
fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
@@ -439,9 +448,9 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
for (r, pair) in sides.chunks(2).enumerate() {
row(ui, 32.0, |ui| {
for (key, name, icon, v, k) in pair {
let e =
crate::variable_ui::number_field(app, ui, ids, key, Id::unique(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col);
app.field(ids, key, e, |v| json!(v.max(0.0)));
let id = Id::unique(("padding", *k));
let e = crate::variable_ui::number_field(app, ui, ids, key, id, Lead::Icon(*icon, name), Some(*v), "", col);
padding_field(app, ui, ids, key, id, e);
}
if r == 0 {
toggle(ui);
@@ -461,7 +470,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
"",
col,
);
app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0)));
padding_field(app, ui, ids, "paddingHorizontal", Id::unique("al_padh"), e);
let e = crate::variable_ui::number_field(
app,
ui,
@@ -473,7 +482,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
"",
col,
);
app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0)));
padding_field(app, ui, ids, "paddingVertical", Id::unique("al_padv"), e);
toggle(ui);
});
}
+2 -1
View File
@@ -1,7 +1,7 @@
//! Params the panels build at run time name only what their commands take: a name a command
//! doesn't list is refused (#1413), which would break the control that sent it.
use figcraft_doc::{ExportFormat, ExportSetting, NodeId, PrintSettings, VdCompat};
use figcraft_doc::{ExportColorProfile, ExportFormat, ExportSetting, NodeId, PrintSettings, VdCompat};
use figcraft_engine::Session;
use serde_json::{Value, json};
@@ -30,6 +30,7 @@ fn export_settings_with_every_option_are_taken() {
svg_id_attribute: true,
svg_simplify_stroke: false,
vd_compat: VdCompat::Exact,
color_profile: ExportColorProfile::DisplayP3,
quality: 70,
..ExportSetting::default()
};
+31 -1
View File
@@ -223,6 +223,12 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option<f64>, suffix:
number_field_mixed(ui, id, lead, value, suffix, width, step)?.settled()
}
/// Whether the edit of number field `id` began with a Cmd/Ctrl-click. A padding field edited
/// that way sets every side (Guide to auto layout, Keyboard shortcut guide).
pub fn edit_began_with_command(ui: &Ui, id: Id) -> bool {
ui.data(|d| d.get_temp::<bool>(id.with("cmdstart"))).unwrap_or(false)
}
/// [`number_field`] over several layers: without a single value (`value` None, "Mixed") a relative
/// edit comes back as one for each layer ([`Edit::Each`]).
pub fn number_field_mixed(ui: &mut Ui, id: Id, lead: Lead, value: Option<f64>, suffix: &str, width: f32, step: f64) -> Option<Edit> {
@@ -398,14 +404,28 @@ fn number_field_impl(
// The text the field would commit to no effect: what it showed when the edit began, or the
// value the arrows last stepped it to.
let kept_id = id.with("kept");
// Whether Cmd/Ctrl was held while the button was down on the field: a click's modifier is
// read on its press, so letting go of the key before the release still counts.
let cmd_press_id = id.with("cmdpress");
let cmd_held_before = ui.data(|d| d.get_temp::<bool>(cmd_press_id)).unwrap_or(false);
let (cmd_pressed, any_down) = ui.input(|i| (field_resp.is_pointer_button_down_on() && i.modifiers.command, i.pointer.any_down()));
ui.data_mut(|d| d.insert_temp(cmd_press_id, cmd_pressed || (cmd_held_before && any_down)));
if started {
editing = Some(if typed_now { String::new() } else { value.map(fmt_num).unwrap_or_default() });
// Whether Cmd/Ctrl was held on the click that began the edit: a padding field then sets
// every side ([`edit_began_with_command`]). The flag lives as long as the edit.
let with_command = !typed_now && (cmd_pressed || cmd_held_before || ui.input(|i| i.modifiers.command));
ui.data_mut(|d| {
d.insert_temp(editing_id, editing.clone().unwrap_or_default());
d.insert_temp(kept_id, value.map(fmt_num).unwrap_or_default());
d.insert_temp(id.with("selectall"), !typed_now);
d.insert_temp(id.with("cmdstart"), with_command);
d.remove::<bool>(id.with("edited"));
});
ui.memory_mut(|m| m.request_focus(id.with("te")));
} else if editing.is_none() {
// No edit under way: an arrow step on the focused field is its own side's.
ui.data_mut(|d| d.remove::<bool>(id.with("cmdstart")));
}
match editing {
Some(mut text) => {
@@ -419,6 +439,11 @@ fn number_field_impl(
// top would move its baseline below the centered value painted while idle.
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect));
let r = child.add(te);
if r.changed() {
// Typing (the same value again included) is what lets a Cmd/Ctrl-begun edit commit
// to every side; a field left as it was commits nothing.
ui.data_mut(|d| d.insert_temp(id.with("edited"), true));
}
a11y::own_focus(&r);
keep_focus(&r, started);
crate::shortcuts::release_field_for_save(&r);
@@ -445,6 +470,7 @@ fn number_field_impl(
ui.data_mut(|d| {
d.insert_temp(kept_id, text.clone());
d.insert_temp(id.with("selectall"), true);
d.insert_temp(id.with("edited"), true);
});
out = Some(set(nv));
}
@@ -460,7 +486,11 @@ fn number_field_impl(
// Enter, Tab or a click elsewhere commits what was typed (a field left as it was
// commits nothing: no undo step, and a rounded display isn't written back).
let kept: String = ui.data(|d| d.get_temp(kept_id)).unwrap_or_default();
if text != kept
// An edit begun with Cmd/Ctrl commits the value even retyped as it was: the other
// sides are to take it ([`edit_began_with_command`]).
let for_all = ui.data(|d| d.get_temp::<bool>(id.with("cmdstart"))).unwrap_or(false);
let edited = ui.data(|d| d.get_temp::<bool>(id.with("edited"))).unwrap_or(false);
if (text != kept || (for_all && edited))
&& let Some(e) = typed(&text)
{
out = Some(e);
+3 -2
View File
@@ -1,6 +1,6 @@
# Auto layout and constraints parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** patch (Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** patch (a Cmd/Ctrl-click on a padding field sets all four sides with the value typed, as the Guide to auto layout's shortcut guide says; Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
Auto layout is the most used layout feature in Figma and the one where small differences (a
hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%,
@@ -80,7 +80,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
|---|---|---|
| Horizontal, vertical, wrap (both directions) | done | `crates/layout` |
| Grid auto layout: fixed / hug / `fr` tracks, cells, spans, gaps, cell alignment | done | `fr` like CSS `fr` |
| Gap, auto gap (between / evenly / around), padding (per side), alignment 3×3 | done | |
| Gap, auto gap (between / evenly / around), padding (per side), alignment 3×3 | done | a Cmd/Ctrl-click on a padding field (the pair or a side) makes the value typed go to all four sides in one undo step (Guide to auto layout, Keyboard shortcut guide; `padding_individual_tests`) |
| Text baseline alignment | done | |
| Hug / Fill / Fixed sizing with coherent rules (Fill turns a hugging parent Fixed) | done | `al_nesting_tests.rs`, `al_behaviour_tests.rs` |
| Min/max width and height, redistribution | done | hugging text wraps at its max |
@@ -102,6 +102,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | patch | M2, #1823: Suggest auto layout is no longer Add auto layout under another name: `layout.suggest` splits a frame's layers into rows (Vertical) or else columns (Horizontal) by the gaps between their boxes, wraps each band of several layers in a new frame without fill or clipping that hugs it, and repeats inside it (16 levels, 1,000 layers per frame at most). The frame keeps its size; gap and padding come from the bands, so layers keep their boxes where the gaps are even and the bands start at the same offset; instances and layers inside them are refused; uneven gaps (smallest used), bands that start at different offsets across the flow (lined up at the smallest) and layers that overlap along both axes come back as `warnings`. A band frame hugs only when its own split succeeds: a band whose layers overlap along both axes gets no auto layout and stays Fixed, so it cannot reflow those children. `direction` sets the top level only; one undo step; `layout.add` is unchanged. Tests in `layout_suggest_tests.rs` |
| 2026-11-02 | patch | M2: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. The modifier was ignored before. Test: `padding_individual_tests` |
| 2026-11-03 | patch | M2, #1825: baseline alignment of a layer without text (an icon next to a text) puts its bottom on the line's baseline, as the help center's figure shows ("the bottoms of the icon and the word home are aligned on the red line"); it kept to the top of the line. A layer reaching below the text's baseline moves the text down, and a line with no text lines the layers' bottoms up (the documentation is silent; CSS baseline alignment does the same). Tests: `alignment_rules` (three cases), engine `baseline_alignment_puts_an_icons_bottom_on_the_text_baseline`. Numbers unchanged. |
| 2026-11-02 | patch | M2, #1824: fill container children share the free space by their content area: each one's own insets (padding and an included inside stroke, from the same `insets` the frame uses) come off the free space before the equal split and go back on its share, so a child with more padding or a thicker inside stroke takes a little more and the content areas match ("Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model", Use auto layout with CSS Flexbox in mind). Min and max still clamp the outer sizes and the frozen-share loop is as it was; children without insets split as before. Test: `nested_tests::fill_children_share_by_content_area` |
| 2026-11-02 | patch | M2, #1838: a double-click on an edge of an auto layout frame's bounding box sets Hug contents on that axis, Alt+double-click on an edge of a child's Fill container ("Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents", "Hold ⌥ Option or Alt and double-click ... to set it to Fill container", Guide to auto layout); a vertical edge sizes the width, a horizontal one the height; the Keyboard shortcuts panel lists both gestures. Test: `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills`. Numbers unchanged. |
File diff suppressed because one or more lines are too long
+10 -8
View File
@@ -208,16 +208,17 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
### 5. Localization: English, reviewed Russian and Turkish, plus unreviewed drafts
- **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese,
Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish
overlay, the strings added since each catalog was drafted (1–5% of each), the Arabic draft's native review and panel mirroring, Hindi and
Vietnamese, localized engine messages and number formatting; CJK UI text on the web without the
craft-fonts CJK build.
Korean, Simplified Chinese, Traditional Chinese, Indonesian and Czech drafts and the Latin
American Spanish overlay, the strings added since each catalog was drafted (1–5% of each), the
Arabic draft's native review and panel mirroring, Hindi and Vietnamese, localized engine
messages and number formatting; CJK UI text on the web without the craft-fonts CJK build.
- **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in
`crates/ui-egui/locales/` (measured 2026-10-17: 1,680 UI keys plus 561 command labels and 31
menu wordings, 2,272 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete,
`crates/ui-egui/locales/` (measured 2026-11-03: 1,975 UI keys plus 641 command labels and 31
menu wordings, 2,647 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete,
Spanish is held to completeness by the check, and the French, German, Brazilian Portuguese,
Japanese, Korean and Simplified Chinese machine drafts cover 95–99% (strings added since they
were drafted show English), with `es-419.toml` a Latin American overlay listing only what
Japanese, Korean, Simplified Chinese and Traditional Chinese machine drafts cover 95–100%
(the Traditional Chinese draft covers all 2,647 entries; strings added since the other drafts
were written show English), with `es-419.toml` a Latin American overlay listing only what
differs from Spanish. Russian (reviewed and approved by VanTan, one/few/many integer plurals)
covers 98.8%; Indonesian and Czech drafts, a Turkish catalog whose header says a native speaker
reviewed it, and a Swedish catalog (with its command labels since 2026-10-17) cover 98.7–99.6% ([localization-parity](localization-parity.md#languages)).
@@ -588,6 +589,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
| 2026-10-31 | minor | Gap 10: library analytics list the layers and files using each style and variable; their insertions, detachments and modes stay on the missing list. |
| 2026-10-31 | minor | Gap 15: the Controller menu names buttons as PlayStation and Switch controllers do; that item leaves the missing list. Estimate unchanged. Test: `present::tail_tests`. |
| 2026-10-30 | minor | Gap 7: text reads only the fallback fonts it draws with: a face per fallback family and style, a file's bytes shared between families, the faces worked out once per style (first text laid out: 41 font files and 265 MiB read down to 3 files and 20 MiB on a Linux machine with Noto Sans CJK). Numbers unchanged |
+15 -8
View File
@@ -4,7 +4,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
Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese, Bosnian and Czech drafts (machine-drafted,
Portuguese, Japanese, Korean, Simplified Chinese, Traditional Chinese, Indonesian, Vietnamese, Bosnian and Czech drafts (machine-drafted,
waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key
languages, then the other languages Figma ships. How to add a
language: [`translating.md`](translating.md).
@@ -32,15 +32,15 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
channel stay English. Accessibility names come from the same strings.
- **Language preference:** Preferences ▸ Language (System, then each catalog by its own name:
English, Bosanski, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語,
한국어, Português (Brasil), Русский, Svenska, Türkçe, Tiếng Việt, 简体中文), applied at once without a
한국어, Português (Brasil), Русский, Svenska, Türkçe, Tiếng Việt, 简体中文, 繁體中文(台灣)), applied at once without a
restart; System takes the first of the OS's preferred languages that has a catalog (on Windows
only the first preferred language counts, since that list also holds the languages added for a
keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional
tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the
other Latin American and US Spanish tags to es-419; regional ru, tr, cs and id tags (ru-RU,
tr-TR, cs-CZ, id-ID) to their catalogs; zh, zh-CN, zh-SG and zh-Hans read zh-Hans,
while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the
next OS language, never Simplified) or the browser's language on the web.
while zh-Hant, zh-TW, zh-HK and zh-MO read zh-Hant; an explicit Hans such as zh-Hans-TW still
reads Simplified) or the browser's language on the web.
`pref.language {language}` sets it from the CLI, MCP and the control channel.
- **Regional overlays:** a regional catalog whose language has a catalog lists only what differs
(`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the
@@ -81,10 +81,15 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
Linux and BSD: Noto Sans CJK, Source Han Sans, Nanum, WenQuanYi; the craft-fonts build's BIZ
UDPGothic first), the UI language's script first; Han without kana outside a Japanese or Korean UI
reads as Chinese, and a Chinese UI puts its Chinese face (Microsoft YaHei, PingFang SC, Noto Sans
CJK SC) ahead of the built-in Japanese one, so Han takes Simplified Chinese shapes; changing the
CJK SC) ahead of the built-in Japanese one, so Han takes Simplified Chinese shapes, while a
Traditional Chinese UI puts an installed Noto Sans CJK TC first, then Microsoft JhengHei, PingFang
TC and Heiti TC, so Han takes Traditional shapes; changing the
UI language lays the faces out again. Only that face's file is read and egui shares its bytes, so English users load
nothing (startup and memory measured unchanged); no CJK font installed means tofu, never a
crash. The web build has no system fonts: built with craft-fonts and `FIGCRAFT_EMBED_CJK=1` it
embeds Noto Sans CJK SC (+16 MB) and offers Japanese, Korean and Simplified Chinese; without a
Noto Sans CJK TC face built in it leaves Traditional Chinese out too; otherwise the language picker and
System leave them out. No font file is in this repo.
embeds Noto Sans CJK SC (+16 MB) and offers Japanese, Korean and Simplified Chinese; otherwise the language picker and
System leave them out. `FIGCRAFT_EMBED_SCRIPTS=Arab,Hebr,Thai,Deva` (one tag per script wanted, as
the craft-fonts manifest lists them) embeds those scripts' faces as well, so an Arabic or Persian
@@ -96,7 +101,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full |
|---|---|---|---|---|---|---|---|
| English | en | 2,352 / 2,352 (100%, source) | yes | Latin | yes (source) | full | 0 |
| Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) |
| Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
| Traditional Chinese (Taiwan) | zh-Hant | 2,647 / 2,647 (100%: 1,975 UI strings, 641 command labels, 31 menu wordings; draft, counted 2026-11-03) | yes | CJK text and IME in documents; UI text in an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC or Heiti TC (web: only with a built-in TC face); zh-TW, zh-HK, zh-MO and zh-Hant read it, an explicit Hans stays Simplified | no | draft | 1–2 (after native review) |
| Spanish | es, es-419 | 2,352 / 2,352 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) |
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 |
| Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web: only a build with `FIGCRAFT_EMBED_SCRIPTS=Arab` embeds Noto Sans Arabic (the font budget leaves it out otherwise, #1902); documents draw in it and the picker offers the Arabic UI then; panels do not mirror | no | draft | review + panel mirroring (not re-estimated) |
@@ -128,13 +134,14 @@ right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h**
every string adds).
Priority: native reviews of the drafts and the es-419 overlay (every one of Figma's languages
and variants has one, plus Simplified Chinese, Indonesian, Vietnamese and Czech, which Figma doesn't ship), then
Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see English).
and variants has one, plus Simplified Chinese, Traditional Chinese, Indonesian, Vietnamese and Czech, which Figma doesn't ship), then
Hindi and Arabic.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`, 繁體中文(台灣)) draft catalog: every UI string (1,975), command label (641) and menu wording (31), translated clean-room from the English source and marked as a draft awaiting native review. `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant` read it; an explicit Hans still reads Simplified Chinese. The desktop UI prefers an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC and Heiti TC; no font is bundled. No plural forms. Localization grade and estimates unchanged |
| 2026-11-03 | patch | M10, #1902: the web build drew Persian and Arabic text as missing-glyph boxes. The browser has no system fonts, and `crates/text/build.rs` keeps the web catalog to Inter, Noto Sans Georgian, the wordmark and (with `FIGCRAFT_EMBED_CJK`) Noto Sans CJK, so craft-fonts' Noto Sans Arabic never reached it. `FIGCRAFT_EMBED_SCRIPTS=Arab,Hebr,…` (script tags as the manifest's scripts column lists them) now embeds those scripts' faces in a web build; the default build and its size are unchanged, and with `Arab` embedded the picker offers the Arabic UI as well (`cjk::can_show`, as on the desktop). Noto Sans Hebrew, Devanagari and Georgian join Arabic and Thai among the document fallbacks, so an embedded face draws its script in an Inter document. Tests: `web_embeds_other_scripts_faces_only_when_asked` in the build script, `every_embeddable_script_face_is_a_document_fallback`. |
| 2026-11-05 | patch | #2014: the panels drew a String variable's Amharic value as boxes: the UI took a system face for Hebrew and Arabic only. `bidi::script_fallbacks` now samples one letter of each of 16 scripts (Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as well as Hebrew and Arabic; Bengali and Thai keep `crate::cjk`'s faces) against the system's families for each (Windows, macOS, the common Linux fonts), the first installed one that draws the letter taken once. Desktop only: the web build has no system fonts. Test: `bidi::tests::other_scripts_take_an_installed_system_face` (skips scripts the machine has no face for). |
| 2026-11-05 | minor | Documents: a paragraph's base direction can be forced left to right or right to left (Text ▸ Text direction, `text.direction`, `node.set textDirection`, the Type settings control, the `.fig` lines' `sourceDirectionality`), as Add right-to-left text says (#1830); detection stays the default. Four new UI strings (English only; other catalogs fall back). |
+2 -2
View File
@@ -380,7 +380,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
| `inspect_document {depth, allPages, fields?, outline?}` | The document as JSON (its `id`, layer tree, styles, selection, history); `fields` selects layer properties, `outline: true` gives a compact tree (below). The tools that act on a document also take `document` (see Naming the document below). |
| `get_node {id, depth, fields?, outline?}` | One layer as JSON; takes the same `fields` and `outline` options; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. |
| `select {ids}` | Replace the selection (`[]` deselects). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?, quality?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`; `quality` is the JPG quality, 1 to 100 (default 90). 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, or only the frame or group it sits in while `contentsOnly` is on); `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, XML as text followed by `{format, bytes, warnings}`. 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). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?, colorProfile?, quality?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`; `quality` is the JPG quality, 1 to 100 (default 90). 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, or only the frame or group it sits in while `contentsOnly` is on); `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; png, jpg and webp: `colorProfile` is `sameAsFile` (default: the document's profile), `srgb` or `displayP3`, the pixels converted (out-of-gamut colors clip) and the file tagged for it (SVG, PDF and XML are always sRGB). 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, XML as text followed by `{format, bytes, warnings}`. 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?, reload?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` or FigJam `.jam` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.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 gives its `index`, with `stale: true` when the file changed on disk since that tab read or saved it; `reload: true` reads it again into the tab, refused while the tab has unsaved changes) and the document's `document` outline. |
| `undo` / `redo` | Step through history. |
@@ -693,7 +693,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
- `pref.startOnHome {on?}`: the desktop app starts on the Home screen instead of in a new file
(off by default; files named on the command line still open, and `--control` starts in a new
file anyway, so control callers have a document).
- `pref.language {language: system|en|bs|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|vi|zh-Hans|…}`: the app's interface language, applied live ("system":
- `pref.language {language: system|en|bs|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|vi|zh-Hans|zh-Hant|…}`: the app's interface language, applied live ("system":
the OS or browser language; a language without a catalog shows English). Command ids, labels and
every result stay English.
+3 -2
View File
@@ -16,9 +16,9 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr
3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)):
40–60 h, parallel by tool.
4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja,
ko, zh-Hans, id and cs drafts and the es-419 overlay (people; Russian and Turkish are reviewed),
ko, zh-Hans, zh-Hant, id and cs drafts and the es-419 overlay (people; Russian and Turkish are reviewed),
the entries added since each catalog was written, then the other key languages (Hindi,
Vietnamese): 3–7 h per language plus native review. Arabic now has a complete draft; native review and panel mirroring remain.
Vietnamese): 3–7 h per language plus native review. Arabic now has a complete draft; native review and panel mirroring remain. Traditional Chinese (Taiwan) has a complete draft too.
5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h.
## Alpha gate
@@ -75,6 +75,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
| 2026-11-03 | patch | M4, #1747: a text layer still carrying the name its characters gave it is renamed as they are typed, so the Layers panel no longer shows the word a layer started with while the canvas shows another (`hello` typed into `hello there` read as `hello` in the panel). A name the author typed is kept, and only the editor follows the characters: commands leave the name alone, so the layer names `data.fill`, component property bindings and templates use as field keys stay put. Tests: `text_behaviour_tests::typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it`, `setting_characters_as_a_command_never_renames_the_layer`. Grade unchanged |
| 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged |
| 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged |
+1
View File
@@ -652,6 +652,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
| 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). |
| 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first; a loop listed out of walking order is left as it was. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). |
| 2026-11-06 | patch | M3: glass's docs say which centre `splay` bends away from: the centre of the layer's bounds, as the reference tool's, checked side by side on a ring and an L at Splay 0% and 100% (an inner edge bends towards the hole in both). And where a glass shape gets no core: past 4M px on the desktop and 1M on the web (`MAX_SHAPE_PX`) it bends along its edge normals, so a large glass text or vector can bend differently once zoomed into, in a 2× export, or on the web. Docs only. Grade unchanged. |
+31 -2
View File
@@ -10,13 +10,14 @@ catalog reads English): a regional tag reads its language's catalog (`fr-CA` →
with only a regional catalog reads that one (`pt-PT` → `pt-BR`), and Latin American and US Spanish
(`es-MX`, `es-AR`, `es-CO`, `es-US`, `es-419`…) read `es-419`.
Chinese (`zh`, `zh-CN`, `zh-SG`, `zh-Hans`) reads `zh-Hans`; Traditional Chinese (`zh-Hant`,
`zh-TW`, `zh-HK`, `zh-MO`) has no catalog and reads English (or the next OS language), not
`zh-TW`, `zh-HK`, `zh-MO`) reads `zh-Hant`, and an explicit `Hans` (`zh-Hans-TW`) still reads
Simplified.
`pref.language {language}` does the same from the CLI, MCP and the control channel.
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
(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Traditional Chinese (`zh-Hant`), Czech
(`cs`), Indonesian (`id`), Finnish
(`fi`), Bengali (`bn`), Italian (`it`), Vietnamese (`vi`), Thai (`th`) and Bosnian (`bs`), machine-drafted and waiting for native speakers' review. Polish (`pl`,
Polski) was reviewed and approved by Lucas (@lukasz-sklad). Georgian (`ka`) was written with AI
assistance and reviewed by a native speaker, and stays a draft until a maintainer changes its status.
@@ -51,6 +52,9 @@ Enter for the key, control for a gamepad, resorte) and keeps Spanish's tú. Japa
concise, without です/ます endings, with full-width ()「」:、。 where Japanese UI text has them;
Korean labels end without 합니다 (instructions in -세요). Simplified Chinese is concise, with
full-width ():,、。“” in Chinese text and a space between Chinese and Latin words or numbers.
Traditional Chinese (Taiwan) is concise too, with full-width ():,、「」 and a space between
Chinese and Latin words or numbers, and Taiwan design terms (畫布, 圖層, 框架, 元件, 原型, 約束,
填色, 邊框, 效果, 匯出, 版本; Auto Layout stays English).
None of the three has plural forms. Turkish uses bare imperatives for buttons and menu items
(Kaydet, Sil) and the polite -in/-ın for instructions (seçin, tıklayın), puts the percent sign before
the number (%50) and keeps a noun singular after a count, so its plural entries give only `other`.
@@ -78,6 +82,12 @@ CJK and Thai text need their own fonts, which egui and Inter lack: the desktop a
one when something on screen needs it, so layer and page names in those scripts show whatever the
UI language (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts,
so Japanese, Korean and Chinese appear there only in a build with craft-fonts and
`FIGCRAFT_EMBED_CJK=1` (Noto Sans CJK SC, +16 MB); otherwise they are left out of the picker, and
Traditional Chinese is left out unless that build has a Noto Sans CJK TC face. A
Chinese UI puts its Chinese face first so Han takes Simplified Chinese shapes, and a Traditional
Chinese UI puts an installed Noto Sans CJK TC first, then the system's Taiwan faces (Microsoft
JhengHei, PingFang TC, Heiti TC), so Han takes Traditional shapes. No font is added to this
repository. Georgian is drawn in
`FIGCRAFT_EMBED_CJK=1` (Noto Sans CJK SC, +16 MB); otherwise they are left out of the picker. The other
scripts' faces (Noto Sans Arabic, Hebrew, Thai, Devanagari) join a web build only with
`FIGCRAFT_EMBED_SCRIPTS=Arab,Hebr,Thai,Deva` (one tag per script wanted, as the manifest lists them), for
@@ -107,6 +117,25 @@ that face out, so Arabic is not offered on the web yet. No font is added to
this repository. The interface's panels are not mirrored; this catalog does not claim a complete
right-to-left interface. Native review and representative in-app checks remain required.
## Traditional Chinese (Taiwan) draft
`zh-Hant.toml` adds 繁體中文(台灣) as a draft: 1,975 UI entries, 641 command labels and 31 menu
wordings (2,647 entries, measured 2026-11-03). It was translated clean-room from the English source
in `en.toml`; the Simplified Chinese catalog was not used as a source. It covers every English key,
so the completeness test keeps it that way.
Taiwan design terms: 畫布 (canvas), 圖層 (layer), 框架 (frame), 元件 (component), 原型 (prototype),
Auto Layout (kept in English), 約束 (constraints), 填色 (fill), 邊框 (stroke), 效果 (effects),
匯出 (export) and 版本 (version). Actions use concise Taiwan verbs (儲存, 刪除, 建立副本), and
quotes are 「」. Placeholders, command ids, keyboard glyphs and product names stay as in English.
Traditional Chinese has no plural forms, so plural entries give the same text for `one` and
`other`; `plural` never picks `one` for `zh-Hant`. The desktop UI adds an installed Noto Sans CJK
TC first, then Microsoft JhengHei, PingFang TC, Heiti TC and the other Taiwan faces; no font is
bundled. The web picker leaves Traditional Chinese out unless the build embeds a Noto Sans CJK TC
face, as it does for the other CJK catalogs. Native review remains open (draft), as do the in-app
checks of long labels in the right panel.
## Adding a language
1. Copy `crates/ui-egui/locales/es.toml` to `<code>.toml` (a BCP 47 code: `fr`, `pt-BR`, `ja`) and
+5 -4
View File
File diff suppressed because one or more lines are too long