Merge main into #1439

This commit is contained in:
bflatastic
2026-10-29 10:33:54 +07:00
16 changed files with 2965 additions and 15 deletions
+6
View File
@@ -40,3 +40,9 @@ egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own l
| Asset | Author | Source | Licence | Use |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
## Arabic interface catalog
| Path | Contributor | Source | Licence | Review |
|---|---|---|---|---|
| `crates/ui-egui/locales/ar.toml` | @hussain-hz with OpenAI Codex (AI-assisted) | Original Arabic wording from FigCraft's English catalog and engine command labels; no proprietary localisation consulted | MIT OR Apache-2.0 | Draft; native-speaker review and maintainer approval pending |
+8 -5
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -63,7 +63,7 @@ native speakers' reviews of the nine language drafts need people.
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
@@ -103,7 +103,7 @@ native speakers' reviews of the nine language drafts need people.
| Simplified Chinese | zh-Hans | 95% | 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 | 0% | none |
| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending |
| French | fr | 95% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
| Indonesian | id | 99% | draft (needs native review) |
@@ -114,7 +114,7 @@ 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%) and Czech (`cs`, 99%, 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 and Vietnamese drafts (Preferences ▸
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:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
@@ -127,11 +127,12 @@ 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, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
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)).
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
- **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged.
- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged.
- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
- **2026-10-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged.
@@ -351,6 +352,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
| 2026-10-27 | minor | M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. |
+14
View File
@@ -405,6 +405,11 @@ fn turn_pivot(s: &Session, ids: &[NodeId]) -> Result<Option<Point>> {
pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
let k = if k.is_finite() && k > 0.0 { k } else { return Ok(()) };
let sub = doc.subtree(id);
// The layers below `id` scale whatever their constraints, so where they are layers of an
// instance their new size and place are overrides. The edit's own diff takes geometry that
// changed with an ancestor's size for what constraints give, so the next rebuild of the
// instance would put them back at their main's size (#922).
let in_instance: Vec<bool> = sub.iter().enumerate().map(|(depth, i)| depth > 0 && crate::components::inside_instance(doc, *i)).collect();
let tiles = |paints: &mut Vec<figcraft_color::Paint>| {
for p in paints {
if let figcraft_color::PaintKind::Image(img) = &mut p.kind
@@ -416,6 +421,7 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
};
for (depth, i) in sub.iter().enumerate() {
let n = doc.get_mut(*i)?;
let (w0, h0, t0) = (n.width, n.height, n.transform);
tiles(&mut n.fills);
tiles(&mut n.strokes);
let cl = &mut n.child_layout;
@@ -511,6 +517,14 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
let c = n.transform.as_coeffs();
n.transform = Affine::new([c[0], c[1], c[2], c[3], c[4] * k, c[5] * k]);
}
if in_instance.get(depth).copied().unwrap_or(false) {
if n.width != w0 || n.height != h0 {
n.overrides.insert("size".into());
}
if n.transform != t0 {
n.overrides.insert("position".into());
}
}
}
Ok(())
}
+2
View File
@@ -426,6 +426,8 @@ mod save_format_tests;
#[cfg(test)]
mod scale_tool_tests;
#[cfg(test)]
mod scaled_instance_tests;
#[cfg(test)]
mod section_contents_tests;
#[cfg(test)]
mod section_tests;
+131
View File
@@ -0,0 +1,131 @@
//! Instances scaled with the Scale tool keep their scaled layers when a main around them or their
//! own main changes (#922): a 24 px icon with a 1.5 px stroke shown at 14 px has a 0.875 px stroke,
//! and its points and size are 14/24 of its main's.
use serde_json::{Value, json};
use crate::Session;
use crate::components::tests::{copy_of, ex, healthy, idv, node};
use figcraft_doc::{NodeId, NodeKind};
/// The scale the icon is shown at: 24 px to 14 px.
const K: f64 = 14.0 / 24.0;
/// Whether two lengths are equal up to rounding.
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
/// The icon: a 24 px component holding a flattened triangle with a 1.5 px stroke; returns the
/// component and the triangle.
fn icon(s: &mut Session) -> (NodeId, NodeId) {
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 24, "height": 24})));
let p = idv(&ex(s, "shape.polygon", json!({"x": 4, "y": 4, "width": 16, "height": 16, "parent": f})));
ex(s, "vector.flatten", json!({"ids": [p]}));
let v = node(s, f).children[0];
assert!(matches!(node(s, v).kind, NodeKind::Vector { .. }));
ex(s, "node.set", json!({"id": v, "props": {"strokes": ["#000000"], "strokeWeight": 1.5}}));
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
(c, v)
}
/// `copy` shows main layer `main` at 14/24: stroke, size and points.
fn assert_scaled(s: &Session, copy: NodeId, main: NodeId, why: &str) {
let (c, m) = (node(s, copy), node(s, main));
assert!(near(c.stroke.weight, m.stroke.weight * K), "{why}: stroke {} (main {})", c.stroke.weight, m.stroke.weight);
assert!(near(c.width, m.width * K) && near(c.height, m.height * K), "{why}: size {}×{} (main {}×{})", c.width, c.height, m.width, m.height);
let (NodeKind::Vector { network: a }, NodeKind::Vector { network: b }) = (&c.kind, &m.kind) else { panic!("{why}: not vectors") };
assert_eq!(a.vertices.len(), b.vertices.len(), "{why}");
for (va, vb) in a.vertices.iter().zip(&b.vertices) {
assert!(near(va.x, vb.x * K) && near(va.y, vb.y * K), "{why}: point ({}, {}) (main ({}, {}))", va.x, va.y, vb.x, vb.y);
}
}
/// Edits of a main: a value on its root, a value on a layer, a layer added (a rebuild).
fn main_edits(main: NodeId, layer: NodeId) -> Vec<(&'static str, Value)> {
vec![
("effects on the main", json!({"id": main, "props": {"effects": [{"type": "dropShadow"}]}})),
("effects cleared on the main", json!({"id": main, "props": {"effects": []}})),
("fill on the main", json!({"id": main, "props": {"fills": ["#ff0000"]}})),
("fill on a main layer", json!({"id": layer, "props": {"fills": ["#00ff00"]}})),
]
}
/// An instance scaled on the page keeps its scaled layers through value edits of its main and
/// a rebuild (a layer added to the main).
#[test]
fn a_scaled_instance_keeps_its_scale_when_its_main_changes() {
let mut s = Session::new();
let (c, v) = icon(&mut s);
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 100, "y": 0})));
ex(&mut s, "transform.scale", json!({"ids": [i], "factor": K}));
let iv = copy_of(&s, i, v);
assert_scaled(&s, iv, v, "scaled");
for (why, p) in main_edits(c, v) {
ex(&mut s, "node.set", p);
assert_scaled(&s, copy_of(&s, i, v), v, why);
}
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": c}));
assert_scaled(&s, copy_of(&s, i, v), v, "a layer added to the main");
healthy(&s);
}
/// Dragging a Scale tool handle records the same overrides, and undoing the drag takes them back.
#[test]
fn an_instance_scaled_with_the_tool_keeps_its_scale_and_undo_takes_it_back() {
use crate::tools::{PointerEvent, PointerKind};
let mut s = Session::new();
let (c, v) = icon(&mut s);
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 100, "y": 0})));
ex(&mut s, "select.set", json!({"ids": [i]}));
ex(&mut s, "tool.scale", json!({}));
// The bottom right handle, from 24 px to 14 px about the top left corner.
for (kind, (x, y)) in
[(PointerKind::Down, (124.0, 24.0)), (PointerKind::Move, (119.0, 19.0)), (PointerKind::Move, (114.0, 14.0)), (PointerKind::Up, (114.0, 14.0))]
{
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
}
assert!(near(node(&s, i).width, 14.0), "{}", node(&s, i).width);
assert_scaled(&s, copy_of(&s, i, v), v, "scaled with the tool");
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": c}));
assert_scaled(&s, copy_of(&s, i, v), v, "a layer added to the main");
s.undo().unwrap();
s.undo().unwrap();
let back = node(&s, copy_of(&s, i, v));
assert!(near(back.stroke.weight, 1.5) && back.overrides.is_empty(), "undo: {} {:?}", back.stroke.weight, back.overrides);
healthy(&s);
}
/// The report's case: an icon scaled inside a header that then becomes a component keeps its scale
/// in the header and in the header's instances, through edits of both mains.
#[test]
fn a_scaled_nested_icon_keeps_its_scale_when_the_component_holding_it_changes() {
let mut s = Session::new();
let (c, v) = icon(&mut s);
// The header holds the icon at 14 px, scaled with the Scale tool, then becomes a component.
let h = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 200, "height": 48})));
let hi = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 10, "y": 117, "parent": h})));
ex(&mut s, "transform.scale", json!({"ids": [hi], "factor": K}));
let header = idv(&ex(&mut s, "component.create", json!({"ids": [h]})));
let screen = idv(&ex(&mut s, "instance.create", json!({"component": header, "x": 0, "y": 300})));
// The icon's triangle as the header and as the instance of the header show it.
let both = |s: &Session| {
let in_header = copy_of(s, hi, v);
[in_header, copy_of(s, copy_of(s, screen, hi), in_header)]
};
let check = |s: &Session, why: &str| {
for (copy, where_) in both(s).into_iter().zip(["the header's icon", "the icon in an instance of the header"]) {
assert_scaled(s, copy, v, &format!("{why}: {where_}"));
}
};
check(&s, "made a component");
for (why, p) in main_edits(header, hi).into_iter().chain(main_edits(c, v)) {
ex(&mut s, "node.set", p);
check(&s, why);
}
ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 110, "width": 20, "height": 20, "parent": header}));
check(&s, "a layer added to the header");
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": c}));
check(&s, "a layer added to the icon");
healthy(&s);
}
+48
View File
@@ -100,6 +100,9 @@ fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> {
/// tall.
pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> {
use image::ImageDecoder;
if jpeg_finer_than_first(bytes) {
return None;
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(max_bytes);
@@ -364,6 +367,51 @@ pub fn apply_filters(p: &mut Pixmap, f: &ImageFilters) {
}
}
/// Whether `bytes` are a progressive JPEG whose frame samples some component more finely (across
/// or down) than its first. zune-jpeg 0.5.15, the decoder behind `image`, asserts in its upsampler
/// on such a frame (etemesi254/zune-image#236; fixed there by 36007420, unreleased as of
/// 0.5.16-rc3), and in the browser a panic aborts the page. Until the fix ships, such a JPEG is
/// refused from its header: the decoder refuses the larger ones itself, so nothing that decoded is
/// lost. Baseline frames of the same shape decode and are left to it.
pub(crate) fn jpeg_finer_than_first(bytes: &[u8]) -> bool {
let Some((marker, frame)) = jpeg_frame(bytes) else { return false };
if !matches!(marker, 0xC2 | 0xC6 | 0xCA | 0xCE) {
return false;
}
// Length (2), precision (1), height (2), width (2), components (1), then id, sampling and
// table for each component.
let n = usize::from(frame.get(7).copied().unwrap_or(0));
let sampling = |k: usize| frame.get(9 + 3 * k).map(|b| (b >> 4, b & 0x0F));
let Some((h0, v0)) = sampling(0) else { return false };
(1..n).any(|k| sampling(k).is_some_and(|(h, v)| h > h0 || v > v0))
}
/// A JPEG's start-of-frame marker and the segment after it (from its length bytes), found as the
/// decoders find it: fill bytes are skipped and markers without a length stepped over. `None` for
/// anything but a JPEG, or one whose scan, end or bytes come before a frame.
fn jpeg_frame(bytes: &[u8]) -> Option<(u8, &[u8])> {
if !bytes.starts_with(&[0xFF, 0xD8]) {
return None;
}
let mut at = 2;
loop {
if *bytes.get(at)? != 0xFF {
return None;
}
let marker = *bytes.get(at + 1)?;
match marker {
0xFF => at += 1,
0x01 | 0xD0..=0xD8 => at += 2,
0xD9 | 0xDA => return None,
0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return Some((marker, bytes.get(at + 2..)?)),
_ => {
let len = usize::from(u16::from_be_bytes([*bytes.get(at + 2)?, *bytes.get(at + 3)?]));
at = at.checked_add(2)?.checked_add(len)?;
}
}
}
}
/// Read an image's pixel size without keeping the decode.
pub fn dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
+137
View File
@@ -0,0 +1,137 @@
//! A progressive JPEG whose frame samples a component more finely than its first is refused from
//! its header, not handed to the decoder: zune-jpeg 0.5.15, the decoder behind `image`, asserts
//! in its upsampler on such a frame (etemesi254/zune-image#236), and in the browser a panic aborts
//! the page. Baseline frames of the same shape are decoded by the decoder itself and left to it.
use std::io::Cursor;
use super::*;
/// An 8 × 8 progressive RGB JPEG with every component sampled 1 × 1, as jpeg-encoder 0.6 writes it
/// at quality 50 (JFIF header, the standard tables, three scans). Its frame header (SOF2) starts at
/// byte 20; the second component's sampling byte is [`SECOND_SAMPLING`], the third's follows 3 on.
const TINY_PROGRESSIVE: &[u8] = &[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xC2, 0x00, 0x11,
0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x00, 0x11, 0x00, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E,
0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33,
0x3D, 0x3C, 0x39, 0x33, 0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68,
0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F,
0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00,
0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24,
0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A,
0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A,
0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99,
0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1,
0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00,
0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31,
0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15,
0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39,
0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97,
0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4,
0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA,
0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6F, 0xFF, 0xDA, 0x00, 0x08,
0x01, 0x01, 0x11, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00,
0x00, 0x01, 0x14, 0x00, 0x4D, 0xB0, 0x97, 0x8E, 0x45, 0x01, 0x08, 0x03, 0x70, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x01, 0x14, 0x00,
0xAD, 0x17, 0xA5, 0x4A, 0x6C, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x01, 0x14, 0x00, 0x8A, 0x75, 0xA1, 0xCB, 0x6D, 0x62, 0x7F, 0xFF, 0xDA,
0x00, 0x08, 0x01, 0x00, 0x00, 0x15, 0x29, 0x00, 0xE0, 0x75, 0x1C, 0x8C, 0x74, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x15, 0x29, 0x00,
0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x15, 0x29, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x2A, 0x3F, 0x00, 0xC8, 0x3C,
0x7E, 0x1F, 0x79, 0x07, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x2A, 0x3F, 0x00, 0xFE, 0x96, 0x8E, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02,
0x11, 0x2A, 0x3F, 0x00, 0xF9, 0x36, 0xFF, 0x00, 0x99, 0xFB, 0xDF, 0xFF, 0xD9,
];
const SECOND_SAMPLING: usize = 34;
/// An 8 × 8 baseline CMYK JPEG as jpeg-encoder 0.6 writes it at quality 50 with 2 × 2 sampling:
/// its black is sampled 2 × 2 against 1 × 1 for the others (SOF0 at byte 36), a well-formed file
/// of the shape the check is about, in a baseline frame.
const TINY_BASELINE_CMYK: &[u8] = &[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xEE, 0x00, 0x0E,
0x41, 0x64, 0x6F, 0x62, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xC0, 0x00, 0x14, 0x08, 0x00, 0x08, 0x00, 0x08, 0x04, 0x00, 0x11,
0x01, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0x03, 0x22, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D,
0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33, 0x38,
0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79,
0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42,
0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02,
0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51,
0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09,
0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47,
0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77,
0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5,
0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2,
0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6,
0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04,
0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07,
0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16,
0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46,
0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76,
0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3,
0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9,
0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6,
0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x0E, 0x04, 0x00, 0x11, 0x01, 0x11, 0x02, 0x11, 0x03, 0x00, 0x00, 0x3F, 0x00, 0xEE, 0xA6, 0xAA, 0x2A,
0xB3, 0x83, 0x95, 0xED, 0x6D, 0x75, 0xBB, 0xF5, 0xB4, 0x97, 0x6D, 0xB6, 0x5A, 0xD9, 0x2B, 0x8D, 0x39, 0x3A, 0xB3, 0x8F, 0x54, 0xFB, 0xCB, 0xAF,
0xFD, 0xBD, 0x7E, 0x9D, 0xDA, 0xEC, 0x96, 0xB7, 0xE2, 0x95, 0x47, 0x2A, 0x8E, 0x0B, 0x46, 0xB7, 0x77, 0x7A, 0xDD, 0xBE, 0xCD, 0x3F, 0xBD, 0xBF,
0x2B, 0x6C, 0x0E, 0xC3, 0x6E, 0xE9, 0xB6, 0xCA, 0xE0, 0x07, 0x1B, 0xC9, 0x21, 0x95, 0x4E, 0x72, 0x54, 0x0C, 0x8E, 0x00, 0x62, 0x00, 0x5C, 0x28,
0x45, 0xEF, 0x51, 0xFF, 0x00, 0x6B, 0xDA, 0xFF, 0x00, 0x76, 0xC7, 0xFF, 0x00, 0x01, 0x27, 0xA1, 0xFF, 0x00, 0xE3, 0xFB, 0xFE, 0xDD, 0x65, 0xFF,
0x00, 0xD1, 0x31, 0xD6, 0x35, 0x7F, 0xFF, 0xD9,
];
fn with_second_sampled(hv: u8) -> Vec<u8> {
let mut b = TINY_PROGRESSIVE.to_vec();
b[SECOND_SAMPLING] = hv;
b
}
#[test]
fn the_jpeg_as_written_is_not_finer_than_its_first_and_decodes() {
assert_eq!(TINY_PROGRESSIVE[SECOND_SAMPLING], 0x11);
assert!(!images::jpeg_finer_than_first(TINY_PROGRESSIVE));
let p = images::decode(TINY_PROGRESSIVE).expect("decoded");
assert_eq!((p.width(), p.height()), (8, 8));
// Chroma sampled coarser than luma (the usual way round) isn't finer either.
assert!(!images::jpeg_finer_than_first(&{
let mut b = TINY_PROGRESSIVE.to_vec();
b[SECOND_SAMPLING - 3] = 0x22;
b
}));
}
/// Without the check this test panics in the decoder (an assertion in zune-jpeg's upsampler).
#[test]
fn a_progressive_jpeg_sampling_a_component_finer_than_its_first_is_refused_from_its_header() {
for hv in [0x22, 0x21, 0x12] {
let b = with_second_sampled(hv);
assert!(images::jpeg_finer_than_first(&b), "second component {hv:#x}");
assert!(images::decode(&b).is_none(), "second component {hv:#x}");
}
let mut third = TINY_PROGRESSIVE.to_vec();
third[SECOND_SAMPLING + 3] = 0x22;
assert!(images::jpeg_finer_than_first(&third));
assert!(images::decode(&third).is_none());
}
#[test]
fn baseline_frames_and_other_files_are_left_to_the_decoder() {
// A well-formed baseline frame with its black sampled finer than its cyan: the decoder takes
// it, so it isn't refused from its header.
assert!(!images::jpeg_finer_than_first(TINY_BASELINE_CMYK));
let p = images::decode(TINY_BASELINE_CMYK).expect("decoded");
assert_eq!((p.width(), p.height()), (8, 8));
// Not JPEGs, or JPEGs that end before their frame.
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert!(!images::jpeg_finer_than_first(&png));
assert!(!images::jpeg_finer_than_first(&TINY_PROGRESSIVE[..19]));
assert!(!images::jpeg_finer_than_first(&[0xFF, 0xD8]));
assert!(!images::jpeg_finer_than_first(&[]));
assert!(images::decode(&TINY_PROGRESSIVE[..19]).is_none());
}
+2
View File
@@ -492,6 +492,8 @@ mod image_budget_tests;
#[cfg(test)]
mod image_profile_tests;
#[cfg(test)]
mod jpeg_sampling_tests;
#[cfg(test)]
mod lod_tests;
#[cfg(test)]
mod magnified_image_tests;
File diff suppressed because it is too large Load Diff
+13
View File
@@ -101,6 +101,11 @@ fn shows(code: &str, web: bool) -> bool {
if code.split('-').next() == Some("ka") {
return figcraft_text::face_with_char('ა', &["Noto Sans Georgian"]).is_some();
}
if code.split('-').next() == Some("ar") {
// Web cannot borrow the desktop's Arabic faces. Its current font budget excludes
// Arabic; keep the catalog out until an embedded Arabic fallback is supplied.
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())
}
@@ -186,6 +191,14 @@ mod tests {
assert!(shows("en", true) && shows("tr", true));
}
#[test]
fn the_web_offers_arabic_only_with_its_face() {
assert!(shows("ar", false));
let face = figcraft_text::face_with_char('ع', &["Noto Sans Arabic"]).is_some();
assert_eq!(shows("ar", true), face);
assert_eq!(shows("ar-SA", true), face);
}
/// The UI language's script leads the fallback order; the others follow.
#[test]
fn the_ui_language_script_comes_first() {
+44
View File
@@ -492,6 +492,50 @@ fn georgian_is_picked_for_georgian_locales() {
}
}
#[test]
fn arabic_tags_and_count_neutral_forms() {
let ar = LANGS.iter().position(|l| l.code == "ar").unwrap();
assert_eq!(LANGS[ar].name, "العربية");
for tag in ["ar", "ar-SA", "ar_EG.UTF-8", "ar_AE@arabic"] {
assert_eq!(resolve("system", Some(tag)), ar, "{tag}");
assert_eq!(resolve(tag, None), ar, "{tag}");
}
let counts = [0, 1, 2, 3, 10, 11, 99, 100, 102, 103, 111, 200, 1000];
for (key, text) in LANGS[ar].ui {
if let Text::Plural { one, few, many, other } = text {
assert!(one.is_none() && few.is_none() && many.is_none(), "{key}: count-neutral wording uses only other");
for n in counts {
assert_eq!(counted(ar, key, n), *other, "{key}, {n}");
assert!(fill(other, &[("n", &n)]).contains(&n.to_string()));
}
}
}
}
#[test]
fn the_built_in_arabic_face_draws_the_arabic_catalog() {
if !figcraft_text::CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic" && f.style == "Regular") {
eprintln!("built without craft-fonts' Noto Sans Arabic; skipping");
return;
}
let Some(face) = figcraft_text::face_with_char('ع', &["Noto Sans Arabic"]) else {
panic!("the supplied Noto Sans Arabic face must draw Arabic");
};
let ar = &LANGS[LANGS.iter().position(|l| l.code == "ar").unwrap()];
let letters: BTreeSet<char> = ar
.ui
.iter()
.chain(ar.commands)
.chain(ar.menu)
.flat_map(|(_, t)| forms(t))
.flat_map(str::chars)
.filter(|c| matches!(u32::from(*c), 0x0600..=0x06FF))
.collect();
assert!(letters.len() > 28);
let missing: String = letters.iter().filter(|c| !face.has_char(**c)).collect();
assert!(missing.is_empty(), "Arabic catalog glyphs missing from the embedded face: {missing:?}");
}
/// Release builds embed craft-fonts' Noto Sans Georgian (`theme::install_fonts`' built-in faces),
/// since Inter and egui's fonts have no Georgian: it draws every Georgian letter the catalog uses.
#[test]
+4 -3
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
@@ -91,7 +91,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
| 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) |
| 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 | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 |
| 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 not offered (font budget excludes Arabic); panels do not mirror | no | draft | review + panel mirroring (not re-estimated) |
| French | fr | 2,328 / 2,352 (99.0%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Indonesian | id | 2,292 / 2,352 (97.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
@@ -123,6 +123,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see
| Date | Change | Summary |
|---|---|---|
| 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed |
| 2026-10-24 | minor | Thai (`th`) draft catalog: `th.toml` translates every UI string (1,791), 589 command labels and the 31 menu wordings, machine-drafted and marked as a draft awaiting native review (9 newer command labels read English, as in the other drafts). The desktop UI now adds an installed Thai face (Noto Sans Thai, Leelawadee UI, Tahoma, Sukhumvit Set, Thonburi, Ayuthaya, Sarabun, Loma, Garuda or Waree) when a layer or page name or the UI language needs it, so Thai names no longer show as tofu squares. No plural forms for Thai. Grade and hours unchanged |
| 2026-10-24 | minor | Vietnamese (`vi`) draft catalog (#1026), assessed as a machine draft awaiting review like the others; string counts re-measured: 2,352 entries. |
| 2026-10-23 | minor | A missing translation fails no check: Spanish is no longer held to full coverage, and the tests of one language's wording, names and plural forms are gone (the plural rules and language-tag tests stay). `cargo xtask i18n-report`, run by `cargo xtask ci`, lists each language's missing UI strings, command labels and menu wordings. Grade unchanged |
+4 -3
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -18,7 +18,7 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr
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),
the entries added since each catalog was written, then the other key languages (Hindi,
Vietnamese, Arabic): 3–7 h per language plus native review.
Vietnamese): 3–7 h per language plus native review. Arabic now has a complete draft; native review and panel mirroring remain.
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
@@ -58,7 +58,7 @@ blockers.
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 |
| M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts | native reviews, Hindi, Vietnamese, Arabic (with mirrored panels), engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 |
| M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Arabic draft: 2,508 entries, native review and panel mirroring pending | native reviews (including the Arabic draft), Hindi, Vietnamese, Arabic panel mirroring, engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 |
| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update); widget fields and property menus (API 4) | a public catalogue, publisher accounts and signatures, the directory in the browser, widget hover and timers; Figma plugin compatibility is an owner decision | 6–22 |
| M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions; then a shared timer and voting sessions | widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 20–44 |
| M13 AI features (new, owner decision) | not started | local models | 30–60 |
@@ -75,6 +75,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) |
| 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged |
| 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged |
| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
File diff suppressed because one or more lines are too long
+18
View File
@@ -81,6 +81,24 @@ it (`crates/text/build.rs`); the commit releases pin doesn't yet (`docs/releasin
picker leaves Georgian out. A new CJK or other non-Latin language also needs its script in
`cjk::language`, or a built-in craft-fonts face.
## Arabic draft
`ar.toml` adds العربية as a draft: 1,857 UI entries, 620 command labels and 31 menu
wordings (2,508 entries, measured 2026-10-28). Contributed by @hussain-hz with OpenAI Codex;
not yet reviewed by a native speaker. Existing language catalogs are unchanged.
The catalog uses Modern Standard Arabic, logical Unicode order, Western digits, and Arabic
punctuation. Product names, command ids, keyboard glyphs and `{placeholders}` are preserved.
English plural entries give only `other`, with count-neutral wording such as `عدد الطبقات: {n}`.
This is deliberate: the current `Text::Plural` has no zero/two fields. It is not an implementation
of Arabic's six CLDR categories; wording remains valid for 0, 1, 2 and larger integer counts.
Arabic text uses the existing bidi/shaping and Arabic system-font fallback on desktop. Web builds
require an embedded Noto Sans Arabic face to offer Arabic. The current web font budget filters
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.
## Adding a language
1. Copy `crates/ui-egui/locales/es.toml` to `<code>.toml` (a BCP 47 code: `fr`, `pt-BR`, `ja`) and