From 64423c58bc5ab7af44f76aeac9f4e05edae5a680 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 18:45:32 +0700 Subject: [PATCH] M3: .fig and REST imports keep Hug on auto layout and text (#732) An auto layout frame a .fig records no primary-axis sizing for hugs on that axis (auto layout sized it by its content before that axis could be fixed) instead of turning fixed; an instance that records no auto layout takes its main's; REST frames whose sizing modes are left out hug (the documented default); text hugs by its resize mode (auto width both axes, auto height its height) instead of coming in fixed. --- .../src/fig/auto_layout_sizing_tests.rs | 193 ++++++++++++++++++ crates/format/src/fig/export_tests.rs | 11 + crates/format/src/fig/map.rs | 16 +- crates/format/src/fig/mod.rs | 2 + crates/format/src/rest/props.rs | 11 +- docs/file-format-parity.md | 5 +- 6 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 crates/format/src/fig/auto_layout_sizing_tests.rs diff --git a/crates/format/src/fig/auto_layout_sizing_tests.rs b/crates/format/src/fig/auto_layout_sizing_tests.rs new file mode 100644 index 00000000..99730f70 --- /dev/null +++ b/crates/format/src/fig/auto_layout_sizing_tests.rs @@ -0,0 +1,193 @@ +//! How auto layout frames and text size on import (hug or fixed), on encoded `.fig` files built in +//! code and REST file JSON: the sizing a file records, an auto layout frame that records none, and +//! text that hugs by its resize mode. + +use figcraft_doc::{Document, Sizing}; +use serde_json::{Value, json}; + +use super::kiwi::{Kv, Schema}; + +const SCHEMA: &str = " +enum NodeType NONE DOCUMENT CANVAS FRAME TEXT SYMBOL INSTANCE +enum StackMode NONE HORIZONTAL VERTICAL +enum StackSize FIXED RESIZE_TO_FIT RESIZE_TO_FIT_WITH_IMPLICIT_SIZE +enum TextAutoResize NONE WIDTH_AND_HEIGHT HEIGHT +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 TextData string characters +message SymbolData GUID symbolID +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, StackMode stackMode, StackSize stackPrimarySizing, StackSize stackCounterSizing, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData +message Message NodeChange[] nodeChanges +"; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn o(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn g(id: u64) -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))]) +} + +fn xy(x: f64, y: f64) -> Kv<'static> { + o(vec![("x", Kv::Float(x as f32)), ("y", Kv::Float(y as f32))]) +} + +fn at(x: f64, y: f64) -> Kv<'static> { + let f = |v: f64| Kv::Float(v as f32); + o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))]) +} + +fn node(id: u64, parent: u64, pos: &str, ty: &'static str, name: &str, mut more: Fields) -> Kv<'static> { + let mut v = vec![("guid", g(id)), ("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(pos.into()))]))]; + v.extend([("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]); + v.append(&mut more); + o(v) +} + +/// An auto layout frame's fields: its direction and the sizing it records (`None`: none). +fn stack(mode: &'static str, primary: Option<&'static str>, counter: Option<&'static str>) -> Fields { + let mut f = vec![("stackMode", Kv::Enum(mode))]; + f.extend(primary.map(|p| ("stackPrimarySizing", Kv::Enum(p)))); + f.extend(counter.map(|c| ("stackCounterSizing", Kv::Enum(c)))); + f +} + +/// A text layer " text" in `parent`, with resize mode `resize` (`None`: none recorded). +fn label(id: u64, parent: u64, name: &str, resize: Option<&'static str>) -> Kv<'static> { + let mut f = vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 0.0)), ("fontSize", Kv::Float(12.0))]; + f.push(("textData", o(vec![("characters", Kv::Str(name.into()))]))); + f.extend(resize.map(|r| ("textAutoResize", Kv::Enum(r)))); + node(id, parent, "!", "TEXT", &format!("{name} text"), f) +} + +/// A component at `x` holding a text layer, with `fields` of its own. +fn component(id: u64, x: f64, name: &str, fields: Fields, resize: Option<&'static str>) -> Vec> { + let mut f = vec![("size", xy(40.0, 16.0)), ("transform", at(x, 0.0))]; + f.extend(fields); + vec![node(id, 1, &format!("{id}"), "SYMBOL", name, f), label(id + 1, id, name, resize)] +} + +fn import(nodes: Vec>) -> Document { + let mut all = vec![ + o(vec![ + ("guid", o(vec![("sessionID", Kv::Uint(0)), ("localID", Kv::Uint(0))])), + ("type", Kv::Enum("DOCUMENT")), + ("name", Kv::Str("D".into())), + ]), + o(vec![ + ("guid", g(1)), + ("parentIndex", o(vec![("guid", o(vec![("sessionID", Kv::Uint(0)), ("localID", Kv::Uint(0))])), ("position", Kv::Str("!".into()))])), + ("type", Kv::Enum("CANVAS")), + ("name", Kv::Str("Page".into())), + ]), + ]; + all.extend(nodes); + let schema = Schema::parse(SCHEMA).unwrap(); + let bytes = super::tests::canvas(&schema, &o(vec![("nodeChanges", Kv::Array(all))])); + super::import(&bytes, "sizing").unwrap().0 +} + +fn sizing(doc: &Document, name: &str) -> (Sizing, Sizing) { + let n = doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}")); + (n.child_layout.sizing_h, n.child_layout.sizing_v) +} + +#[test] +fn auto_layout_frames_hug_as_recorded_and_by_default_on_their_primary_axis() { + use Sizing::{Fixed, Hug}; + let fit = Some("RESIZE_TO_FIT"); + // (component, its auto layout fields, expected (horizontal, vertical), derivation) + let rows: Vec<(&str, Fields, (Sizing, Sizing), &str)> = vec![ + ("Button", stack("HORIZONTAL", fit, fit), (Hug, Hug), "both axes hug, as recorded"), + ("Implicit", stack("HORIZONTAL", Some("RESIZE_TO_FIT_WITH_IMPLICIT_SIZE"), fit), (Hug, Hug), "hugging with an implicit size"), + ("Fixed", stack("HORIZONTAL", Some("FIXED"), Some("FIXED")), (Fixed, Fixed), "both axes fixed, as recorded"), + ("Legacy", stack("HORIZONTAL", None, fit), (Hug, Hug), "no primary sizing: the primary axis sizes by its content"), + ("Legacy column", stack("VERTICAL", None, None), (Fixed, Hug), "no sizing: the primary (vertical) axis hugs, the counter axis is fixed"), + ]; + let mut nodes = Vec::new(); + for (k, (name, fields, _, _)) in rows.iter().enumerate() { + nodes.extend(component(10 * (k as u64 + 1), 60.0 * k as f64, name, fields.clone(), Some("WIDTH_AND_HEIGHT"))); + } + let doc = import(nodes); + for (name, _, want, why) in rows { + assert_eq!(sizing(&doc, name), want, "{name}: {why}"); + } +} + +#[test] +fn text_hugs_by_its_resize_mode() { + use Sizing::{Fixed, Hug}; + let fit = Some("RESIZE_TO_FIT"); + let mut nodes = Vec::new(); + for (k, (name, resize)) in + [("Auto width", Some("WIDTH_AND_HEIGHT")), ("Auto height", Some("HEIGHT")), ("Fixed box", Some("NONE")), ("Unset", None)] + .into_iter() + .enumerate() + { + nodes.extend(component(10 * (k as u64 + 1), 60.0 * k as f64, name, stack("HORIZONTAL", fit, fit), resize)); + } + // An instance of the auto width one: it hugs, and so does its copy of the text. + nodes.push(node( + 90, + 1, + "~", + "INSTANCE", + "Auto width instance", + [vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(10))]))], stack("HORIZONTAL", fit, fit)] + .concat(), + )); + // One that records nothing of its auto layout: it has its main's. + nodes.push(node( + 91, + 1, + "~~", + "INSTANCE", + "Bare instance", + vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 150.0)), ("symbolData", o(vec![("symbolID", g(10))]))], + )); + let doc = import(nodes); + for (name, want) in + [("Auto width text", (Hug, Hug)), ("Auto height text", (Fixed, Hug)), ("Fixed box text", (Fixed, Fixed)), ("Unset text", (Fixed, Fixed))] + { + assert_eq!(sizing(&doc, name), want, "{name}"); + } + let inst = doc.nodes().find(|n| n.name == "Auto width instance").unwrap(); + assert_eq!((inst.child_layout.sizing_h, inst.child_layout.sizing_v), (Hug, Hug)); + let copy = inst.children.first().and_then(|c| doc.node(*c)).unwrap(); + assert_eq!((copy.child_layout.sizing_h, copy.child_layout.sizing_v), (Hug, Hug)); + let bare = doc.nodes().find(|n| n.name == "Bare instance").unwrap(); + assert_eq!(bare.layout.as_ref().map(|l| l.mode), Some(figcraft_doc::LayoutMode::Horizontal)); + assert_eq!((bare.child_layout.sizing_h, bare.child_layout.sizing_v), (Hug, Hug)); +} + +#[test] +fn rest_files_read_left_out_sizing_modes_as_hug_and_text_by_its_resize_mode() { + use Sizing::{Fixed, Hug}; + let at = json!([[1, 0, 0], [0, 1, 0]]); + let text = |id: &str, name: &str, more: Value| { + let mut t = json!({"id": id, "name": name, "type": "TEXT", "relativeTransform": at, "size": {"x": 40, "y": 16}, "characters": name, + "style": {"fontFamily": "Inter", "fontSize": 12, "textAutoResize": "WIDTH_AND_HEIGHT"}}); + t.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); + t + }; + let frame = |id: &str, name: &str, more: Value, kids: Value| { + let mut f = json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at, "size": {"x": 40, "y": 16}, + "layoutMode": "HORIZONTAL", "children": kids}); + f.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); + f + }; + let file = json!({"name": "Sizing", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [ + frame("1:1", "Defaults", json!({}), json!([text("1:2", "Label", json!({}))])), + frame("2:1", "Fixed", json!({"primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED"}), + json!([text("2:2", "Fixed label", json!({"layoutSizingHorizontal": "FIXED"}))])), + ]}]}}); + let (doc, _) = crate::rest::import(&file, &crate::rest::no_images).unwrap(); + // The REST API leaves out the default sizing modes (AUTO); explicit per-axis sizing wins. + for (name, want) in [("Defaults", (Hug, Hug)), ("Label", (Hug, Hug)), ("Fixed", (Fixed, Fixed)), ("Fixed label", (Fixed, Hug))] { + assert_eq!(sizing(&doc, name), want, "{name}"); + } +} diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 53cfaf91..56e9fcbf 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -595,6 +595,17 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) { n.source = None; n.overrides.clear(); n.constrain_proportions = false; + // A text's resize mode is its sizing (the editor keeps them together; generated documents + // don't): auto width hugs both axes, auto height its height. + if let NodeKind::Text(t) = &n.kind { + let cl = &mut n.child_layout; + let hug = |s: &mut Sizing| *s = if *s == Sizing::Fixed { Sizing::Hug } else { *s }; + match t.auto_resize { + TextAutoResize::WidthAndHeight => [&mut cl.sizing_h, &mut cl.sizing_v].into_iter().for_each(hug), + TextAutoResize::Height => hug(&mut cl.sizing_v), + _ => {} + } + } let fold = |ps: &mut Vec| { for p in ps.iter_mut() { if let PaintKind::Solid { color } = &mut p.kind { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 230faa7d..25540ff6 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -924,7 +924,16 @@ impl<'a> Mapper<'a> { } else if ty == "INSTANCE" { if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) { let main = self.nodes.get(&sym).map(|m| View::of(m)); - let fit = Fit::frame(main.and_then(|m| m.size()), view.size()); + let fit = Fit::frame(main.as_ref().and_then(View::size), view.size()); + // An instance that records no auto layout of its own has its main's. + let view = match main { + Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => { + let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) }; + self.auto_layout(&mut o, &layered); + layered + } + _ => view.clone(), + }; for c in self.children(&sym) { // The main's branch can live outside the cached context being mapped. kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None)); @@ -1407,7 +1416,10 @@ impl<'a> Mapper<'a> { }; o.insert("counterAxisAlignItems".into(), json!(cross)); let sizing = |k: &str| if v.str(k).is_some_and(|s| s.starts_with("RESIZE_TO_FIT")) { "AUTO" } else { "FIXED" }; - o.insert("primaryAxisSizingMode".into(), json!(sizing("stackPrimarySizing"))); + // A flow that records no sizing for its primary axis sizes it by its content: auto layout + // did before that axis could be fixed. + let primary = if v.get("stackPrimarySizing").is_none() && mode != "GRID" { "AUTO" } else { sizing("stackPrimarySizing") }; + o.insert("primaryAxisSizingMode".into(), json!(primary)); o.insert("counterAxisSizingMode".into(), json!(sizing("stackCounterSizing"))); if let Some(b) = v.flag("bordersTakeSpace") { o.insert("strokesIncludedInLayout".into(), json!(b)); diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 97f35f63..fc9293d9 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -13,6 +13,8 @@ //! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped //! with a warning; malformed input never panics. +#[cfg(test)] +mod auto_layout_sizing_tests; #[cfg(test)] mod connector_label_tests; mod export; diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 49c9d372..1003682b 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -484,8 +484,17 @@ pub(super) fn child_layout(v: &Value, own: Option, parent_mode: Opti let mut cl = ChildLayout::default(); let horizontal_main = |m: LayoutMode| m != LayoutMode::Vertical; // Legacy fields first, the explicit per-axis sizing wins. + // A text's resize mode is its sizing: auto width hugs both axes, auto height its height. + if text(v, "type") == Some("TEXT") && v.get("style").and_then(|s| text(s, "textTruncation")) != Some("ENDING") { + match v.get("style").and_then(|s| text(s, "textAutoResize")) { + Some("WIDTH_AND_HEIGHT") => (cl.sizing_h, cl.sizing_v) = (Sizing::Hug, Sizing::Hug), + Some("HEIGHT") => cl.sizing_v = Sizing::Hug, + _ => {} + } + } if let Some(m) = own { - let hug = |k: &str| text(v, k) == Some("AUTO"); + // Both default to AUTO (hug) when the file leaves them out. + let hug = |k: &str| text(v, k) != Some("FIXED"); let (main, cross) = (hug("primaryAxisSizingMode"), hug("counterAxisSizingMode")); let (h, w) = if horizontal_main(m) { (main, cross) } else { (cross, main) }; if h { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e6591853..250d796a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,9 +17,9 @@ 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; the size and resizing their layers override stay overrides), 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, 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; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), 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 (sizes and resizing included), 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` | — | +| 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 (sizing modes left out read as hug, as documented), constraints, grids, components, sets, instances with overrides (sizes and resizing included), 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`) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | @@ -162,6 +162,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged | | 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). 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 |