fix(format): open large .fig files

A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are
expanded) didn't open: "message: too many values". Lifting that cap alone
made it import only its first 200 000 layers, and lifting that one took
41 GB of memory.

The global caps stay as they are. The .fig import gets budgets from the
size of its compressed canvas instead (counted up to the file's own size),
not from the message it unpacks to, since chunks inflate up to 512 MB:
- the kiwi value budget is one value per 2 bytes of the canvas, at least
  the old 20 million (kiwi is only read for .fig files);
- the layer cap is one layer per 32 bytes of the canvas, at least the REST
  import's 200 000 and at most 2 000 000. A small or crafted file can't
  expand its instances into millions of layers, however far it unpacks; the
  REST JSON import keeps 200 000.

Memory:
- The mapper no longer passes the document through `json!`, which serialises
  its arguments: the whole tree was deep-copied twice.
- Vector networks are built once per distinct network and handed to the REST
  importer beside the JSON (`vectorNetworkRef`), not written into it as JSON
  objects for every instance that shows them.
- Decoded kiwi objects are trimmed, and each import stage's input is dropped
  as soon as the next stage is built.

On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
This commit is contained in:
Andrii Volkov
2026-10-16 10:46:08 +03:00
co-authored by Claude Opus 5.5
parent 23bdebd973
commit 12d43b1f99
7 changed files with 251 additions and 64 deletions
+20 -4
View File
@@ -10,8 +10,12 @@ use std::fmt;
/// Deepest struct/message nesting decoded.
const MAX_DEPTH: usize = 64;
/// Most values decoded from one message (every scalar, array element and field counts).
const MAX_VALUES: usize = 20_000_000;
/// Most values decoded from one message (every scalar, array element and field counts): at least
/// [`MIN_VALUES`], and one per [`PACKED_BYTES_PER_VALUE`] bytes of the file's compressed canvas,
/// so a large real file decodes while a small one can't expand into a huge tree, however far its
/// chunks inflate.
const MIN_VALUES: usize = 20_000_000;
const PACKED_BYTES_PER_VALUE: usize = 2;
/// Most definitions in a schema, and fields in one definition.
const MAX_DEFS: usize = 10_000;
const MAX_FIELDS: usize = 10_000;
@@ -284,13 +288,23 @@ impl Schema {
self.defs.iter().position(|d| d.name == name)
}
/// Decode a message of definition `root`.
/// Decode a message of definition `root`, within [`MIN_VALUES`] values.
pub fn decode_message<'s>(&'s self, root: usize, data: &[u8]) -> Result<Kv<'s>, KiwiError> {
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget: MAX_VALUES };
self.decode_message_within(root, data, MIN_VALUES)
}
/// [`Self::decode_message`] within `budget` values (see [`value_budget`]).
pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result<Kv<'s>, KiwiError> {
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget };
d.def(root, 0)
}
}
/// Most values decoded from a file whose compressed canvas is `packed` bytes.
pub(crate) fn value_budget(packed: usize) -> usize {
MIN_VALUES.max(packed / PACKED_BYTES_PER_VALUE)
}
struct Decoder<'s, 'b> {
schema: &'s Schema,
r: Reader<'b>,
@@ -327,6 +341,8 @@ impl<'s> Decoder<'s, '_> {
loop {
let id = self.r.var_uint()?;
if id == 0 {
// Grown by doubling: trimmed, a large file's tree takes a good part less.
out.shrink_to_fit();
return Ok(Kv::Object(out));
}
let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?;
+87 -40
View File
@@ -6,9 +6,10 @@
//! are expanded here from the main component (overrides and derived layout matched by id path,
//! component property values applied) so the import looks as saved.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
use figcraft_doc::VectorNetwork;
use figcraft_doc::geom::{Affine, Point};
use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH};
use serde_json::{Map, Value, json};
@@ -18,8 +19,7 @@ use super::network;
type Obj = Map<String, Value>;
/// Most layers produced (instance expansion included) and deepest nesting followed.
const MAX_NODES: usize = crate::rest::MAX_NODES;
/// Deepest nesting followed.
const MAX_DEPTH: usize = 256;
/// Most path commands read from one geometry blob.
const MAX_COMMANDS: usize = 200_000;
@@ -28,11 +28,15 @@ const NO_STYLE: &str = "4294967295:4294967295";
/// Longest asset `key` or `version` kept for resolving references given as assets.
const MAX_ASSET_PART: usize = 256;
/// The REST API file JSON for message `msg`, with the warnings about what was skipped.
pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec<String>) {
let mut m = Mapper::new(msg);
/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the
/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]).
pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<String>, Vec<Option<VectorNetwork>>) {
let mut m = Mapper::new(msg, max_nodes);
let doc = m.document();
let mut out = json!({"name": name, "document": doc});
// Large values are moved in, never passed through `json!`, which serialises its arguments:
// a deep copy of the whole tree, briefly holding it twice.
let mut out = json!({"name": name});
out["document"] = doc;
if !m.styles.is_empty() {
out["styles"] = Value::Object(std::mem::take(&mut m.styles));
}
@@ -49,7 +53,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec<String>) {
if !styles.is_empty() {
out["styleDefinitions"] = Value::Array(styles);
}
(out, m.warnings)
(out, m.warnings, m.networks)
}
/// REST importer warnings reworded for a `.fig` import (`None`: drop it).
@@ -261,10 +265,18 @@ struct Mapper<'a> {
sets: Obj,
warnings: Vec<String>,
count: usize,
/// Most layers produced, instance expansion included (see [`super::layer_budget`]).
max_nodes: usize,
/// Vector networks, each built once: the layers that show one (every instance of a main
/// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's
/// networks took gigabytes.
networks: Vec<Option<VectorNetwork>>,
/// Index in `networks` by what a network is made from: its blob, its scale and its style table.
network_ids: HashMap<(usize, u64, u64, usize), usize>,
}
impl<'a> Mapper<'a> {
fn new(msg: &'a Kv<'a>) -> Self {
fn new(msg: &'a Kv<'a>, max_nodes: usize) -> Self {
let mut nodes = BTreeMap::new();
let mut kids: BTreeMap<String, Vec<(&'a str, String)>> = BTreeMap::new();
let mut prop_defs = BTreeMap::new();
@@ -302,7 +314,21 @@ impl<'a> Mapper<'a> {
let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or("");
assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id);
}
Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 }
Self {
nodes,
kids,
blobs,
prop_defs,
assets,
styles: Obj::new(),
components: Obj::new(),
sets: Obj::new(),
warnings: Vec::new(),
count: 0,
max_nodes,
networks: Vec::new(),
network_ids: HashMap::new(),
}
}
/// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`,
@@ -360,7 +386,9 @@ impl<'a> Mapper<'a> {
if out.is_empty() {
self.warn("the file has no pages");
}
json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out})
let mut doc = json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT"});
doc["children"] = Value::Array(out);
doc
}
fn page(&mut self, id: &str, n: &'a Kv<'a>) -> Value {
@@ -409,8 +437,9 @@ impl<'a> Mapper<'a> {
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
return None;
}
if self.count >= MAX_NODES {
self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped"));
if self.count >= self.max_nodes {
let max = self.max_nodes;
self.warn(format!("the file has more than {max} layers; the rest were skipped"));
return None;
}
let base = *self.nodes.get(id)?;
@@ -521,7 +550,7 @@ impl<'a> Mapper<'a> {
fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option<Value> {
let shape = v.str("shapeWithTextType").unwrap_or("SQUARE");
let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty();
if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= MAX_NODES {
if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes {
return None;
}
let mut s = Obj::new();
@@ -553,7 +582,7 @@ impl<'a> Mapper<'a> {
/// layer filling it, inset a little; centred unless it's a note.
fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option<Value> {
let chars = own_text(v)?;
if self.count >= MAX_NODES {
if self.count >= self.max_nodes {
return None;
}
self.count += 1;
@@ -587,7 +616,7 @@ impl<'a> Mapper<'a> {
if own_text(v).is_some() {
self.warn("connector labels were skipped");
}
if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) {
if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) {
return;
}
let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid);
@@ -879,7 +908,8 @@ impl<'a> Mapper<'a> {
/// False when there's none or it's damaged (the outlines stand in).
fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool {
let Some(vd) = v.get("vectorData") else { return false };
let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false };
let Some(blob_id) = num(vd, "vectorNetworkBlob").map(|i| i as usize) else { return false };
let Some(blob) = self.blobs.get(blob_id).copied() else { return false };
let Some(net) = network::decode(blob) else {
self.warn("damaged vector networks import as outlines");
return false;
@@ -899,28 +929,6 @@ impl<'a> Mapper<'a> {
size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n)
};
let (sx, sy) = (scale("x"), scale("y"));
let vertices: Vec<Value> = net
.vertices
.iter()
.map(|p| {
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
let st = style(p.style);
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
out["radius"] = json!(r);
}
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
Some("ANGLE") => out["mirror"] = json!("angle"),
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
Some("NONE") => out["mirror"] = json!("none"),
_ => {}
}
out
})
.collect();
let segments: Vec<Value> =
net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect();
let regions: Vec<Value> =
net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect();
if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) {
self.warn("filled areas of a vector with their own fill use the layer's fill");
}
@@ -946,7 +954,46 @@ impl<'a> Mapper<'a> {
o.insert("strokeCapStart".into(), json!(cap_name(first.unwrap_or(node))));
o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node))));
}
o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments, "regions": regions}));
// Every instance of a main shows the same network: it is written once and shared.
let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize);
let id = match self.network_ids.get(&key) {
Some(id) => *id,
None => {
let vertices: Vec<Value> = net
.vertices
.iter()
.map(|p| {
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
let st = style(p.style);
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
out["radius"] = json!(r);
}
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
Some("ANGLE") => out["mirror"] = json!("angle"),
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
Some("NONE") => out["mirror"] = json!("none"),
_ => {}
}
out
})
.collect();
let segments: Vec<Value> = net
.segments
.iter()
.map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]}))
.collect();
let regions: Vec<Value> =
net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect();
let mut entry = json!({});
entry["vertices"] = Value::Array(vertices);
entry["segments"] = Value::Array(segments);
entry["regions"] = Value::Array(regions);
self.networks.push(crate::rest::network(&entry));
self.network_ids.insert(key, self.networks.len() - 1);
self.networks.len() - 1
}
};
o.insert("vectorNetworkRef".into(), json!(id));
true
}
+25 -5
View File
@@ -39,6 +39,18 @@ const MAX_CHUNK: u64 = 512 * 1024 * 1024;
const MAX_IMAGE: u64 = 256 * 1024 * 1024;
/// Most chunks read from `canvas.fig`.
const MAX_CHUNKS: usize = 16;
/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its
/// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances
/// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the
/// budget follows what the file takes, not what it unpacks to: a small file can't claim a large
/// count, however it's built; a large one gets room.
const PACKED_BYTES_PER_LAYER: usize = 32;
const MAX_FIG_NODES: usize = 2_000_000;
/// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes.
pub(crate) fn layer_budget(packed: usize) -> usize {
(packed / PACKED_BYTES_PER_LAYER).clamp(crate::rest::MAX_NODES, MAX_FIG_NODES)
}
/// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas.
pub fn is_fig(bytes: &[u8]) -> bool {
@@ -57,14 +69,22 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option<Result<(Document, Vec<Stri
/// Build a document from a `.fig` file. Returns it with the warnings about what was skipped or
/// approximated. `name`: the document name when the file doesn't carry one.
pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), FormatError> {
let archive = Archive::open(bytes)?;
let (schema, message) = decode_canvas(&archive.canvas)?;
let mut archive = Archive::open(bytes)?;
// The budgets follow the compressed canvas, which the file itself bounds (its zip member may
// inflate too).
let packed = archive.canvas.len().min(bytes.len());
// Each stage's input is dropped as soon as the next one is built: in a large file every one
// of them takes gigabytes, and holding them all at once runs out of memory.
let (schema, message) = decode_canvas(&std::mem::take(&mut archive.canvas))?;
let root = schema.find("Message").ok_or_else(|| corrupt("the schema has no Message definition"))?;
let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?;
let msg = schema.decode_message_within(root, &message, kiwi::value_budget(packed)).map_err(|e| corrupt(e.to_string()))?;
let max_nodes = layer_budget(packed);
drop(message);
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
let (json, mut warnings) = map::to_rest(&msg, file_name);
let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
drop(msg);
let images = |r: &str| archive.image(r);
let (doc, more) = crate::rest::import(&json, &images)?;
let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
warnings.extend(more.into_iter().filter_map(map::adapt_warning));
Ok((doc, warnings))
}
+2 -2
View File
@@ -392,8 +392,8 @@ fn mangled_tail_files_never_panic() {
m[i] = r.next() as u8;
}
if let Ok(kv) = sc.decode_message(root, &m) {
let (json, _) = map::to_rest(&kv, "x");
let _ = crate::rest::import(&json, &crate::rest::no_images);
let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES);
let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES);
}
}
}
+57 -4
View File
@@ -588,7 +588,7 @@ fn mangled_kiwi_never_panics() {
}
for cut in (0..msg.len()).step_by(3) {
if let Ok(kv) = sc.decode_message(root, &msg[..cut]) {
let _ = map::to_rest(&kv, "x");
let _ = map::to_rest(&kv, "x", crate::rest::MAX_NODES);
}
}
for _ in 0..2000 {
@@ -598,8 +598,8 @@ fn mangled_kiwi_never_panics() {
m[i] = r.next() as u8;
}
if let Ok(kv) = sc.decode_message(root, &m) {
let (json, _) = map::to_rest(&kv, "x");
let _ = crate::rest::import(&json, &crate::rest::no_images);
let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES);
let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES);
}
let mut sbytes = schema_bytes.clone();
let i = (r.next() as usize) % sbytes.len();
@@ -621,6 +621,59 @@ fn mangled_kiwi_never_panics() {
}
}
#[test]
fn large_files_get_a_larger_value_budget() {
// Small files keep the fixed floor: a few bytes can't expand into a huge tree.
assert_eq!(kiwi::value_budget(0), 20_000_000);
assert_eq!(kiwi::value_budget(1 << 20), 20_000_000);
// A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds
// 30.5 million values was refused as "too many values".
assert!(kiwi::value_budget(83_491_232) > 30_514_899);
// Still bounded by the largest canvas read.
assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20);
}
#[test]
fn the_layer_cap_follows_the_file_size() {
// A small (or broken) file keeps the REST import's cap: instances expand into a layer per
// layer of their component, and a few bytes mustn't claim millions of layers.
assert_eq!(layer_budget(0), crate::rest::MAX_NODES);
assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES);
// A real 894 004-layer file (an 83.5 MB canvas) stopped at 200 000 and came in with 27 of
// its 30 pages empty.
assert!(layer_budget(83_491_232) > 894_004);
// No file gets more than the ceiling.
assert_eq!(layer_budget(MAX_CANVAS as usize), MAX_FIG_NODES);
}
#[test]
fn a_small_file_that_unpacks_large_gets_no_larger_budgets() {
// 40 MB of zeros in a blob pack into a few KB. Keyed to the unpacked message, they lifted the
// layer cap above the 231 231 layers its instances expand into; keyed to the file, the cap
// stays at the REST import's.
let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])];
nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(10.0, 10.0))]));
nodes.extend((10..1010).map(|i| node(i, Some((5, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))])));
nodes.extend((2000..2230).map(|i| node(i, Some((1, "!")), "INSTANCE", "I", vec![("symbolData", o(vec![("symbolID", g(5))]))])));
let blob = o(vec![("bytes", Kv::Bytes(vec![0; 40 << 20]))]);
let file = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![blob]))]));
assert!(file.len() < 1 << 20, "{} bytes", file.len());
let (doc, warnings) = import(&file, "x").unwrap();
assert!(warnings.iter().any(|w| w.contains("more than 200000 layers")), "{warnings:?}");
assert!(doc.node_count() <= crate::rest::MAX_NODES + 10);
}
#[test]
fn the_mapper_stops_at_the_layer_cap_it_is_given() {
let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])];
nodes.extend((2..12).map(|i| node(i, Some((1, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))])));
let msg = o(vec![("nodeChanges", arr(nodes))]);
let (_, warnings, _) = map::to_rest(&msg, "x", 5);
assert!(warnings.iter().any(|w| w.contains("more than 5 layers")), "{warnings:?}");
let (_, warnings, _) = map::to_rest(&msg, "x", crate::rest::MAX_NODES);
assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}");
}
#[test]
fn hostile_input_is_refused() {
// A message that contains itself, nested past the depth cap.
@@ -916,7 +969,7 @@ fn dump_samples() {
std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap();
let (rest, _) = map::to_rest(&msg, &stem);
let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len()));
std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap();
}
}
+45 -9
View File
@@ -49,7 +49,7 @@ use crate::FormatError;
use props::{MAX_COORD, MAX_SIZE, clip_str, flag, list, num, text};
pub use variables::export as export_variables;
/// Most layers imported from one file.
/// Most layers imported from one file. The `.fig` import sets its own, from the file's size.
pub const MAX_NODES: usize = 200_000;
/// Deepest layer nesting followed.
const MAX_DEPTH: usize = 256;
@@ -138,6 +138,28 @@ pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec<String>
/// in the file JSON (a `meta` with `variableCollections`, or `variableCollections` and
/// `variables` at the top level) are read too; the first definition of an id wins.
pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) -> Result<(Document, Vec<String>), FormatError> {
import_parts(v, variables, images, Vec::new(), MAX_NODES)
}
/// [`import`] for the `.fig` import's JSON, whose vectors name their networks by
/// `vectorNetworkRef`, an index in `networks` (built once each, and far smaller than as JSON).
/// `max_nodes`: the layer cap, set by the `.fig` import in place of [`MAX_NODES`].
pub(crate) fn import_with_networks(
v: &Value,
images: ImageResolver,
networks: Vec<Option<VectorNetwork>>,
max_nodes: usize,
) -> Result<(Document, Vec<String>), FormatError> {
import_parts(v, None, images, networks, max_nodes)
}
fn import_parts(
v: &Value,
variables: Option<&Value>,
images: ImageResolver,
networks: Vec<Option<VectorNetwork>>,
max_nodes: usize,
) -> Result<(Document, Vec<String>), FormatError> {
let entries: Vec<&Value> = if is_document(v) { vec![v] } else { nodes_entries(v).collect() };
if entries.is_empty() {
return Err(FormatError::Corrupt("not a REST API file JSON (no `document`)".into()));
@@ -157,6 +179,8 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver)
cx.warn("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored");
}
cx.style_definitions(v);
cx.networks = networks;
cx.max_nodes = max_nodes;
for e in entries {
let Some(root) = e.get("document") else { continue };
match text(root, "type") {
@@ -309,6 +333,10 @@ pub(crate) struct Ctx<'a> {
/// Imported variables by id, and the mode ids of imported collections (default first).
var_kinds: BTreeMap<String, VariableType>,
var_modes: BTreeMap<String, Vec<String>>,
/// Vector networks built by the `.fig` import, named by layers' `vectorNetworkRef`.
networks: Vec<Option<VectorNetwork>>,
/// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap.
max_nodes: usize,
}
impl<'a> Ctx<'a> {
@@ -332,6 +360,8 @@ impl<'a> Ctx<'a> {
overlay_meta: BTreeMap::new(),
var_kinds: BTreeMap::new(),
var_modes: BTreeMap::new(),
networks: Vec::new(),
max_nodes: MAX_NODES,
}
}
@@ -387,8 +417,9 @@ impl<'a> Ctx<'a> {
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
return;
}
if self.count >= MAX_NODES {
self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped"));
if self.count >= self.max_nodes {
let max = self.max_nodes;
self.warn(format!("the file has more than {max} layers; the rest were skipped"));
return;
}
let Some(ty) = text(j, "type") else {
@@ -586,12 +617,17 @@ impl<'a> Ctx<'a> {
}
}
/// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our
/// extension, written by the `.fig` import), else from its geometry. A stroke-only vector
/// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape).
/// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or
/// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its
/// geometry. A stroke-only vector uses the stroke outline,
/// painted with the stroke paints (exact look, editable as a shape).
fn vector(&mut self, n: &mut Node, j: &Value) {
if let Some(net) = j.get("vectorNetwork") {
match network(net) {
let net = match j.get("vectorNetworkRef") {
Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
None => j.get("vectorNetwork").map(network),
};
if let Some(net) = net {
match net {
Some(network) => {
n.kind = NodeKind::Vector { network };
return;
@@ -1110,7 +1146,7 @@ fn local(parent: Affine, world: Affine) -> Affine {
/// Finite path data within the coordinate cap.
/// A vector network in our own JSON shape, when it's within bounds and refers only to what it has.
fn network(v: &Value) -> Option<VectorNetwork> {
pub(crate) fn network(v: &Value) -> Option<VectorNetwork> {
use serde::Deserialize;
let mut net = VectorNetwork::deserialize(v).ok()?;
let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum();
+15
View File
@@ -707,3 +707,18 @@ fn layer_count_is_capped() {
assert_eq!(doc.node_count(), MAX_NODES + 1);
assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}");
}
#[test]
fn the_fig_import_sets_its_own_layer_cap() {
// The `.fig` import passes a cap from the file's size: above `MAX_NODES` every layer comes in.
let kids: Vec<Value> = (0..MAX_NODES + 5).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect();
let json = file(json!([page("Page", Value::Array(kids))]));
let (doc, warnings) = import_with_networks(&json, &no_images, Vec::new(), MAX_NODES + 10).unwrap();
assert_eq!(doc.node_count(), MAX_NODES + 6);
assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}");
// Below it, the import stops at the cap it was given.
let few: Vec<Value> = (0..10).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect();
let (doc, warnings) = import_with_networks(&file(json!([page("Page", Value::Array(few))])), &no_images, Vec::new(), 4).unwrap();
assert_eq!(doc.node_count(), 5);
assert!(warnings.iter().any(|w| w.contains("more than 4 layers")), "{warnings:?}");
}