fix(format): numbered lists from .fig files keep their start number (#686)

Figma stores where a numbered list starts counting on each text line
(listStartOffset; a list shown as "2." has offset 1). The import dropped
it, so every list counted from 1.

A paragraph's list settings now carry a start number (written only when
set, so older files open and save unchanged). Numbering counts on from
it where a list starts; items that go on counting ignore it. The .fig
import reads it, the .fig export writes it on a list's first line, and
plain paragraphs drop it. Toggling a list off ignores differing starts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RealtaNua
2026-10-17 00:27:34 +07:00
co-authored by Claude Opus 5.5
parent d1d8c3f652
commit 60fa813eb2
11 changed files with 204 additions and 25 deletions
+38 -8
View File
@@ -215,6 +215,10 @@ pub struct ParaList {
pub list: ListStyle,
/// Nesting level (0 = outermost; plain paragraphs are always 0).
pub indent: u8,
/// A numbered list that starts counting at this paragraph counts from `start + 1` (items
/// that go on counting ignore it; plain paragraphs are always 0).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub start: u32,
}
/// Character style. In runs, every field is an override; `None` means "inherit the base".
@@ -637,13 +641,13 @@ impl TextData {
/// Paragraph `p`'s list settings.
pub fn para_list(&self, p: usize) -> ParaList {
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 })
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() })
}
/// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries).
fn fill_paragraphs(&mut self) {
let n = self.paragraph_count();
let fill = ParaList { list: self.list, indent: 0 };
let fill = ParaList { list: self.list, ..ParaList::default() };
self.paragraphs.resize(n, fill);
}
@@ -657,17 +661,20 @@ impl TextData {
}
/// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain
/// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with
/// no nesting folds back into `list`.
/// paragraphs stay at level 0 with no start, levels stop at [`MAX_LIST_INDENT`]); one style
/// for all with no nesting or start folds back into `list`.
pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) {
let (a, b) = self.para_span(start, end);
self.fill_paragraphs();
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
f(pl);
pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) };
if pl.list == ListStyle::None {
*pl = ParaList::default();
}
pl.indent = pl.indent.min(MAX_LIST_INDENT);
}
if let Some(first) = self.paragraphs.first().copied()
&& first.indent == 0
&& first == (ParaList { list: first.list, ..ParaList::default() })
&& self.paragraphs.iter().all(|p| *p == first)
{
self.list = first.list;
@@ -675,11 +682,12 @@ impl TextData {
}
}
/// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ.
/// The list style and level shared by the paragraphs chars `start..end` touch (the first
/// one's settings), or None when they differ. Start numbers don't count.
pub fn common_para_list(&self, start: usize, end: usize) -> Option<ParaList> {
let (a, b) = self.para_span(start, end);
let first = self.para_list(a);
(a..=b).all(|p| self.para_list(p) == first).then_some(first)
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
}
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
@@ -782,4 +790,26 @@ mod case_tests {
assert_eq!(st.variations.keys().collect::<Vec<_>>(), ["wdth"]);
assert!(valid_axis_tag("wght") && !valid_axis_tag("weight"));
}
#[test]
fn list_starts_load_from_old_files_and_follow_edits() {
use ListStyle::{None as P, Numbered as N};
// Files written before start numbers load with none, and write the same bytes.
let old = r#"{"list":"numbered","indent":1}"#;
let pl: ParaList = serde_json::from_str(old).unwrap();
assert_eq!(pl, ParaList { list: N, indent: 1, start: 0 });
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
let mut t = TextData { characters: "Next".into(), ..Default::default() };
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start: 1 });
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start: 1 });
// Enter copies it to the new item; different starts don't make the list mixed.
t.replace_range(4, 4, "\nMore");
assert_eq!(t.para_list(1).start, 1);
t.set_para_lists(5, 5, |p| p.start = 0);
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
// A plain paragraph has none.
t.set_para_lists(0, 0, |p| p.list = P);
assert_eq!(t.para_list(0), ParaList::default());
}
}
+18 -3
View File
@@ -57,6 +57,21 @@ fn list_buttons_act_on_the_selected_paragraphs() {
assert_eq!(data(&s, t).list, P, "toggled off");
}
#[test]
fn a_list_with_a_start_number_toggles_off() {
// An imported list that starts at 2 has the start on its first item only; it is still one
// numbered list, so the button turns it back to plain (#686).
let mut s = Session::new();
let t = text(&mut s, "two\nthree\nfour");
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
td.paragraphs =
vec![ParaList { list: N, indent: 0, start: 1 }, ParaList { list: N, indent: 0, start: 0 }, ParaList { list: N, indent: 0, start: 0 }];
}
s.execute("text.numberList", &json!({"ids": [t]})).unwrap();
assert_eq!(lists(&s, t), [(P, 0); 3]);
assert_eq!(data(&s, t).para_list(0).start, 0, "a plain paragraph has no start");
}
#[test]
fn typed_list_starts() {
// Typing "- " or "* " at the start of a paragraph starts a bulleted list, "1. " a numbered one
@@ -117,7 +132,7 @@ fn list_keys_at_list_items() {
for (chars, before, caret, k, shift, after, want, why) in cases {
let mut s = Session::new();
let t = text(&mut s, chars);
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent }).collect();
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start: 0 }).collect();
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
td.paragraphs = ps;
}
@@ -141,7 +156,7 @@ fn paragraph_lists_follow_edits() {
// A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy
// the one they split from; joined paragraphs keep the first one's.
let mut t = TextData::new("ab\ncd\nef");
t.paragraphs = vec![ParaList { list: B, indent: 1 }, ParaList::default(), ParaList { list: N, indent: 0 }];
t.paragraphs = vec![ParaList { list: B, indent: 1, start: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start: 0 }];
let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::<Vec<_>>();
t.replace_range(1, 1, "x\ny");
assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it");
@@ -156,7 +171,7 @@ fn paragraph_lists_follow_edits() {
(seed >> 33) as usize % n.max(1)
};
let mut t = TextData::new("a\nb\nc\nd");
t.paragraphs = vec![ParaList { list: B, indent: 2 }; 4];
t.paragraphs = vec![ParaList { list: B, indent: 2, start: 0 }; 4];
for _ in 0..500 {
let n = t.char_count();
let (a, b) = (rnd(n + 2), rnd(n + 2));
+3 -1
View File
@@ -1574,12 +1574,14 @@ impl<'d> Exporter<'d> {
};
let listed = *l != ListStyle::None;
let first = listed && (i == 0 || lists.get(i - 1) != Some(l));
// Where a numbered list starts counting, less one.
let start = if first && *l == ListStyle::Numbered { td.para_list(i).start } else { 0 };
o(vec![
("lineType", e(ty)),
("styleId", int(0)),
("indentationLevel", int(i64::from(listed))),
("sourceDirectionality", e("AUTO")),
("listStartOffset", int(0)),
("listStartOffset", int(i64::from(start))),
("isFirstLineOfList", b(first)),
])
};
+1 -1
View File
@@ -680,7 +680,7 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
for r in &mut td.runs {
px(&mut r.style);
}
td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { list: td.para_list(p).list, indent: 0 }).collect();
td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { indent: 0, ..td.para_list(p) }).collect();
td.list = ListStyle::None;
}
NodeKind::Vector { network } => {
+8 -4
View File
@@ -827,10 +827,8 @@ impl<'a> Mapper<'a> {
o.insert("characterStyleOverrides".into(), json!(ids));
o.insert("styleOverrideTable".into(), Value::Object(table));
}
let lines: Vec<&str> = td
.get("lines")
.map(Kv::list)
.unwrap_or_default()
let raw = td.get("lines").map(Kv::list).unwrap_or_default();
let lines: Vec<&str> = raw
.iter()
.map(|l| match l.get("lineType").and_then(Kv::str) {
Some("ORDERED_LIST") => "ORDERED",
@@ -841,6 +839,12 @@ impl<'a> Mapper<'a> {
if lines.iter().any(|l| *l != "NONE") {
o.insert("lineTypes".into(), json!(lines));
}
// Where a numbered list starts counting, less one (not a REST field).
let starts: Vec<u32> =
raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect();
if starts.iter().any(|n| *n > 0) {
o.insert("listStartOffsets".into(), json!(starts));
}
}
/// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters.
+3
View File
@@ -873,6 +873,9 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() {
#[path = "tail_tests.rs"]
mod tail;
#[path = "text_list_tests.rs"]
mod text_list;
/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset).
pub(super) fn samples() -> Vec<std::path::PathBuf> {
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() };
+94
View File
@@ -0,0 +1,94 @@
//! Numbered lists that start past 1 (#686): each text line says where its list starts counting,
//! less one (`listStartOffset`). Fixtures built in code with the parent module's helpers.
use figcraft_doc::{ListStyle, NodeKind, ParaList};
use super::*;
const LIST_SCHEMA: &str = "
enum NodeType NONE DOCUMENT CANVAS TEXT
enum LineType PLAIN ORDERED_LIST UNORDERED_LIST
struct GUID uint sessionID, uint localID
struct Vector float x, float y
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
message ParentIndex GUID guid, string position
message TextLineData LineType lineType, int indentationLevel, int listStartOffset, bool isFirstLineOfList
message TextData string characters, TextLineData[] lines
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData
message Message NodeChange[] nodeChanges
";
/// A list line: type, start offset, first of its list.
fn line(ty: &'static str, offset: i64, first: bool) -> Kv<'static> {
let level = i64::from(ty != "PLAIN");
o(vec![("lineType", e(ty)), ("indentationLevel", Kv::Int(level)), ("listStartOffset", Kv::Int(offset)), ("isFirstLineOfList", Kv::Bool(first))])
}
fn text_node(id: u64, name: &str, chars: &str, lines: Vec<Kv<'static>>) -> Kv<'static> {
let pos = ["!", "\"", "#", "$", "%", "&"][id as usize % 6];
node(
id,
Some((1, pos)),
"TEXT",
name,
vec![
("size", xy(200.0, 60.0)),
("transform", at(0.0, 0.0)),
("fontSize", f(16.0)),
("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])),
],
)
}
fn starts(doc: &figcraft_doc::Document, name: &str) -> Vec<(ListStyle, u32)> {
let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") };
(0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start)).collect()
}
#[test]
fn numbered_lists_keep_their_start() {
use ListStyle::{Bulleted as B, None as P, Numbered as N};
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
text_node(2, "From 1", "Start here", vec![line("ORDERED_LIST", 0, true)]),
text_node(3, "From 2", "Next try this", vec![line("ORDERED_LIST", 1, true)]),
text_node(4, "First only", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 0, false), line("ORDERED_LIST", 0, false)]),
text_node(5, "Every line", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 3, false), line("ORDERED_LIST", 3, false)]),
text_node(6, "Mixed", "x\na\nb", vec![line("PLAIN", 0, false), line("ORDERED_LIST", 4, true), line("UNORDERED_LIST", 2, true)]),
text_node(7, "Junk", "a", vec![line("ORDERED_LIST", -5, true)]),
];
let sc = Schema::parse(LIST_SCHEMA).unwrap();
let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(starts(&doc, "From 1"), [(N, 0)]);
assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2.");
assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]);
assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]);
assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 2)]);
assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1");
}
#[test]
fn the_export_writes_where_a_list_starts() {
let mut doc = figcraft_doc::Document::new("Lists");
let page = doc.first_page().unwrap();
let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix");
td.paragraphs = vec![
ParaList { list: ListStyle::Numbered, indent: 0, start: 1 },
ParaList { list: ListStyle::Numbered, indent: 0, start: 1 },
ParaList::default(),
ParaList { list: ListStyle::Numbered, indent: 0, start: 5 },
];
doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap();
let (bytes, _) = super::super::export(&doc, None).unwrap();
let a = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&a.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("List")).unwrap();
let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap();
let offsets: Vec<f64> = lines.iter().map(|l| l.get("listStartOffset").and_then(Kv::f64).unwrap()).collect();
assert_eq!(offsets, [1.0, 0.0, 0.0, 5.0], "on the first line of each list only");
let (back, _) = import(&bytes, "x").unwrap();
assert_eq!(starts(&back, "List"), [(ListStyle::Numbered, 1), (ListStyle::Numbered, 0), (ListStyle::None, 0), (ListStyle::Numbered, 5)]);
}
+4 -1
View File
@@ -210,10 +210,12 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec<StyleRun> {
out
}
/// Each paragraph's list type and nesting level from `lineTypes` and `lineIndentations`.
/// Each paragraph's list type, nesting level and start number from `lineTypes`,
/// `lineIndentations` and (from `.fig` files) `listStartOffsets`.
fn lists(v: &Value, td: &mut TextData) {
let types = list(v, "lineTypes");
let indents = list(v, "lineIndentations");
let starts = list(v, "listStartOffsets");
td.paragraphs = types
.iter()
.enumerate()
@@ -224,6 +226,7 @@ fn lists(v: &Value, td: &mut TextData) {
_ => ListStyle::None,
},
indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8),
start: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32),
})
.collect();
// Clamp the levels and fold one style for all back into the layer's list style.
+4 -3
View File
@@ -827,8 +827,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
/// The markers of a text's paragraphs ("" for plain ones). Numbers count the items of one list at
/// one level, restarting after a plain paragraph, a shallower item or the other kind of list;
/// levels go 1. a. i. and • ◦ ▪, then round again.
/// one level, restarting after a plain paragraph, a shallower item or the other kind of list,
/// from the first item's start number; levels go 1. a. i. and • ◦ ▪, then round again.
pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
const BULLETS: [&str; 3] = ["\u{2022}", "\u{25E6}", "\u{25AA}"];
let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1];
@@ -844,7 +844,8 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
*c = (ListStyle::None, 0);
}
let Some(c) = count.get_mut(d) else { return String::new() };
*c = if c.0 == pl.list { (pl.list, c.1 + 1) } else { (pl.list, 1) };
let first = usize::try_from(pl.start).unwrap_or(usize::MAX).saturating_add(1);
*c = if c.0 == pl.list { (pl.list, c.1.saturating_add(1)) } else { (pl.list, first) };
match (pl.list, d % 3) {
(ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)),
(ListStyle::Numbered, 2) => format!("{}.", roman(c.1)),
+26 -1
View File
@@ -43,7 +43,32 @@ fn starts(l: &TextLayout) -> Vec<(f64, Option<f64>)> {
}
fn pl(list: ListStyle, indent: u8) -> ParaList {
ParaList { list, indent }
ParaList { list, indent, start: 0 }
}
fn from(list: ListStyle, indent: u8, start: u32) -> ParaList {
ParaList { list, indent, start }
}
#[test]
fn numbered_lists_count_from_their_start() {
use ListStyle::{Bulleted as B, None as P, Numbered as N};
let cases: Vec<(Vec<ParaList>, Vec<&str>, &str)> = vec![
(vec![from(N, 0, 1)], vec!["2."], "a one-item list started at 2 (#686)"),
(vec![from(N, 0, 3), pl(N, 0), pl(N, 0)], vec!["4.", "5.", "6."], "the items after the first count on from it"),
(vec![from(N, 0, 3), from(N, 0, 3), from(N, 0, 3)], vec!["4.", "5.", "6."], "items that go on counting ignore their own start"),
(vec![from(N, 0, 1), pl(P, 0), from(N, 0, 1)], vec!["2.", "", "2."], "a list after a plain paragraph starts from its own start"),
(vec![pl(N, 0), from(N, 1, 1), pl(N, 1)], vec!["1.", "b.", "c."], "a nested level starts from its first item's start"),
(vec![from(B, 0, 4), pl(B, 0)], vec!["\u{2022}", "\u{2022}"], "bullets have no number"),
(vec![from(N, 0, 4_000), pl(N, 2)], vec!["4001.", "i."], "big starts"),
];
for (lists, want, why) in cases {
assert_eq!(list_markers(&lists), want, "{why}");
}
// The largest start never overflows, on 32-bit targets too.
let max = list_markers(&[from(N, 0, u32::MAX), pl(N, 0), pl(N, 0)]);
assert_eq!(max.len(), 3);
assert!(max.iter().all(|m| m.starts_with("429496729")), "{max:?}");
}
#[test]
+5 -3
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (`.fig` numbered list start numbers (#686); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -17,7 +17,7 @@ and prototype interactions are still unchecked there.
| Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours |
|---|---|---|---|---|---|
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs and list types (numbered lists keep the number they start at), vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — |
@@ -88,7 +88,8 @@ each.
layers sit in it (fill, absolute, min/max sizes); layout grids; export settings; text style
(font, with styles under their standard names such as "Semi Bold"; size; line height, a share of
the font size as a raw multiplier; letter spacing, case, decoration, links), alignment, auto resize,
truncation, paragraph spacing and indent, list types and styled runs with their fills (indexed
truncation, paragraph spacing and indent, list types (with the number a numbered list starts
at; not yet checked in other software) and styled runs with their fills (indexed
by UTF-16 unit); components (description, boolean, text and instance swap properties and the
layers bound to them), component sets, instances (their main, property values, what the instance
itself changes from its main, such as its size, name and paints, as an override addressed by the
@@ -162,6 +163,7 @@ each.
| Date | Change | Summary |
|---|---|---|
| 2026-10-16 | minor | `.fig` numbered lists keep the number they start at (#686): a list Figma shows as starting at 2 imported as 1. The import reads each line's start offset, a text keeps it per paragraph (older files open unchanged), numbering counts on from it, and the export writes it on a list's first line. Nested list levels from `.fig` files are still read as the first level. Numbers unchanged |
| 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. |
| 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged |
| 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged |