diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 8343a4ff..b475a9cb 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -37,6 +37,14 @@ use super::kiwi::{Kv, Schema, encode}; use super::network; use crate::FormatError; +mod assets; +mod common; +mod component; +mod layer; +mod paint; +mod prototype; +mod shape; + /// Most layers written, and deepest nesting followed (the import's limits). const MAX_NODES: usize = crate::rest::MAX_NODES; const MAX_DEPTH: usize = 256; @@ -598,2062 +606,10 @@ impl<'d> Exporter<'d> { ("blobs", arr(blobs.into_iter().map(|(_, bytes)| o(vec![("bytes", Kv::Bytes(bytes))])).collect())), ]) } - - /// GUIDs for every layer (pages first, then depth-first), component property definitions, - /// flow starting points and overlay settings, before anything refers to them. - fn assign(&mut self) { - let doc = self.doc; - let mut stack: Vec = doc.pages.iter().rev().copied().collect(); - while let Some(id) = stack.pop() { - let Some(n) = doc.node(id) else { continue }; - if self.ids.len() >= MAX_NODES { - self.warn("the document has more layers than a .fig file is read with; the rest were left out"); - break; - } - let local = u32::try_from(self.ids.len() + 1).unwrap_or(u32::MAX); - self.ids.insert(id, local); - // An instance's layers aren't written (its main says them), so nothing may refer to them. - let mirrored = matches!(&n.kind, NodeKind::Instance { component, .. } if doc.node(*component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))); - if !mirrored { - stack.extend(n.children.iter().rev()); - } - match &n.kind { - NodeKind::Component { props, .. } => { - let set = self.set_of(id); - let owner = set.unwrap_or(id); - for (name, d) in props { - if let Some(ty) = prop_type(d) { - let key = (owner, name.clone(), ty); - if !self.defs.contains_key(&key) { - let def = self.next_aux(); - self.defs.insert(key, def); - } - if set.is_some() { - let child = self.next_aux(); - self.child_defs.insert((id, name.clone(), ty), child); - } - } - } - if let Some(set) = set { - for (prop, _) in variant_pairs(&n.name) { - let key = (set, prop, "VARIANT"); - if !self.defs.contains_key(&key) { - let def = self.next_aux(); - self.defs.insert(key, def); - } - } - } - } - NodeKind::Page { flows, .. } => { - for (i, f) in flows.iter().enumerate() { - self.starts.entry(f.start).or_insert((i, f.name.clone())); - } - } - _ => {} - } - for a in n.interactions.iter().flat_map(|it| figcraft_doc::proto::walk(&it.actions)) { - if let Action::OpenOverlay { dest, position, close_on_outside, background, anchor, .. } = a { - let position = if !anchor.is_screen() && *position != OverlayPosition::Manual { OverlayPosition::Manual } else { *position }; - let o = Overlay { position, close_on_outside: *close_on_outside, background: *background }; - match self.overlays.get(dest) { - Some(old) if (old.position, old.close_on_outside, old.background) != (o.position, o.close_on_outside, o.background) => { - self.warn("overlays opened with different settings keep the first one's"); - } - Some(_) => {} - None => { - self.overlays.insert(*dest, o); - } - } - } - } - } - // The page that holds styles and variables takes the next id, then their nodes. - let mut next = u32::try_from(self.ids.len() + 1).unwrap_or(u32::MAX); - let mut node = || { - next = next.saturating_add(1); - next - }; - for st in &doc.styles { - self.style_ids.entry(st.id.as_str()).or_insert_with(&mut node); - } - for c in &doc.variables.collections { - self.set_ids.entry(c.id.as_str()).or_insert_with(&mut node); - } - for v in &doc.variables.variables { - if self.set_ids.contains_key(v.collection.as_str()) { - self.var_ids.entry(v.id.as_str()).or_insert_with(&mut node); - } - } - for c in &doc.variables.collections { - for m in &c.modes { - if !self.mode_ids.contains_key(&(c.id.as_str(), m.id.as_str())) { - let id = self.next_aux(); - self.mode_ids.insert((c.id.as_str(), m.id.as_str()), id); - } - } - } - } - - /// The hidden page after the `pages` real ones that holds the shared styles, the variable - /// collections and the variables, each a node of its own. - fn assets(&mut self, pages: usize) { - let doc = self.doc; - if self.style_ids.is_empty() && self.set_ids.is_empty() { - return; - } - let page = u32::try_from(self.ids.len() + 1).unwrap_or(u32::MAX); - let mut f: Fields = vec![ - ("guid", guid(LAYERS, page)), - ("phase", e("CREATED")), - ("parentIndex", o(vec![("guid", guid(0, 0)), ("position", s(&position(pages)))])), - ("type", e("CANVAS")), - ("name", s("Internal Only Canvas")), - ]; - f.extend(root_fields().into_iter().map(|(k, v)| if k == "visible" { (k, b(false)) } else { (k, v) })); - f.push(("internalOnly", b(true))); - self.nodes.push(o(f)); - let mut index = 0usize; - let mut head = |local: u32, ty: &'static str, name: &str| -> Fields { - index += 1; - vec![ - ("guid", guid(LAYERS, local)), - ("phase", e("CREATED")), - ("parentIndex", o(vec![("guid", guid(LAYERS, page)), ("position", s(&position(index)))])), - ("type", e(ty)), - ("name", s(name)), - ("sortPosition", s(&position(index))), - ] - }; - for st in &doc.styles { - let Some(&local) = self.style_ids.get(st.id.as_str()) else { continue }; - // A sample layer that shows the style: a square for paints and effects, a frame for - // grids, two letters for text. - let (ty, kind) = match &st.kind { - StyleKind::Paint { .. } => ("ROUNDED_RECTANGLE", "FILL"), - StyleKind::Effect { .. } => ("ROUNDED_RECTANGLE", "EFFECT"), - StyleKind::Grid { .. } => ("FRAME", "GRID"), - StyleKind::Text { .. } => ("TEXT", "TEXT"), - }; - let mut f = head(local, ty, &st.name); - f.extend([ - ("styleType", e(kind)), - ("visible", b(true)), - ("opacity", fl(1.0)), - ("size", xy(100.0, 100.0)), - ("transform", matrix(Affine::IDENTITY)), - ("strokeWeight", fl(0.0)), - ("strokeAlign", e("INSIDE")), - ("strokeJoin", e("MITER")), - ]); - if !st.description.is_empty() { - f.push(("description", s(&st.description))); - } - // The style's variable bindings ride on its sample layer, as a layer's do. - let none = if st.bound.is_empty() { - Bound::default() - } else { - let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0); - if let StyleKind::Text { style } = &st.kind { - sample.kind = NodeKind::Text(TextData { style: style.as_text_style(), ..TextData::new("Ag") }); - } - sample.bound.clone_from(&st.bound); - self.bound(&sample) - }; - let entries = none.entries.clone(); - if !entries.is_empty() { - f.push(("variableConsumptionMap", o(vec![("entries", arr(entries))]))); - } - match &st.kind { - StyleKind::Paint { paints } => f.push(("fillPaints", self.paints(paints, true, &none))), - StyleKind::Effect { effects } => { - let list = effects.iter().enumerate().map(|(i, x)| self.effect(x, i, &none)).collect(); - f.extend([("fillPaints", arr(Vec::new())), ("effects", arr(list))]); - } - StyleKind::Grid { grids } => { - let list = grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &none)).collect(); - f.extend([("fillPaints", arr(Vec::new())), ("layoutGrids", arr(list))]); - } - StyleKind::Text { style } => { - let td = TextData { style: style.as_text_style(), ..TextData::new("Ag") }; - let fills = [Paint::solid(Color::BLACK)]; - f.push(("fillPaints", self.paints(style.fills.as_deref().unwrap_or(&fills), true, &none))); - self.text(&td, &fills, &mut f); - } - } - self.nodes.push(o(f)); - } - for c in &doc.variables.collections { - let Some(&local) = self.set_ids.get(c.id.as_str()) else { continue }; - let mut f = head(local, "VARIABLE_SET", &c.name); - let modes = c.modes.iter().enumerate().filter_map(|(i, m)| { - let id = self.mode_ids.get(&(c.id.as_str(), m.id.as_str()))?; - Some(o(vec![("id", guid(AUX, *id)), ("name", s(&m.name)), ("sortPosition", s(&position(i)))])) - }); - f.push(("variableSetModes", arr(modes.collect()))); - self.nodes.push(o(f)); - if c.extends.is_some() { - self.warn("extended variable collections were written as collections of their own"); - } - } - for v in &doc.variables.variables { - let ids = (self.var_ids.get(v.id.as_str()), self.set_ids.get(v.collection.as_str())); - let (Some(&local), Some(&set), Some(c)) = (ids.0, ids.1, doc.variables.collections.iter().find(|c| c.id == v.collection)) else { - self.warn("variables whose collection isn't in the document were left out"); - continue; - }; - let resolved = resolved_type(v.kind); - let mut values = Vec::new(); - for m in &c.modes { - let (Some(mode), Some(val)) = (self.mode_ids.get(&(c.id.as_str(), m.id.as_str())), v.values.get(&m.id)) else { continue }; - let (ty, value) = match val { - VariableValue::Color(x) => ("COLOR", ("colorValue", color(*x))), - VariableValue::Number(x) => ("FLOAT", ("floatValue", fl(*x))), - // NULs can't be stored in a string. - VariableValue::String(x) => ("STRING", ("textValue", s(&x.replace('\0', "")))), - VariableValue::Boolean(x) => ("BOOLEAN", ("boolValue", b(*x))), - VariableValue::Alias(a) => match self.var_ids.get(a.as_str()) { - Some(t) => ("ALIAS", ("alias", o(vec![("guid", guid(LAYERS, *t))]))), - None => { - self.warn("variable aliases to variables that aren't in the document were left out"); - continue; - } - }, - }; - let data = o(vec![("value", o(vec![value])), ("dataType", e(ty)), ("resolvedDataType", e(resolved))]); - values.push(o(vec![("modeID", guid(AUX, *mode)), ("variableData", data)])); - } - let mut f = head(local, "VARIABLE", &v.name); - f.extend([ - ("variableSetID", o(vec![("guid", guid(LAYERS, set))])), - ("variableResolvedType", e(resolved)), - ("variableDataValues", o(vec![("entries", arr(values))])), - ]); - if !v.description.is_empty() { - f.push(("description", s(&v.description))); - } - if let Some(scopes) = &v.scopes { - let known: Vec = scopes.iter().filter_map(|sc| scope_name(sc)).map(e).collect(); - if known.len() < scopes.len() { - self.warn("variable scopes the format has no name for were left out"); - } - f.push(("variableScopes", arr(known))); - } - if v.hidden_from_publishing || !v.code_syntax.is_empty() || v.extends.is_some() { - self.warn("variables' publishing, code syntax and inheritance settings were left out"); - } - self.nodes.push(o(f)); - } - } - - /// A reference to variable `var` (its id or name) as variable data. - fn alias(&mut self, var: &str) -> Option> { - let found = self.doc.variables.get(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?))); - let Some((kind, local)) = found else { - self.warn("bindings to variables that aren't in the document were written as their values"); - return None; - }; - Some(o(vec![ - ("value", o(vec![("alias", o(vec![("guid", guid(LAYERS, local))]))])), - ("dataType", e("ALIAS")), - ("resolvedDataType", e(resolved_type(kind))), - ])) - } - - /// Layer `n`'s variable bindings, sorted into the fields that carry them. What the format has - /// no field for stays as the value the layer already shows, with a warning. - fn bound(&mut self, n: &Node) -> Bound { - const BAKED: &str = "variables bound to properties the format has no field for were written as their values"; - let mut out = Bound::default(); - for (key, var) in &n.bound { - let parts: Vec<&str> = key.split('/').collect(); - let index = |i: &str| i.parse::().ok().filter(|_| i.bytes().all(|c| c.is_ascii_digit())); - let fields: &[&'static str] = match (parts.as_slice(), key.as_str()) { - (_, "fill" | "fills" | "fills/0" | "stroke") => { - if let Some(a) = self.alias(var) { - out.paints.insert((key != "stroke", 0), a); - } - continue; - } - ([list @ ("fills" | "strokes"), i, "color"], _) => { - if let (Some(i), Some(a)) = (index(i), self.alias(var)) { - out.paints.insert((*list == "fills", i), a); - } - continue; - } - ([list @ ("fills" | "strokes"), i, "stops", j, "color"], _) => { - if let (Some(i), Some(j), Some(a)) = (index(i), index(j), self.alias(var)) { - out.stops.insert((*list == "fills", i, j), a); - } - continue; - } - ([list @ ("effects" | "layoutGrids"), i, field], _) => { - let names: &[&'static str] = if *list == "effects" { - &["color", "radius", "spread", "offsetX", "offsetY", "startRadius"] - } else { - &["size", "count", "gutter", "offset"] - }; - match (index(i), names.iter().find(|f| *f == field)) { - (Some(i), Some(name)) => { - if let Some(a) = self.alias(var) { - if *list == "effects" { &mut out.effects } else { &mut out.grids }.insert((i, *name), a); - } - } - _ => self.warn(BAKED), - } - continue; - } - (_, "width") => &["WIDTH"], - (_, "height") => &["HEIGHT"], - (_, "gap") => &["STACK_SPACING"], - (_, "crossGap") => &["STACK_COUNTER_SPACING"], - (_, "paddingLeft") => &["STACK_PADDING_LEFT"], - (_, "paddingTop") => &["STACK_PADDING_TOP"], - (_, "paddingRight") => &["STACK_PADDING_RIGHT"], - (_, "paddingBottom") => &["STACK_PADDING_BOTTOM"], - (_, "paddingHorizontal") => &["STACK_PADDING_LEFT", "STACK_PADDING_RIGHT"], - (_, "paddingVertical") => &["STACK_PADDING_TOP", "STACK_PADDING_BOTTOM"], - (_, "padding") => &["STACK_PADDING_LEFT", "STACK_PADDING_TOP", "STACK_PADDING_RIGHT", "STACK_PADDING_BOTTOM"], - (_, "cornerRadius") => &[ - "CORNER_RADIUS", - "RECTANGLE_TOP_LEFT_CORNER_RADIUS", - "RECTANGLE_TOP_RIGHT_CORNER_RADIUS", - "RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS", - "RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS", - ], - (_, "topLeftRadius") => &["RECTANGLE_TOP_LEFT_CORNER_RADIUS"], - (_, "topRightRadius") => &["RECTANGLE_TOP_RIGHT_CORNER_RADIUS"], - (_, "bottomLeftRadius") => &["RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS"], - (_, "bottomRightRadius") => &["RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS"], - (_, "opacity") => &["OPACITY"], - (_, "visible") => &["VISIBLE"], - (_, "characters") => &["TEXT_DATA"], - (_, "strokeWeight") => &["STROKE_WEIGHT"], - (_, "strokeTopWeight") => &["BORDER_TOP_WEIGHT"], - (_, "strokeRightWeight") => &["BORDER_RIGHT_WEIGHT"], - (_, "strokeBottomWeight") => &["BORDER_BOTTOM_WEIGHT"], - (_, "strokeLeftWeight") => &["BORDER_LEFT_WEIGHT"], - (_, "minWidth") => &["MIN_WIDTH"], - (_, "maxWidth") => &["MAX_WIDTH"], - (_, "minHeight") => &["MIN_HEIGHT"], - (_, "maxHeight") => &["MAX_HEIGHT"], - (_, "fontSize") => &["FONT_SIZE"], - (_, "fontFamily") => &["FONT_FAMILY"], - (_, "fontStyle") => &["FONT_STYLE"], - (_, "letterSpacing") => &["LETTER_SPACING"], - (_, "lineHeight") => &["LINE_HEIGHT"], - (_, "paragraphSpacing") => &["PARAGRAPH_SPACING"], - (_, "paragraphIndent") => &["PARAGRAPH_INDENT"], - _ => { - self.warn(BAKED); - continue; - } - }; - // Each entry names the bound property and the number of the layer field that holds it. - for field in fields { - if let Some(a) = self.alias(var) { - let mut entry: Fields = node_field(field).into_iter().map(|id| ("nodeField", u(id))).collect(); - entry.extend([("variableData", a), ("variableField", e(field))]); - out.entries.push(o(entry)); - } - } - } - out - } - - /// Write layer `id` (child `index` of the layer with GUID `parent_guid`) and its subtree. - fn emit(&mut self, id: NodeId, parent_guid: Kv<'static>, index: usize, parent: Option<&Node>, depth: usize) { - let doc = self.doc; - let (Some(&local), Some(n)) = (self.ids.get(&id), doc.node(id)) else { return }; - if depth > MAX_DEPTH { - self.warn("layers nested deeper than 256 levels were left out"); - return; - } - let mut f: Fields = vec![ - ("guid", guid(LAYERS, local)), - ("phase", e("CREATED")), - ("parentIndex", o(vec![("guid", parent_guid), ("position", s(&position(index)))])), - ]; - let children = if let NodeKind::Page { background, guides, prototype, .. } = &n.kind { - f.extend([("type", e("CANVAS")), ("name", s(&n.name))]); - f.extend(root_fields()); - f.extend([("backgroundColor", color(*background)), ("backgroundOpacity", fl(background.a.into())), ("backgroundEnabled", b(true))]); - if !guides.is_empty() { - self.warn("ruler guides were left out"); - } - if *prototype != Default::default() { - self.warn("prototype device settings were left out"); - } - self.modes(n, &mut f); - true - } else { - self.layer(n, parent, &mut f, false) - }; - self.nodes.push(o(f)); - if children { - for (i, c) in n.children.iter().enumerate() { - self.emit(*c, guid(LAYERS, local), i, Some(n), depth + 1); - } - } - } - - /// The fields of layer `n` (whose parent is `parent`). `nested`: for an instance's override - /// (no type, no instance or component data). Returns whether its children are written as - /// layers (an instance's come from its main). - fn layer(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields, nested: bool) -> bool { - let doc = self.doc; - if let Some((outline, original)) = outlined(n) { - // The format's variable width is the same on both sides of the path: a stroke wider on - // one side is written as the area it paints, with the stroke itself kept as plugin data - // (which the import puts back). - self.warn("strokes wider on one side of their path were written as filled outlines (the stroke itself is kept for FigCraft)"); - let children = self.layer(&outline, parent, f, nested); - f.push(("pluginData", arr(vec![o(vec![("pluginID", s(STASH.0)), ("value", s(&original)), ("key", s(STASH.1))])]))); - return children; - } - let main = match &n.kind { - NodeKind::Instance { component, .. } => { - let found = self.ids.contains_key(component) && doc.node(*component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. })); - if !found && !nested { - self.warn("instances of components that aren't in the file were written as frames"); - } - found.then_some(*component) - } - _ => None, - }; - let ty = match &n.kind { - NodeKind::Page { .. } | NodeKind::Frame | NodeKind::Group | NodeKind::ComponentSet { .. } => "FRAME", - NodeKind::Instance { .. } if main.is_none() => "FRAME", - NodeKind::Section { .. } => "SECTION", - NodeKind::Rectangle => "ROUNDED_RECTANGLE", - NodeKind::Ellipse { .. } => "ELLIPSE", - NodeKind::Polygon { .. } => "REGULAR_POLYGON", - NodeKind::Star { .. } => "STAR", - NodeKind::Line => "LINE", - NodeKind::Vector { .. } => "VECTOR", - NodeKind::Text(_) => "TEXT", - NodeKind::BooleanOp { .. } => "BOOLEAN_OPERATION", - NodeKind::Component { .. } => "SYMBOL", - NodeKind::Instance { .. } => "INSTANCE", - NodeKind::Slice => "SLICE", - }; - if !nested { - f.push(("type", e(ty))); - } - f.push(("name", s(&n.name))); - self.common(n, parent, f); - match &n.kind { - NodeKind::Group => f.push(("resizeToFit", b(true))), - NodeKind::Section { contents_hidden } => f.push(("sectionContentsHidden", b(*contents_hidden))), - NodeKind::Ellipse { start, sweep, inner } => { - f.push(("arcData", o(vec![("startingAngle", fl(*start)), ("endingAngle", fl(start + sweep)), ("innerRadius", fl(*inner))]))) - } - NodeKind::Polygon { count } => f.push(("count", u(*count))), - NodeKind::Star { count, inner_ratio } => f.extend([("count", u(*count)), ("starInnerScale", fl(*inner_ratio))]), - NodeKind::Vector { network } => self.vector(n, network, f), - NodeKind::Text(td) => self.text(td, &n.fills, f), - NodeKind::BooleanOp { op } => { - let name = match op { - BoolOp::Union => "UNION", - BoolOp::Subtract => "SUBTRACT", - BoolOp::Intersect => "INTERSECT", - BoolOp::Exclude => "XOR", - }; - f.push(("booleanOperation", e(name))); - if n.children.iter().filter_map(|c| doc.node(*c)).any(|c| c.bool_op.is_some()) { - self.warn("boolean groups whose layers combine in different ways use the group's operation"); - } - } - NodeKind::Component { description, props } if !nested => { - if let Some(set) = self.set_of(n.id) { - // A variant: its set holds the definitions; the variant has its own copy of - // each (pointing at the set's), and its values of the variant properties. - let own: Vec = props - .iter() - .filter_map(|(name, d)| { - let ty = prop_type(d)?; - let id = *self.child_defs.get(&(n.id, name.clone(), ty))?; - let parent = *self.defs.get(&(set, name.clone(), ty))?; - Some(o(vec![("id", guid(AUX, id)), ("parentPropDefId", guid(AUX, parent))])) - }) - .collect(); - if !own.is_empty() { - f.push(("componentPropDefs", arr(own))); - } - let specs: Vec = variant_pairs(&n.name) - .into_iter() - .filter_map(|(prop, value)| { - let id = *self.defs.get(&(set, prop, "VARIANT"))?; - Some(o(vec![("propDefId", guid(AUX, id)), ("value", s(&value))])) - }) - .collect(); - if !specs.is_empty() { - f.push(("variantPropSpecs", arr(specs))); - } - } else { - let defs: Vec = props.iter().enumerate().filter_map(|(i, (name, d))| self.prop_def(n.id, name, d, i)).collect(); - if !defs.is_empty() { - f.push(("componentPropDefs", arr(defs))); - } - } - if props.values().any(|d| matches!(d, PropDef::Slot { .. })) { - self.warn("slot properties were left out"); - } - if !description.is_empty() { - f.push(("symbolDescription", s(description))); - } - } - NodeKind::ComponentSet { description } if !nested => { - f.push(("isStateGroup", b(true))); - self.set_props(n, f); - if !description.is_empty() { - f.push(("symbolDescription", s(description))); - } - } - NodeKind::Instance { props, .. } if !nested => { - if let Some(main) = main { - self.instance(n, main, props, f); - } - } - _ => {} - } - let boxy = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle | NodeKind::Section { .. }); - if (boxy || matches!(n.kind, NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. })) - && let Some(p) = figcraft_doc::geometry::shape_path(n) - { - self.geometry("fillGeometry", &p, WindingRule::NonZero, f); - } - self.stroke_geometry(n, boxy, f); - main.is_none() - } - - /// Fields every layer kind shares. - fn common(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { - f.extend([ - ("visible", b(n.visible)), - ("locked", b(n.locked)), - ("opacity", fl(n.opacity.into())), - ("blendMode", e(blend_name(n.blend))), - ("size", xy(n.width, n.height)), - ("transform", matrix(n.transform)), - ]); - if n.is_mask { - let ty = match n.mask_type { - MaskType::Alpha => "ALPHA", - MaskType::Vector => "OUTLINE", - MaskType::Luminance => "LUMINANCE", - }; - f.extend([("mask", b(true)), ("maskType", e(ty))]); - } - let bound = self.bound(n); - // Text whose own style sets fills paints with them (the layer's fills only show through - // runs); a variable bound to the layer's fill colours the layer's. - let fills = match &n.kind { - NodeKind::Text(TextData { style: TextStyle { fills: Some(own), .. }, .. }) => self.paints(own, true, &Bound::default()), - _ => self.paints(&n.fills, true, &bound), - }; - let strokes = self.paints(&n.strokes, false, &bound); - f.extend([("fillPaints", fills), ("strokePaints", strokes)]); - self.stroke(n, f); - if !n.effects.is_empty() { - let effects = n.effects.iter().enumerate().map(|(i, x)| self.effect(x, i, &bound)).collect(); - f.push(("effects", arr(effects))); - } - if n.kind.has_corner_radius() { - let r = n.corners.radii; - f.push(("cornerRadius", fl(r[0]))); - let differ = r.iter().any(|x| *x != r[0]); - if n.kind.has_independent_corners() && (differ || r[0] > 0.0) { - f.extend(differ.then(|| ("rectangleCornerRadiiIndependent", b(true)))); - f.extend([ - ("rectangleTopLeftCornerRadius", fl(r[0])), - ("rectangleTopRightCornerRadius", fl(r[1])), - ("rectangleBottomRightCornerRadius", fl(r[2])), - ("rectangleBottomLeftCornerRadius", fl(r[3])), - ]); - } - if n.corners.smoothing > 0.0 { - f.push(("cornerSmoothing", fl(n.corners.smoothing))); - } - } - let h = match n.constraints.horizontal { - HConstraint::Left => "MIN", - HConstraint::Right => "MAX", - HConstraint::LeftRight => "STRETCH", - HConstraint::Center => "CENTER", - HConstraint::Scale => "SCALE", - }; - let v = match n.constraints.vertical { - VConstraint::Top => "MIN", - VConstraint::Bottom => "MAX", - VConstraint::TopBottom => "STRETCH", - VConstraint::Center => "CENTER", - VConstraint::Scale => "SCALE", - }; - f.extend([("horizontalConstraint", e(h)), ("verticalConstraint", e(v))]); - if n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }) { - f.push(("frameMaskDisabled", b(!n.clip))); - } - if n.kind.is_frame_like() { - if let Some(l) = &n.layout { - self.auto_layout(n, l, f); - } - if !n.grids.is_empty() { - f.push(("layoutGrids", arr(n.grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &bound)).collect()))); - } - } - self.child_layout(n, parent, f); - if !n.exports.is_empty() { - if n.exports.iter().any(|x| x.format == figcraft_doc::ExportFormat::Webp) { - self.warn("WebP export settings were written as PNG"); - } - if n.exports.iter().any(|x| x.format == figcraft_doc::ExportFormat::VectorDrawable) { - self.warn("Android Vector Drawable (XML) export settings were written as SVG"); - } - f.push(("exportSettings", arr(n.exports.iter().map(export_setting).collect()))); - } - self.prototype(n, f); - let (entries, refs): (Vec, Vec) = n.prop_refs.iter().filter_map(|(field, prop)| self.prop_ref(n, field, prop)).unzip(); - if !entries.is_empty() { - f.push(("parameterConsumptionMap", o(vec![("entries", arr(entries))]))); - f.push(("componentPropRefs", arr(refs))); - } - let text = matches!(n.kind, NodeKind::Text(_)); - let links = [ - ("styleIdForFill", &n.styles.fill, true), - ("styleIdForStrokeFill", &n.styles.stroke, true), - ("styleIdForEffect", &n.styles.effect, true), - ("styleIdForGrid", &n.styles.grid, n.kind.is_frame_like()), - ("styleIdForText", &n.styles.text, text), - ]; - for (key, id, fits) in links { - let Some(id) = id else { continue }; - match self.style_ids.get(id.as_str()).filter(|_| fits) { - Some(local) => f.push((key, o(vec![("guid", guid(LAYERS, *local))]))), - None => self.warn("links to shared styles that aren't in the document were left out (their layers keep the values)"), - } - } - if let NodeKind::Text(td) = &n.kind - && n.styles.text.is_none() - && std::iter::once(&td.style).chain(td.runs.iter().map(|r| &r.style)).any(|st| st.style_id.is_some()) - { - self.warn("text styles applied to part of a text were written as their values"); - } - if !bound.entries.is_empty() { - f.push(("variableConsumptionMap", o(vec![("entries", arr(bound.entries))]))); - } - self.modes(n, f); - if !n.guides.is_empty() { - self.warn("ruler guides were left out"); - } - if !n.annotations.is_empty() - || !n.dev_resources.is_empty() - || !n.code_links.is_empty() - || !n.measurements.is_empty() - || !n.dev_status.is_none() - { - self.warn("Dev Mode annotations, statuses, resources, measurements and code links were left out"); - } - if !n.plugin_data.is_empty() { - self.warn("plugin data was left out"); - } - } - - /// The variable modes layer (or page) `n` sets for what is in it. - fn modes(&mut self, n: &Node, f: &mut Fields) { - let mut entries = Vec::new(); - for (coll, mode) in &n.var_modes { - match (self.set_ids.get(coll.as_str()), self.mode_ids.get(&(coll.as_str(), mode.as_str()))) { - (Some(set), Some(mode)) => { - entries.push(o(vec![("variableSetID", o(vec![("guid", guid(LAYERS, *set))])), ("variableModeID", guid(AUX, *mode))])); - } - _ => self.warn("variable modes of collections that aren't in the document were left out"), - } - } - if !entries.is_empty() { - f.push(("variableModeBySetMap", o(vec![("entries", arr(entries))]))); - } - } - - /// A paint list: a layer's fills (`fills`) or strokes with the variables `bound` to them. - fn paints(&mut self, list: &[Paint], fills: bool, bound: &Bound) -> Kv<'static> { - arr(list.iter().enumerate().filter_map(|(i, p)| self.paint(p, (fills, i), bound)).collect()) - } - - fn paint(&mut self, p: &Paint, at: (bool, usize), bound: &Bound) -> Option> { - let mut f: Fields = vec![("visible", b(p.visible)), ("blendMode", e(blend_name(p.blend)))]; - let mut opacity = f64::from(p.opacity); - match &p.kind { - PaintKind::Solid { color: c } => { - opacity *= f64::from(c.a); - f.extend([("type", e("SOLID")), ("color", color(c.with_alpha(1.0)))]); - f.extend(bound.paints.get(&at).map(|a| ("colorVar", a.clone()))); - } - PaintKind::Gradient(g) => { - let ty = match g.kind { - GradientKind::Linear => "GRADIENT_LINEAR", - GradientKind::Radial => "GRADIENT_RADIAL", - GradientKind::Angular => "GRADIENT_ANGULAR", - GradientKind::Diamond => "GRADIENT_DIAMOND", - }; - // The handles in the unit box come from the inverse of the paint transform: it maps - // (0, ½) → start, (1, ½) → end, (0, 1) → width for linear gradients, and the centre - // (½, ½) and the points half a box away from it for the others. - let (from, to, width) = (g.from, g.to, g.width); - let (ax, ay) = - if g.kind == GradientKind::Linear { (to.0 - from.0, to.1 - from.1) } else { (2.0 * (to.0 - from.0), 2.0 * (to.1 - from.1)) }; - let (cx, cy) = (2.0 * (width.0 - from.0), 2.0 * (width.1 - from.1)); - let (ex, ey) = if g.kind == GradientKind::Linear { - (from.0 - 0.5 * cx, from.1 - 0.5 * cy) - } else { - (from.0 - 0.5 * (ax + cx), from.1 - 0.5 * (ay + cy)) - }; - let handles = Affine::new([ax, ay, cx, cy, ex, ey]); - let t = if handles.determinant().abs() > 1e-12 && handles.as_coeffs().iter().all(|x| x.is_finite()) { - handles.inverse() - } else { - self.warn("gradients with handles on one line were written with the default direction"); - Affine::IDENTITY - }; - let stops = g.stops.iter().map(|st| o(vec![("color", color(st.color)), ("position", fl(st.position.into()))])).collect(); - f.extend([("type", e(ty)), ("stops", arr(stops)), ("transform", matrix(t))]); - // Stops bound to variables: every stop again, the bound ones with their variable. - if bound.stops.range((at.0, at.1, 0)..=(at.0, at.1, usize::MAX)).next().is_some() { - let vars = g.stops.iter().enumerate().map(|(j, st)| { - let mut sf: Fields = vec![("color", color(st.color))]; - sf.extend(bound.stops.get(&(at.0, at.1, j)).map(|a| ("colorVar", a.clone()))); - sf.push(("position", fl(st.position.into()))); - o(sf) - }); - f.push(("stopsVar", arr(vars.collect()))); - } - } - PaintKind::Image(img) => { - let hash = self.image(&img.image); - let mode = match img.scale_mode { - ImageScaleMode::Fill => "FILL", - ImageScaleMode::Fit => "FIT", - ImageScaleMode::Crop => "STRETCH", - ImageScaleMode::Tile => "TILE", - }; - f.extend([ - ("type", e("IMAGE")), - ("image", o(vec![("hash", Kv::Bytes(hash)), ("name", s(""))])), - ("imageScaleMode", e(mode)), - ("rotation", fl(f64::from(img.rotation.rem_euclid(4)) * 90.0)), - ("scale", fl(img.scale)), - ]); - if let (ImageScaleMode::Crop, Some(c)) = (img.scale_mode, img.crop) { - f.push(("transform", matrix(Affine::new(c)))); - } - if img.filters != Default::default() { - self.warn("image adjustments (exposure, contrast, …) were left out"); - } - } - PaintKind::Pattern(_) => { - self.warn("pattern fills were left out"); - return None; - } - } - f.push(("opacity", fl(opacity))); - Some(o(f)) - } - - /// The hash bytes of image `key`, writing its file once (empty when its bytes aren't there). - fn image(&mut self, key: &str) -> Vec { - let hex = match self.images.get(key) { - Some(h) => h.clone(), - None => { - let h = match self.doc.images.get(key) { - Some(img) if img.bytes.len() as u64 <= MAX_IMAGE => { - let h = sha1_hex(&img.bytes); - if !self.files.iter().any(|(x, _)| *x == h) { - self.files.push((h.clone(), img.bytes.clone())); - } - Some(h) - } - Some(_) => { - self.warn("images larger than 256 MB were left out"); - None - } - None => { - self.warn("image fills whose image isn't in the document were written without one"); - None - } - }; - self.images.insert(key.to_string(), h.clone()); - h - } - }; - let hex = hex.unwrap_or_default(); - (0..hex.len() / 2).filter_map(|i| hex.get(2 * i..2 * i + 2).and_then(|d| u8::from_str_radix(d, 16).ok())).collect() - } - - fn stroke(&mut self, n: &Node, f: &mut Fields) { - let sp = &n.stroke; - let align = match sp.align { - StrokeAlign::Inside => "INSIDE", - StrokeAlign::Center => "CENTER", - StrokeAlign::Outside => "OUTSIDE", - }; - f.extend([ - ("strokeWeight", fl(sp.weight)), - ("strokeAlign", e(align)), - ("strokeJoin", e(join_name(sp.join))), - ("strokeCap", e(cap_name(sp.cap_start))), - ("miterLimit", fl(sp.miter_limit)), - ]); - if !sp.dashes.is_empty() { - f.push(("dashPattern", arr(sp.dashes.iter().map(|d| fl(*d)).collect()))); - } - if let Some([t, r, bo, l]) = sp.sides { - f.extend([ - ("borderStrokeWeightsIndependent", b(true)), - ("borderTopWeight", fl(t)), - ("borderRightWeight", fl(r)), - ("borderBottomWeight", fl(bo)), - ("borderLeftWeight", fl(l)), - ]); - } - // A variable width: how far the stroke reaches on each side of the path at positions along - // its length, as a share of the stroke weight (half of it each for the full weight). The - // two sides are the same: a reader draws nothing for a point with unequal sides. - if let Some(p) = sp.profile().filter(|_| sp.weight.is_finite() && sp.weight > 0.0) { - let point = |q: &figcraft_doc::WidthPoint| o(vec![("position", fl(q.t)), ("ascent", fl(q.scale / 2.0)), ("descent", fl(q.scale / 2.0))]); - let p = p.sanitized(); - if one_sided(&p) { - self.warn("strokes wider on one side of their path were written with that width on both sides"); - } - f.push(("variableWidthPoints", arr(p.points.iter().map(point).collect()))); - } - if sp.brush.is_some() { - self.warn("brush strokes were written as plain strokes"); - } - if [sp.cap_start, sp.cap_end].contains(&StrokeCap::ReversedTriangle) { - self.warn("reversed triangle stroke ends were written as triangle arrows"); - } - // Vectors keep each end's cap on their end points; other layers have one cap. - if sp.cap_start != sp.cap_end && !matches!(n.kind, NodeKind::Vector { .. }) { - self.warn("lines with different stroke ends use the start's at both ends"); - } - } - - /// Effect `index` of a layer, with the variables `bound` to its fields. - fn effect(&mut self, x: &figcraft_doc::Effect, index: usize, bound: &Bound) -> Kv<'static> { - let mut f: Fields = vec![("visible", b(x.visible))]; - let vars = [ - ("color", "colorVar"), - ("radius", "radiusVar"), - ("spread", "spreadVar"), - ("offsetX", "xVar"), - ("offsetY", "yVar"), - ("startRadius", "startRadiusVar"), - ]; - f.extend(vars.into_iter().filter_map(|(ours, key)| Some((key, bound.effects.get(&(index, ours))?.clone())))); - let blur = |f: &mut Fields, ty: &'static str, bl: &figcraft_doc::Blur| { - f.extend([("type", e(ty)), ("radius", fl(bl.radius))]); - if bl.blur_type == figcraft_doc::BlurType::Progressive { - f.extend([ - ("blurOpType", e("PROGRESSIVE")), - ("startRadius", fl(bl.start_radius)), - ("startOffset", xy(bl.start.0, bl.start.1)), - ("endOffset", xy(bl.end.0, bl.end.1)), - ]); - } - }; - let grain = |size: f64, size_y: Option| xy(size, size_y.unwrap_or(size)); - match &x.kind { - EffectKind::DropShadow(sh) | EffectKind::InnerShadow(sh) => { - let ty = if matches!(x.kind, EffectKind::DropShadow(_)) { "DROP_SHADOW" } else { "INNER_SHADOW" }; - f.extend([ - ("type", e(ty)), - ("color", color(sh.color)), - ("offset", xy(sh.offset.0, sh.offset.1)), - ("radius", fl(sh.radius)), - ("spread", fl(sh.spread)), - ("blendMode", e(blend_name(sh.blend))), - ("showShadowBehindNode", b(sh.show_behind)), - ]); - } - EffectKind::LayerBlur(bl) => blur(&mut f, "FOREGROUND_BLUR", bl), - EffectKind::BackgroundBlur(bl) => blur(&mut f, "BACKGROUND_BLUR", bl), - EffectKind::Noise(nz) => { - let mode = match nz.mode { - NoiseMode::Mono => "MONOTONE", - NoiseMode::Duo => "DUOTONE", - NoiseMode::Multi => "MULTITONE", - }; - f.extend([ - ("type", e("NOISE")), - ("noiseType", e(mode)), - ("noiseSize", grain(nz.size, nz.size_y)), - ("density", fl(nz.density)), - ("color", color(nz.color)), - ("secondaryColor", color(nz.secondary)), - ("opacity", fl(nz.opacity.into())), - ("blendMode", e(blend_name(nz.blend))), - ]); - } - EffectKind::Texture(t) => { - f.extend([ - ("type", e("GRAIN")), - ("noiseSize", grain(t.size, t.size_y)), - ("radius", fl(t.radius)), - ("clipToShape", b(t.clip_to_shape)), - ]); - } - EffectKind::Glass(g) => { - self.warn("glass effects keep only their frost"); - f.extend([("type", e("GLASS")), ("radius", fl(g.frost))]); - } - } - o(f) - } - - fn auto_layout(&mut self, n: &Node, l: &AutoLayout, f: &mut Fields) { - let grid = l.mode == LayoutMode::Grid; - let mode = match l.mode { - LayoutMode::Horizontal => "HORIZONTAL", - LayoutMode::Vertical => "VERTICAL", - LayoutMode::Grid => "GRID", - }; - let [top, right, bottom, left] = l.padding; - let main = match l.main_align { - MainAlign::Min => "MIN", - MainAlign::Center => "CENTER", - MainAlign::Max => "MAX", - MainAlign::SpaceBetween => "SPACE_BETWEEN", - MainAlign::SpaceAround => "SPACE_AROUND", - MainAlign::SpaceEvenly => "SPACE_EVENLY_CSS", - }; - let cross = match l.cross_align { - CrossAlign::Min => "MIN", - CrossAlign::Center => "CENTER", - CrossAlign::Max => "MAX", - CrossAlign::Baseline => "BASELINE", - }; - // The main axis is the width for horizontal and grid layouts. - let (main_hug, cross_hug) = { - let (h, v) = (n.child_layout.sizing_h == Sizing::Hug, n.child_layout.sizing_v == Sizing::Hug); - if l.mode == LayoutMode::Vertical { (v, h) } else { (h, v) } - }; - let size = |hug: bool| e(if hug { "RESIZE_TO_FIT" } else { "FIXED" }); - f.extend([ - ("stackMode", e(mode)), - ("stackWrap", e(if l.wrap { "WRAP" } else { "NO_WRAP" })), - ("stackHorizontalPadding", fl(left)), - ("stackVerticalPadding", fl(top)), - ("stackPaddingRight", fl(right)), - ("stackPaddingBottom", fl(bottom)), - ("stackPrimaryAlignItems", e(main)), - ("stackCounterAlignItems", e(cross)), - ("stackPrimarySizing", size(main_hug)), - ("stackCounterSizing", size(cross_hug)), - ("bordersTakeSpace", b(l.strokes_included)), - ("stackReverseZIndex", b(l.reverse_z)), - ]); - if grid { - self.grid_layout(n, l, f); - } else { - let spread = l.content_align == ContentAlign::SpaceBetween; - f.extend([ - ("stackSpacing", fl(l.gap)), - ("stackCounterSpacing", fl(l.cross_gap)), - ("stackCounterAlignContent", e(if spread { "SPACE_BETWEEN" } else { "AUTO" })), - ]); - } - } - - /// A grid layout's track gaps, order and sizes. - fn grid_layout(&mut self, n: &Node, l: &AutoLayout, f: &mut Fields) { - // Instance layers share their main layer's track ids, so unchanged tracks and the cells - // their layers sit in still match. - let key = self.main_layer(n.id); - let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize); - let (cols, rows) = match self.tracks.get(&key) { - Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()), - _ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()), - }; - let map = |ids: &[u32]| { - o(vec![("entries", arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()))]) - }; - f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); - let mut dropped = false; - let column_sizing = grid_sizing(&l.column_tracks, &cols, &mut dropped); - let row_sizing = grid_sizing(&l.row_tracks, &rows, &mut dropped); - if dropped { - self.warn("grid tracks whose size the format can't hold were written as equal tracks"); - } - for (name, sizing) in [("gridColumnsSizing", column_sizing), ("gridRowsSizing", row_sizing)] { - if let Some(v) = sizing { - f.push((name, v)); - } - } - self.tracks.insert(key, (cols, rows)); - } - - /// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own - /// and are written whatever its parent is. - fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { - let cl = &n.child_layout; - // An axis without a minimum is 0; one without a maximum is infinite (0 would be a maximum - // of 0, which collapses the layer in readers that apply it). - for (key, w, h, none) in [("minSize", cl.min_w, cl.min_h, 0.0), ("maxSize", cl.max_w, cl.max_h, f32::INFINITY)] { - if w.is_some() || h.is_some() { - let axis = |x: Option| x.map_or(Kv::Float(none), fl); - f.push((key, o(vec![("value", o(vec![("x", axis(w)), ("y", axis(h))]))]))); - } - } - let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return }; - let (fill_h, fill_v) = (cl.sizing_h == Sizing::Fill, cl.sizing_v == Sizing::Fill); - let (grow, stretch) = if pl.mode == LayoutMode::Vertical { (fill_v, fill_h) } else { (fill_h, fill_v) }; - if grow { - f.push(("stackChildPrimaryGrow", fl(1.0))); - } - if stretch { - f.push(("stackChildAlignSelf", e("STRETCH"))); - } - if cl.absolute { - f.push(("stackPositioning", e("ABSOLUTE"))); - } - if pl.mode == LayoutMode::Grid - && let Some((cols, rows)) = parent.and_then(|p| self.tracks.get(&self.main_layer(p.id))) - { - let anchor = |ids: &[u32], i: Option| i.and_then(|i| ids.get(i as usize)).map(|t| guid(AUX, *t)); - let (c, r) = (anchor(cols, cl.grid_column), anchor(rows, cl.grid_row)); - f.extend(c.map(|g| ("gridColumnAnchor", g))); - f.extend(r.map(|g| ("gridRowAnchor", g))); - if cl.grid_column_span > 0 { - f.push(("gridColumnSpan", int(cl.grid_column_span.into()))); - } - if cl.grid_row_span > 0 { - f.push(("gridRowSpan", int(cl.grid_row_span.into()))); - } - } - } - - /// Text: its style, box settings, and characters with their styled runs. `fills`: the layer's - /// (what runs without fills of their own show when the text's own style sets other fills). - fn text(&mut self, td: &TextData, fills: &[Paint], f: &mut Fields) { - style_fields(&td.style, f); - let (ha, va) = ( - match td.align_h { - TextAlignH::Left => "LEFT", - TextAlignH::Center => "CENTER", - TextAlignH::Right => "RIGHT", - TextAlignH::Justified => "JUSTIFIED", - }, - match td.align_v { - TextAlignV::Top => "TOP", - TextAlignV::Middle => "CENTER", - TextAlignV::Bottom => "BOTTOM", - }, - ); - let resize = match td.auto_resize { - TextAutoResize::WidthAndHeight => "WIDTH_AND_HEIGHT", - TextAutoResize::Height => "HEIGHT", - TextAutoResize::None | TextAutoResize::Truncate => "NONE", - }; - f.extend([ - ("textAlignHorizontal", e(ha)), - ("textAlignVertical", e(va)), - ("textAutoResize", e(resize)), - ("paragraphSpacing", fl(td.paragraph_spacing)), - ("paragraphIndent", fl(td.paragraph_indent)), - ("autoRename", b(false)), - ("fontVariantCommonLigatures", b(true)), - ("fontVariantContextualLigatures", b(true)), - ("fontVersion", s("")), - ("textUserLayoutVersion", u(5)), - ("textExplicitLayoutVersion", u(1)), - ("textBidiVersion", u(1)), - ("textTracking", fl(0.0)), - ("emojiImageSet", e("APPLE")), - ]); - if td.truncates() { - f.push(("textTruncation", e("ENDING"))); - } - if td.max_lines > 0 { - f.push(("maxLines", int(td.max_lines.into()))); - } - // Styles per UTF-16 unit: 0 for the base style, else an entry of the override table. - if td.char_count() > MAX_CHARS { - self.warn("text longer than 1,048,576 characters was cut"); - } - let spans = td.spans(); - let mut table: Vec<&TextStyle> = Vec::new(); - let mut span_ids = Vec::with_capacity(spans.len()); - for (_, _, st) in &spans { - span_ids.push(if **st == td.style { - 0 - } else { - let i = table.iter().position(|t| *t == *st).unwrap_or_else(|| { - table.push(st); - table.len() - 1 - }); - i as u32 + 1 - }); - } - let mut chars = String::new(); - let mut ids: Vec = Vec::new(); - let mut span = 0; - for (i, ch) in td.characters.chars().take(MAX_CHARS).enumerate() { - while spans.get(span).is_some_and(|sp| i >= sp.1) { - span += 1; - } - // NULs can't be stored in a string. - if ch == '\0' { - continue; - } - chars.push(ch); - let id = span_ids.get(span).copied().unwrap_or(0); - ids.extend((0..ch.len_utf16()).map(|_| u(id))); - } - let mut data: Fields = vec![("characters", Kv::Str(chars))]; - if !table.is_empty() { - let entries = table - .iter() - .zip(1u32..) - .map(|(st, id)| { - let mut ef: Fields = vec![("styleID", u(id))]; - style_fields(st, &mut ef); - // An empty link clears the text's own (a missing one would inherit it). - if st.link.is_none() && td.style.link.is_some() { - ef.push(("hyperlink", o(vec![("url", s(""))]))); - } - if let Some(p) = st.fills.as_deref().or(td.style.fills.as_ref().map(|_| fills)) { - ef.push(("fillPaints", self.paints(p, true, &Bound::default()))); - } - o(ef) - }) - .collect(); - data.extend([("characterStyleIDs", arr(ids)), ("styleOverrideTable", arr(entries))]); - } - let lists: Vec = (0..td.paragraph_count()).map(|p| td.para_list(p).list).collect(); - let first = |i: usize, l: &ListStyle| *l != ListStyle::None && (i == 0 || lists.get(i - 1) != Some(l)); - // Where each line's numbered list starts counting, less one: its first item's start, on - // every line of the list. - let mut run = 0; - let starts: Vec = lists - .iter() - .enumerate() - .map(|(i, l)| { - if first(i, l) { - run = td.para_list(i).start_offset; - } - if *l == ListStyle::Numbered { run } else { 0 } - }) - .collect(); - // One entry per paragraph, each with every field a reader expects. - let line = |(i, l): (usize, &ListStyle)| { - let ty = match l { - ListStyle::None => "PLAIN", - ListStyle::Numbered => "ORDERED_LIST", - ListStyle::Bulleted => "UNORDERED_LIST", - }; - let listed = *l != ListStyle::None; - let begins = first(i, l); - let start = starts.get(i).copied().unwrap_or(0); - o(vec![ - ("lineType", e(ty)), - ("styleId", int(0)), - ("indentationLevel", int(i64::from(listed))), - ("sourceDirectionality", e("AUTO")), - ("listStartOffset", int(i64::from(start))), - ("isFirstLineOfList", b(begins)), - ]) - }; - data.push(("lines", arr(lists.iter().enumerate().map(line).collect()))); - f.push(("textData", o(data))); - if (0..td.paragraph_count()).any(|p| td.para_list(p).indent > 0) { - self.warn("nested list levels were written as the first level"); - } - let styles = std::iter::once(&td.style).chain(td.runs.iter().map(|r| &r.style)); - if styles.clone().any(|st| unsupported_decoration_look(st) || !st.variations.is_empty()) { - self.warn("text decoration looks and variable font settings were left out"); - } - if td.path.is_some() { - self.warn("text on a path was written as plain text"); - } - if td.list_spacing != 0.0 || td.hanging_punctuation || td.hanging_list || td.leading_trim != Default::default() || !td.features.is_empty() { - self.warn("list spacing, hanging punctuation, leading trim and OpenType features were left out"); - } - } - - /// The editable network (with per-point corner radii, handle mirroring and end caps) and, - /// when it has filled areas, their outline. - fn vector(&mut self, n: &Node, net: &VectorNetwork, f: &mut Fields) { - let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum(); - if net.vertices.len() + net.segments.len() + entries > MAX_NETWORK { - self.warn("vector networks too large for the format were written as outlines"); - } else { - // The stroke starts at the first end point and stops at the last: the layer's end caps go - // there (where the point has none of its own). - let mut degree = vec![0usize; net.vertices.len()]; - for sg in &net.segments { - for i in [sg.a, sg.b] { - if let Some(d) = degree.get_mut(i) { - *d += 1; - } - } - } - let ends: Vec = (0..degree.len()).filter(|i| degree.get(*i) == Some(&1)).collect(); - let (first, last) = (ends.first().copied(), ends.last().copied()); - let caps_differ = n.stroke.cap_start != n.stroke.cap_end || net.vertices.iter().any(|v| v.cap.is_some()); - let mut styles: Vec = Vec::new(); - let mut style_of = |fields: Fields| -> u32 { - if fields.is_empty() { - return 0; - } - let i = styles.iter().position(|x| *x == fields).unwrap_or_else(|| { - styles.push(fields); - styles.len() - 1 - }); - i as u32 + 1 - }; - let vertices = net - .vertices - .iter() - .enumerate() - .map(|(i, v)| { - let mut st: Fields = Vec::new(); - if v.radius > 0.0 { - st.push(("cornerRadius", fl(v.radius))); - } - if let Some(m) = v.mirror { - st.push(( - "handleMirroring", - e(match m { - figcraft_doc::HandleMirroring::None => "NONE", - figcraft_doc::HandleMirroring::Angle => "ANGLE", - figcraft_doc::HandleMirroring::AngleAndLength => "ANGLE_AND_LENGTH", - }), - )); - } - let end_cap = if Some(i) == first { - Some(n.stroke.cap_start) - } else if Some(i) == last { - Some(n.stroke.cap_end) - } else { - None - }; - if let Some(c) = v.cap.or(end_cap.filter(|_| caps_differ)) { - st.push(("strokeCap", e(cap_name(c)))); - } - if let Some(j) = v.join { - st.push(("strokeJoin", e(join_name(j)))); - } - network::Vertex { style: style_of(st), x: finite(v.x), y: finite(v.y) } - }) - .collect(); - let segments = net - .segments - .iter() - .map(|sg| network::Segment { - style: 0, - a: sg.a, - ta: (finite(sg.ta.0), finite(sg.ta.1)), - b: sg.b, - tb: (finite(sg.tb.0), finite(sg.tb.1)), - }) - .collect(); - let regions = net - .regions - .iter() - .map(|r| network::Region { style: 0, non_zero: r.winding == WindingRule::NonZero, loops: r.loops.clone() }) - .collect(); - let blob = network::encode(&network::Network { vertices, segments, regions }); - let table = styles.into_iter().zip(1u32..).map(|(mut st, id)| { - st.insert(0, ("styleID", u(id))); - o(st) - }); - let table: Vec = table.collect(); - if let Some(i) = self.blob(blob) { - f.push(( - "vectorData", - o(vec![("vectorNetworkBlob", u(i)), ("normalizedSize", xy(n.width, n.height)), ("styleOverrideTable", arr(table))]), - )); - } - } - if !net.regions.is_empty() { - let winding = net.regions.first().map(|r| r.winding).unwrap_or_default(); - if net.regions.iter().any(|r| r.winding != winding) { - self.warn("vectors whose filled areas use different winding rules use the first one's in their outline"); - } - self.geometry("fillGeometry", &net.fill_path(), winding, f); - } - } - - /// `fillGeometry` or `strokeGeometry` (`key`) from a local path. - fn geometry(&mut self, key: &'static str, p: &BezPath, winding: WindingRule, f: &mut Fields) { - if p.elements().is_empty() { - return; - } - if let Some(i) = self.blob(path_blob(p)) { - let rule = if winding == WindingRule::EvenOdd { "ODD" } else { "NONZERO" }; - f.push((key, arr(vec![o(vec![("windingRule", e(rule)), ("commandsBlob", u(i)), ("styleID", u(0))])]))); - } - } - - /// `strokeGeometry`, the outline of a plain stroke: a ring around boxes and whole ellipses, the - /// widened path of other centred strokes. Dashed, per-side and other strokes get none (a reader - /// builds theirs from the stroke settings). - fn stroke_geometry(&mut self, n: &Node, boxy: bool, f: &mut Fields) { - let sp = &n.stroke; - let (w, h, weight) = (n.width, n.height, sp.weight); - let plain = n.strokes.iter().any(|p| p.visible) && sp.dashes.is_empty() && sp.sides.is_none() && sp.profile().is_none() && sp.brush.is_none(); - if !(plain && weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) { - return; - } - let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep.abs() >= std::f64::consts::TAU - 1e-9); - if boxy || whole { - let (out, inset) = match sp.align { - StrokeAlign::Inside => (0.0, weight), - StrokeAlign::Center => (weight / 2.0, weight / 2.0), - StrokeAlign::Outside => (weight, 0.0), - }; - // The shape grown by `d` (shrunk when negative), or nothing when it shrinks away. - let grown = |d: f64| -> Option { - let (gw, gh) = (w + 2.0 * d, h + 2.0 * d); - if gw <= 0.0 || gh <= 0.0 { - return None; - } - let mut p = if whole { - figcraft_doc::geom::ellipse(gw, gh, 0.0, std::f64::consts::TAU, 0.0) - } else { - let radii = n.corners.radii.map(|r| if r > 0.0 { (r + d).max(0.0) } else { 0.0 }); - figcraft_doc::geom::rounded_rect(gw, gh, radii, n.corners.smoothing) - }; - p.apply_affine(Affine::translate((-d, -d))); - Some(p) - }; - let Some(mut ring) = grown(out) else { return }; - ring.extend(grown(-inset).iter().flat_map(|p| p.elements().iter().copied())); - self.geometry("strokeGeometry", &ring, WindingRule::EvenOdd, f); - } else if sp.align == StrokeAlign::Center { - let path = match &n.kind { - NodeKind::Vector { network } => Some(network.stroke_path()), - NodeKind::Text(_) => None, - _ => figcraft_doc::geometry::shape_path(n), - }; - let Some(path) = path.filter(|p| !p.elements().is_empty()) else { return }; - use figcraft_doc::geom::kurbo::{Cap, Join, Stroke, StrokeOpts, stroke}; - let cap = match sp.cap_start { - StrokeCap::Round => Cap::Round, - StrokeCap::Square => Cap::Square, - _ => Cap::Butt, - }; - let join = match sp.join { - StrokeJoin::Miter => Join::Miter, - StrokeJoin::Bevel => Join::Bevel, - StrokeJoin::Round => Join::Round, - }; - let limit = if sp.miter_limit.is_finite() { sp.miter_limit.max(1.0) } else { 4.0 }; - let style = Stroke::new(weight).with_caps(cap).with_join(join).with_miter_limit(limit); - self.geometry("strokeGeometry", &stroke(path, &style, &StrokeOpts::default(), 0.05), WindingRule::NonZero, f); - } - } - - /// Add a blob; its index (`None` once the blobs would make the file too large). - fn blob(&mut self, bytes: Vec) -> Option { - if let Some(i) = self.blobs.get(&bytes) { - return Some(*i); - } - self.blob_bytes += bytes.len() as u64; - if self.blob_bytes > MAX_CANVAS { - self.warn("vector data too large for the format was left out"); - return None; - } - let i = u32::try_from(self.blobs.len()).ok()?; - self.blobs.insert(bytes, i); - Some(i) - } - - /// A component property definition of `owner` (a component, or a set), `index`-th in its list. - /// The default value is written twice: as `initialValue` and as `varValue`, which is what - /// readers that know variables take. - fn prop_def(&mut self, owner: NodeId, name: &str, d: &PropDef, index: usize) -> Option> { - let ty = prop_type(d)?; - let id = *self.defs.get(&(owner, name.to_string(), ty))?; - let (value, var) = match d { - PropDef::Boolean { default } => (o(vec![("boolValue", b(*default))]), bool_data(*default)), - PropDef::Text { default } => (o(vec![("textValue", text_value(default))]), text_data(default)), - PropDef::InstanceSwap { default, preferred } => { - if !preferred.is_empty() { - self.warn("preferred values of instance swap properties were left out"); - } - let main = self.guid_of(*default)?; - (o(vec![("guidValue", main.clone())]), symbol_data(main)) - } - PropDef::Variant { .. } | PropDef::Slot { .. } => return None, - }; - Some(o(vec![ - ("id", guid(AUX, id)), - ("name", s(name)), - ("initialValue", value), - ("sortPosition", s(&sort_position(index))), - ("type", e(ty)), - ("preferredValues", o(Vec::new())), - ("varValue", var), - ])) - } - - /// A component set's property definitions: its variant properties (from the variants' names, - /// in the order they first appear, with their values in order) and the other properties its - /// variants share; written on the set, as the format keeps them. - fn set_props(&mut self, set: &Node, f: &mut Fields) { - let doc = self.doc; - let variants: Vec<&Node> = - set.children.iter().filter_map(|c| doc.node(*c)).filter(|c| matches!(c.kind, NodeKind::Component { .. })).collect(); - let mut order: Vec<(String, Vec)> = Vec::new(); - for v in &variants { - for (prop, value) in variant_pairs(&v.name) { - let i = match order.iter().position(|(p, _)| *p == prop) { - Some(i) => i, - None => { - order.push((prop, Vec::new())); - order.len() - 1 - } - }; - if let Some((_, values)) = order.get_mut(i) - && !values.contains(&value) - { - values.push(value); - } - } - } - let mut defs = Vec::new(); - for (prop, _) in &order { - if let Some(&id) = self.defs.get(&(set.id, prop.clone(), "VARIANT")) { - defs.push(o(vec![ - ("id", guid(AUX, id)), - ("name", s(prop)), - ("initialValue", o(Vec::new())), - ("sortPosition", s(&sort_position(defs.len()))), - ("type", e("VARIANT")), - ("preferredValues", o(Vec::new())), - ("varValue", var_data(("textValue", s("")), "STRING", "STRING")), - ])); - } - } - // The format keeps one default per property of a set (a variant's copy holds none): the first - // variant's, with a warning when another variant's differs. - // A name used with two types is two properties (their definitions have their own ids). - let mut seen: BTreeMap<(String, &'static str), PropDef> = BTreeMap::new(); - for v in &variants { - let NodeKind::Component { props, .. } = &v.kind else { continue }; - for (name, d) in props { - let Some(ty) = prop_type(d) else { continue }; - match seen.get(&(name.clone(), ty)) { - Some(first) if first != d => self.warn("variants with different defaults for one property were written with the first variant's"), - Some(_) => {} - None => { - seen.insert((name.clone(), ty), d.clone()); - let index = defs.len(); - defs.extend(self.prop_def(set.id, name, d, index)); - } - } - } - } - if !defs.is_empty() { - f.push(("componentPropDefs", arr(defs))); - } - if !order.is_empty() { - let orders = order.into_iter().map(|(p, values)| o(vec![("property", s(&p)), ("values", arr(values.iter().map(|x| s(x)).collect()))])); - f.push(("stateGroupPropertyValueOrders", arr(orders.collect()))); - } - } - - /// Which property controls a layer's `field`: the entry of its parameter consumption map and - /// its component property reference (readers take one or the other), both naming the - /// definition of the component the layer is in (a variant's own copy, in a set). - fn prop_ref(&mut self, n: &Node, field: &str, prop: &str) -> Option<(Kv<'static>, Kv<'static>)> { - let (var_field, ty, resolved) = match field { - "visible" => ("VISIBLE", "BOOL", "BOOLEAN"), - "characters" => ("TEXT_DATA", "TEXT", "TEXT_DATA"), - "mainComponent" => ("OVERRIDDEN_SYMBOL_ID", "INSTANCE_SWAP", "SYMBOL_ID"), - _ => { - self.warn("slot layers were written as plain layers"); - return None; - } - }; - let id = self.def_for(self.component_of(n)?, prop, ty)?; - let data = o(vec![ - ("value", o(vec![("propRefValue", o(vec![("defId", guid(AUX, id))]))])), - ("dataType", e("PROP_REF")), - ("resolvedDataType", e(resolved)), - ]); - let entry = o(vec![("variableData", data), ("variableField", e(var_field))]); - let reference = o(vec![("defID", guid(AUX, id)), ("componentPropNodeField", e(var_field))]); - Some((entry, reference)) - } - - /// An instance: its main, the fields it and its layers override, the size, place and outline - /// its layers get from that (derived data), its property values. `f`: its own fields so far. - fn instance(&mut self, n: &Node, main: NodeId, props: &BTreeMap, f: &mut Fields) { - // Which properties control the instance itself (a nested instance in a main component) is - // its own too, not an override of its main. - const OWN: [&str; 9] = - ["guid", "phase", "parentIndex", "type", "transform", "fillGeometry", "strokeGeometry", "parameterConsumptionMap", "componentPropRefs"]; - let doc = self.doc; - let (Some(m), Some(symbol)) = (doc.node(main), self.guid_of(main)) else { return }; - // What the instance itself changes from its main (its size, its paints, …) is an override - // addressed by the main's id: a reader takes everything else from the main. Where it sits - // is its own. - let mut root_theirs = Vec::new(); - self.layer(m, m.parent.and_then(|p| doc.node(p)), &mut root_theirs, true); - root_theirs.retain(|(k, _)| !OWN.contains(k)); - // An instance is named after its component (the set, for a variant) until renamed. - let set = m.parent.and_then(|p| doc.node(p)).filter(|p| matches!(p.kind, NodeKind::ComponentSet { .. })); - let named = set.is_none_or(|s| s.name != n.name); - let root_mine: Fields = f.iter().filter(|(k, _)| !OWN.contains(k) && (named || *k != "name")).cloned().collect(); - // Each layer below: its id path, the fields that differ from the main's layer, that layer's - // fields, whether its size is its own (a nested instance, a size override) and the - // component a nested instance was swapped for. - struct Row { - path: Kv<'static>, - changed: Fields, - theirs: Fields, - sized: bool, - swap: Option>, - } - let mut rows: Vec = Vec::new(); - let mut stack: Vec<(NodeId, NodeId)> = n.children.iter().rev().map(|c| (*c, n.id)).collect(); - let mut seen = 0usize; - while let Some((id, parent)) = stack.pop() { - let Some(d) = doc.node(id) else { continue }; - seen += 1; - if seen > MAX_NODES { - break; - } - stack.extend(d.children.iter().rev().map(|c| (*c, id))); - let path = self.layer_path(d, n.id, main, 0); - let guids: Option> = path.and_then(|p| p.iter().map(|x| self.guid_of(*x)).collect()); - let (Some(guids), Some(src)) = (guids, d.source.and_then(|src| doc.node(src))) else { - self.warn("instance layers that don't come from their main component were left out"); - continue; - }; - let (mut mine, mut theirs) = (Vec::new(), Vec::new()); - self.layer(d, doc.node(parent), &mut mine, true); - self.layer(src, src.parent.and_then(|p| doc.node(p)), &mut theirs, true); - // A nested instance swapped for another component. - let swap = match (&d.kind, &src.kind) { - (NodeKind::Instance { component, .. }, NodeKind::Instance { component: was, .. }) if component == was => None, - (NodeKind::Instance { component, .. }, _) => self.guid_of(*component), - _ => None, - }; - let sized = matches!(d.kind, NodeKind::Instance { .. }) || d.overrides.contains("size"); - rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, sized, swap }); - } - // An instance scaled as a whole (the Scale tool) is `factor` times its main in every length: - // sizes, places and stroke weights, its own and its layers'. A resized one keeps its weights. - let field = |fields: &Fields, key: &str| fields.iter().find(|(k, _)| *k == key).map(|(_, v)| v.clone()); - let factor = Some(n.width / m.width).filter(|k| k.is_finite() && *k > 0.0 && (k - 1.0).abs() > 1e-6).filter(|k| { - let grown = |mine: &Fields, theirs: &Fields| { - let weight = field(theirs, "strokeWeight").and_then(|w| w.f64()).unwrap_or(0.0); - (weight == 0.0 || field(mine, "strokeWeight").is_some_and(|w| scaled(&w, &fl(weight), *k, 0.0))) - && field(mine, "size").zip(field(theirs, "size")).is_some_and(|(a, b)| scaled(&a, &b, *k, 1.0)) - && field(mine, "transform").zip(field(theirs, "transform")).is_none_or(|(a, b)| scaled(&a, &b, *k, 1.0)) - }; - (n.height / m.height - k).abs() <= 1e-6 * k.max(1.0) - && grown(&changed(root_mine.clone(), &root_theirs), &root_theirs) - && rows.iter().all(|r| grown(&r.changed, &r.theirs)) - }); - // What follows from the overrides goes with the derived data: a layer's size, place and - // outline (a vector's network is the main's, drawn at the layer's size), the colours its - // variables give here, and what the scale factor makes of the main's values. - let follows = |k: &str, v: &Kv<'static>, theirs: &Fields| { - ["size", "transform", "fillGeometry", "strokeGeometry", "vectorData"].contains(&k) - || resolved_alike(k, v, theirs) - || factor.is_some_and(|x| field(theirs, k).is_some_and(|t| scaled(v, &t, x, 0.0))) - }; - let mut overrides = Vec::new(); - let mut derived = Vec::new(); - let mut of: Fields = vec![("guidPath", o(vec![("guids", arr(vec![symbol.clone()]))]))]; - of.extend(changed(root_mine, &root_theirs).into_iter().filter(|(k, v)| *k == "size" || !follows(k, v, &root_theirs))); - if of.len() > 1 { - overrides.push(o(of)); - } - for r in rows { - let (mut of, mut df): (Fields, Fields) = (vec![("guidPath", r.path.clone())], vec![("guidPath", r.path)]); - for (k, v) in r.changed { - let follows = follows(k, &v, &r.theirs); - // A nested instance's size is its own to set, as is a size the layer overrides. - if follows && r.sized && k == "size" { - of.push((k, v.clone())); - } - if follows { &mut df } else { &mut of }.push((k, v)); - } - if df.len() > 1 { - derived.push(o(df)); - } - of.extend(r.swap.map(|g| ("overriddenSymbolID", g))); - if of.len() > 1 { - overrides.push(o(of)); - } - } - f.push(("symbolData", o(vec![("symbolID", symbol), ("symbolOverrides", arr(overrides)), ("uniformScaleFactor", fl(factor.unwrap_or(1.0)))]))); - if !derived.is_empty() { - f.push(("derivedSymbolData", arr(derived))); - } - let mut assignments = Vec::new(); - for (name, v) in props { - let (ty, value, var) = match v { - Value::Bool(x) => ("BOOL", o(vec![("boolValue", b(*x))]), bool_data(*x)), - Value::String(t) => ("TEXT", o(vec![("textValue", text_value(t))]), text_data(t)), - Value::Number(c) => { - let Some(g) = c.as_u64().and_then(|c| self.guid_of(NodeId(c))) else { continue }; - ("INSTANCE_SWAP", o(vec![("guidValue", g.clone())]), symbol_data(g)) - } - _ => continue, - }; - // Variant values have no definition of their own: the main they show says them. A - // variant's instance names the variant's own copy of the definition. - if let Some(id) = self.def_for(main, name, ty) { - assignments.push(o(vec![("defID", guid(AUX, id)), ("value", value), ("varValue", var)])); - } - } - if !assignments.is_empty() { - f.push(("componentPropAssignments", arr(assignments))); - } - if n.children.iter().filter_map(|c| doc.node(*c)).any(|c| c.source.is_none()) { - self.warn("instance layers that don't come from their main component were left out"); - } - } - - /// The main component layer an instance or instance layer mirrors (through nested instances), - /// or `id` itself. - fn main_layer(&self, id: NodeId) -> NodeId { - let mut cur = id; - for _ in 0..32 { - let next = self.doc.node(cur).and_then(|n| match &n.kind { - _ if n.source.is_some() => n.source, - NodeKind::Instance { component, .. } => Some(*component), - _ => None, - }); - match next.filter(|s| self.doc.contains(*s)) { - Some(s) => cur = s, - None => break, - } - } - cur - } - - /// The id path of layer `d` of instance `inst` (of `main`): that of the main's layer it mirrors, - /// or, inside a nested instance swapped for another component, the path of that instance and - /// then the one inside its new main. - fn layer_path(&self, d: &Node, inst: NodeId, main: NodeId, depth: usize) -> Option> { - let src = d.source?; - if let Some(p) = self.path_in(src, main, 0) { - return Some(p); - } - if depth > 32 { - return None; - } - let doc = self.doc; - let nested = doc.ancestors(d.id).into_iter().take_while(|a| *a != inst).filter_map(|a| doc.node(a)).find_map(|a| match &a.kind { - NodeKind::Instance { component, .. } => Some((a, *component)), - _ => None, - }); - let (outer, component) = nested?; - let mut p = self.layer_path(outer, inst, main, depth + 1)?; - p.extend(self.path_in(src, component, 0)?); - Some(p) - } - - /// The id path of layer `s` inside component `comp`: `s` itself, or through the nested - /// instance it is expanded in, then on inside that instance's main. - fn path_in(&self, s: NodeId, comp: NodeId, depth: usize) -> Option> { - if depth > 32 { - return None; - } - let doc = self.doc; - let up = doc.ancestors(s); - let below: Vec = up.iter().take_while(|a| **a != comp).copied().collect(); - if below.len() == up.len() { - return None; - } - // The outermost nested instance between the component and the layer. - match below.iter().rev().find_map(|a| match doc.node(*a).map(|n| &n.kind) { - Some(NodeKind::Instance { component, .. }) => Some((*a, *component)), - _ => None, - }) { - None => Some(vec![s]), - Some((inst, main)) => { - let mut p = vec![inst]; - p.extend(self.path_in(doc.node(s)?.source?, main, depth + 1)?); - Some(p) - } - } - } - - /// Interactions, scrolling, flow starts and the overlay settings this layer is opened with. - fn prototype(&mut self, n: &Node, f: &mut Fields) { - self.trigger_size = [n.width, n.height]; - let list: Vec = n.interactions.iter().filter_map(|it| self.interaction(it)).collect(); - if !list.is_empty() { - f.push(("prototypeInteractions", arr(list))); - } - let dir = match n.overflow { - Overflow::None => None, - Overflow::Horizontal => Some("HORIZONTAL"), - Overflow::Vertical => Some("VERTICAL"), - Overflow::Both => Some("BOTH"), - }; - f.extend(dir.map(|d| ("scrollDirection", e(d)))); - let behavior = match n.scroll_position { - ScrollPosition::Scrolls => None, - ScrollPosition::Fixed => Some("FIXED_WHEN_CHILD_OF_SCROLLING_FRAME"), - ScrollPosition::Sticky => Some("STICKY_SCROLLS"), - }; - f.extend(behavior.map(|x| ("scrollBehavior", e(x)))); - if let Some((i, name)) = self.starts.get(&n.id) { - f.push(("prototypeStartingPoint", o(vec![("name", s(name)), ("position", s(&position(*i)))]))); - } - if let Some(ov) = self.overlays.get(&n.id) { - const POSITIONS: [&str; 8] = ["CENTER", "TOP_LEFT", "TOP_CENTER", "TOP_RIGHT", "BOTTOM_LEFT", "BOTTOM_CENTER", "BOTTOM_RIGHT", "MANUAL"]; - let pos = POSITIONS.into_iter().find(|p| crate::rest::proto::overlay_position(p) == Some(ov.position)).unwrap_or("CENTER"); - let bg = match ov.background { - Some(c) => o(vec![("backgroundType", e("SOLID_COLOR")), ("backgroundColor", color(c))]), - None => o(vec![("backgroundType", e("NONE"))]), - }; - f.extend([ - ("overlayPositionType", e(pos)), - ("overlayBackgroundInteraction", e(if ov.close_on_outside { "CLOSE_ON_CLICK_OUTSIDE" } else { "NONE" })), - ("overlayBackgroundAppearance", bg), - ]); - } - } - - fn interaction(&mut self, it: &Interaction) -> Option> { - let mut event: Fields = Vec::new(); - let ty = match &it.trigger { - Trigger::OnClick => "ON_CLICK", - Trigger::OnHover => "ON_HOVER", - Trigger::OnPress => "ON_PRESS", - Trigger::OnDrag => "DRAG", - Trigger::MouseEnter => "MOUSE_IN", - Trigger::MouseLeave => "MOUSE_OUT", - Trigger::MouseDown => "MOUSE_DOWN", - Trigger::MouseUp => "MOUSE_UP", - Trigger::AfterDelay { ms } => { - event.push(("transitionTimeout", fl(f64::from(*ms) / 1000.0))); - "AFTER_TIMEOUT" - } - Trigger::KeyPress { key } => { - let Some((codes, pad)) = key_codes(key) else { - self.warn("key triggers on keys the format has no code for were left out"); - return None; - }; - let device = e(if pad { "UNKNOWN_CONTROLLER" } else { "KEYBOARD" }); - event.push(("keyTrigger", o(vec![("keyCodes", arr(codes.into_iter().map(int).collect())), ("triggerDevice", device)]))); - "ON_KEY_DOWN" - } - }; - // A mouse trigger's delay is the event's timeout. - if it.delay_secs() > 0.0 { - event.push(("transitionTimeout", fl(it.delay_secs()))); - } - event.insert(0, ("interactionType", e(ty))); - let actions: Vec = it.actions.iter().filter_map(|a| self.action(a, it)).collect(); - (!actions.is_empty()).then(|| o(vec![("event", o(event)), ("actions", arr(actions))])) - } - - fn action(&mut self, a: &Action, it: &Interaction) -> Option> { - let node = |x: &mut Self, dest: &NodeId, nav: &'static str| -> Option> { - let Some(g) = x.guid_of(*dest) else { - x.warn("prototype actions whose destination isn't written were left out"); - return None; - }; - let mut f: Fields = vec![("transitionNodeID", g), ("connectionType", e("INTERNAL_NODE")), ("navigationType", e(nav))]; - x.transition(it, &mut f); - Some(o(f)) - }; - let plain = |ty: &'static str| Some(o(vec![("connectionType", e(ty))])); - match a { - Action::Navigate { dest, preserve_scroll } => { - let mut act = node(self, dest, "NAVIGATE")?; - if let Kv::Object(f) = &mut act { - f.push(("transitionPreserveScroll", b(*preserve_scroll))); - } - Some(act) - } - Action::OpenOverlay { dest, position, offset, anchor, .. } => { - let mut act = node(self, dest, "OVERLAY")?; - let size = self.doc.node(*dest).map_or([0.0, 0.0], |d| [d.width, d.height]); - let offset = match anchor.offset(*position, self.trigger_size, size) { - Some(o) => Some(o), - None if *position == OverlayPosition::Manual => *offset, - None => None, - }; - if let (Kv::Object(f), Some([x, y])) = (&mut act, offset) { - f.push(("overlayRelativePosition", o(vec![("x", fl(x)), ("y", fl(y))]))); - } - Some(act) - } - Action::SwapOverlay { dest } => node(self, dest, "SWAP"), - Action::ChangeTo { dest } => node(self, dest, "SWAP_STATE"), - Action::ScrollTo { dest, offset } => { - if *offset != [0.0, 0.0] { - self.warn("scroll-to offsets were left out"); - } - node(self, dest, "SCROLL_TO") - } - Action::Back => plain("BACK"), - Action::CloseOverlay => plain("CLOSE"), - Action::OpenUrl { url } => Some(o(vec![("connectionType", e("URL")), ("connectionURL", s(url))])), - Action::SetVariable { .. } | Action::SetVariableMode { .. } | Action::Conditional { .. } => { - self.warn("variable and conditional prototype actions were left out"); - None - } - } - } - - /// An interaction's transition on one of its actions. - fn transition(&mut self, it: &Interaction, f: &mut Fields) { - let side_from = |d: Direction, l: &'static str, r: &'static str, t: &'static str, bo: &'static str| match d { - // The direction names where the incoming screen moves: it comes from the other side. - Direction::Right => l, - Direction::Left => r, - Direction::Bottom => t, - Direction::Top => bo, - }; - let side_to = |d: Direction, l: &'static str, r: &'static str, t: &'static str, bo: &'static str| match d { - Direction::Left => l, - Direction::Right => r, - Direction::Top => t, - Direction::Bottom => bo, - }; - let ty = match it.transition { - Transition::Instant => "INSTANT_TRANSITION", - Transition::Dissolve => "DISSOLVE", - Transition::SmartAnimate => "SMART_ANIMATE", - Transition::SlideIn { direction } => side_from(direction, "SLIDE_FROM_LEFT", "SLIDE_FROM_RIGHT", "SLIDE_FROM_TOP", "SLIDE_FROM_BOTTOM"), - Transition::Push { direction } => side_from(direction, "PUSH_FROM_LEFT", "PUSH_FROM_RIGHT", "PUSH_FROM_TOP", "PUSH_FROM_BOTTOM"), - Transition::MoveIn { direction } => side_from(direction, "MOVE_FROM_LEFT", "MOVE_FROM_RIGHT", "MOVE_FROM_TOP", "MOVE_FROM_BOTTOM"), - Transition::SlideOut { direction } => { - side_to(direction, "SLIDE_OUT_TO_LEFT", "SLIDE_OUT_TO_RIGHT", "SLIDE_OUT_TO_TOP", "SLIDE_OUT_TO_BOTTOM") - } - Transition::MoveOut { direction } => side_to(direction, "MOVE_OUT_TO_LEFT", "MOVE_OUT_TO_RIGHT", "MOVE_OUT_TO_TOP", "MOVE_OUT_TO_BOTTOM"), - }; - let easing = match it.easing { - Easing::Linear => "LINEAR", - Easing::EaseIn => "IN_CUBIC", - Easing::EaseOut => "OUT_CUBIC", - Easing::EaseInOut => "INOUT_CUBIC", - Easing::EaseInBack => "IN_BACK_CUBIC", - Easing::EaseOutBack => "OUT_BACK_CUBIC", - Easing::EaseInOutBack => "INOUT_BACK_CUBIC", - Easing::Gentle => "GENTLE_SPRING", - Easing::CustomBezier { .. } => "CUSTOM_CUBIC", - Easing::Quick | Easing::Bouncy | Easing::Slow | Easing::CustomSpring { .. } => { - self.warn("spring animations other than Gentle were written as ease out"); - "OUT_CUBIC" - } - }; - f.extend([("transitionType", e(ty)), ("transitionDuration", fl(f64::from(it.duration_ms) / 1000.0)), ("easingType", e(easing))]); - if let Easing::CustomBezier { x1, y1, x2, y2 } = it.easing { - f.push(("easingFunction", arr(vec![fl(x1), fl(y1), fl(x2), fl(y2)]))); - } - } } /// The plugin id and key under which a layer written as an outline keeps what it was. pub(super) const STASH: (&str, &str) = ("figcraft", "stroke"); -/// Longest kept description of such a layer (bytes of JSON). -const MAX_STASH: usize = 1 << 20; - -/// Whether a width profile reaches further on one side of the path than on the other. -fn one_sided(p: &figcraft_doc::WidthProfile) -> bool { - p.points.iter().any(|q| q.bias.abs() > 1e-6) -} - -/// A shape whose stroke is wider on one side of its path, as the area that stroke paints (a vector -/// filled with the stroke's paints), with what it was as JSON: its kind, paints and stroke. Only -/// for shapes that show nothing but that stroke; `None` for every other layer. -fn outlined(n: &Node) -> Option<(Node, String)> { - let sp = &n.stroke; - let shape = matches!( - n.kind, - NodeKind::Line | NodeKind::Vector { .. } | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle - ); - let shown = |paints: &[Paint]| paints.iter().any(|p| p.visible); - if !(shape - && sp.brush.is_none() - && sp.profile().is_some_and(one_sided) - && sp.weight.is_finite() - && sp.weight > 0.0 - && shown(&n.strokes) - && !shown(&n.fills)) - { - return None; - } - let path = figcraft_doc::geometry::stroke_geometry(n)?; - let net = match &n.kind { - NodeKind::Vector { network } => Some(network), - _ => None, - }; - let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net); - if area.elements().is_empty() || area.elements().len() > MAX_NETWORK / 4 { - return None; - } - let original = serde_json::json!({"kind": n.kind, "fills": n.fills, "strokes": n.strokes, "stroke": n.stroke}).to_string(); - if original.len() > MAX_STASH { - return None; - } - let mut network = VectorNetwork::from_path(&area); - for r in &mut network.regions { - r.winding = WindingRule::NonZero; - } - let mut v = n.clone(); - v.kind = NodeKind::Vector { network }; - v.fills = std::mem::take(&mut v.strokes); - v.stroke = Default::default(); - v.styles.fill = v.styles.stroke.take(); - // Bindings name the stroke's paints, which are the fills now: the values stay. - v.bound.retain(|k, _| !(k == "stroke" || k == "fill" || k.starts_with("strokes/") || k.starts_with("fills/") || k.starts_with("stroke"))); - Some((v, original)) -} - -/// The number of the layer field a bound property (`VariableField`) is held in. -fn node_field(field: &str) -> Option { - let name = match field { - "WIDTH" | "HEIGHT" => "size", - "MIN_WIDTH" | "MIN_HEIGHT" => "minSize", - "MAX_WIDTH" | "MAX_HEIGHT" => "maxSize", - "STACK_SPACING" => "stackSpacing", - "STACK_COUNTER_SPACING" => "stackCounterSpacing", - "STACK_PADDING_LEFT" => "stackHorizontalPadding", - "STACK_PADDING_TOP" => "stackVerticalPadding", - "STACK_PADDING_RIGHT" => "stackPaddingRight", - "STACK_PADDING_BOTTOM" => "stackPaddingBottom", - "CORNER_RADIUS" => "cornerRadius", - "RECTANGLE_TOP_LEFT_CORNER_RADIUS" => "rectangleTopLeftCornerRadius", - "RECTANGLE_TOP_RIGHT_CORNER_RADIUS" => "rectangleTopRightCornerRadius", - "RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS" => "rectangleBottomLeftCornerRadius", - "RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS" => "rectangleBottomRightCornerRadius", - "OPACITY" => "opacity", - "VISIBLE" => "visible", - "TEXT_DATA" => "textData", - "STROKE_WEIGHT" => "strokeWeight", - "BORDER_TOP_WEIGHT" => "borderTopWeight", - "BORDER_RIGHT_WEIGHT" => "borderRightWeight", - "BORDER_BOTTOM_WEIGHT" => "borderBottomWeight", - "BORDER_LEFT_WEIGHT" => "borderLeftWeight", - "FONT_SIZE" => "fontSize", - "FONT_FAMILY" | "FONT_STYLE" => "fontName", - "LETTER_SPACING" => "letterSpacing", - "LINE_HEIGHT" => "lineHeight", - "PARAGRAPH_SPACING" => "paragraphSpacing", - "PARAGRAPH_INDENT" => "paragraphIndent", - _ => return None, - }; - // The numbers are the schema's. - let layer = SCHEMA.lines().find(|l| l.starts_with("message NodeChange "))?; - layer.split(", ").find_map(|f| f.rsplit_once(' ')?.1.strip_prefix(name)?.strip_prefix('=')?.parse().ok()) -} - -/// Whether `a` is `b` with its lengths scaled by `k`: every number is `k` times the other's (within -/// `slack`, for sizes that snap to whole pixels) or the same, and the rest is equal. -fn scaled(a: &Kv<'static>, b: &Kv<'static>, k: f64, slack: f64) -> bool { - match (a, b) { - (Kv::Float(x), Kv::Float(y)) => { - let (x, y) = (f64::from(*x), f64::from(*y)); - x == y || (x - y * k).abs() <= slack + 1e-3 * x.abs().max(1.0) - } - (Kv::Array(x), Kv::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| scaled(p, q, k, slack)), - (Kv::Object(x), Kv::Object(y)) => x.len() == y.len() && x.iter().zip(y).all(|((kp, p), (kq, q))| kp == kq && scaled(p, q, k, slack)), - _ => a == b, - } -} - -/// The fields of `mine` that differ from `theirs` (a layer and the main's layer it mirrors), and -/// the ones only `theirs` has at their empty values. -fn changed(mine: Fields, theirs: &Fields) -> Fields { - let cleared: Fields = theirs.iter().filter(|(k, _)| !mine.iter().any(|(mk, _)| mk == k)).filter_map(|(k, _)| Some((*k, unset(k)?))).collect(); - let mut out: Fields = mine.into_iter().filter(|(k, v)| !theirs.iter().any(|(tk, tv)| tk == k && tv == v)).collect(); - out.extend(cleared); - out -} - -/// Whether paint list `v` (field `key`) is `theirs` but for the colours variables give: two layers -/// bound alike are the same, whatever mode each one shows. -fn resolved_alike(key: &str, v: &Kv<'static>, theirs: &Fields) -> bool { - fn unresolved(v: &Kv<'static>) -> Vec> { - let bare = |p: &Kv<'static>| match p { - Kv::Object(f) if f.iter().any(|(k, _)| *k == "colorVar") => o(f.iter().filter(|(k, _)| *k != "color").cloned().collect()), - other => other.clone(), - }; - v.list().iter().map(bare).collect() - } - matches!(key, "fillPaints" | "strokePaints") && theirs.iter().any(|(k, t)| *k == key && unresolved(t) == unresolved(v)) -} - -/// The format's name of a variable type. -fn resolved_type(kind: VariableType) -> &'static str { - match kind { - VariableType::Color => "COLOR", - VariableType::Number => "FLOAT", - VariableType::String => "STRING", - VariableType::Boolean => "BOOLEAN", - } -} - -/// The format's name of a variable scope (`None`: it has none). -fn scope_name(scope: &str) -> Option<&'static str> { - const NAMES: [&str; 20] = [ - "ALL_SCOPES", - "TEXT_CONTENT", - "CORNER_RADIUS", - "WIDTH_HEIGHT", - "GAP", - "ALL_FILLS", - "FRAME_FILL", - "SHAPE_FILL", - "TEXT_FILL", - "STROKE_FLOAT", - "EFFECT_FLOAT", - "EFFECT_COLOR", - "OPACITY", - "FONT_STYLE", - "FONT_FAMILY", - "FONT_SIZE", - "LINE_HEIGHT", - "LETTER_SPACING", - "PARAGRAPH_SPACING", - "PARAGRAPH_INDENT", - ]; - if scope == "STROKE_COLOR" { - return Some("STROKE"); - } - NAMES.into_iter().find(|n| *n == scope) -} - -/// The empty value of a field only written when set (an override clears it with this). -fn unset(key: &str) -> Option> { - Some(match key { - "styleIdForFill" | "styleIdForStrokeFill" | "styleIdForEffect" | "styleIdForGrid" | "styleIdForText" => { - o(vec![("guid", guid(u32::MAX, u32::MAX))]) - } - "variableConsumptionMap" | "variableModeBySetMap" | "gridColumnsSizing" | "gridRowsSizing" => o(vec![("entries", arr(Vec::new()))]), - "mask" | "rectangleCornerRadiiIndependent" | "borderStrokeWeightsIndependent" => b(false), - "effects" | "dashPattern" | "layoutGrids" | "exportSettings" | "prototypeInteractions" | "fillGeometry" | "variableWidthPoints" => { - arr(Vec::new()) - } - "cornerSmoothing" | "stackChildPrimaryGrow" => fl(0.0), - "stackMode" => e("NONE"), - "stackChildAlignSelf" | "stackPositioning" => e("AUTO"), - "minSize" => o(vec![("value", xy(0.0, 0.0))]), - // No maximum is infinite (a maximum of 0 collapses the layer). - "maxSize" => o(vec![("value", o(vec![("x", Kv::Float(f32::INFINITY)), ("y", Kv::Float(f32::INFINITY))]))]), - "gridColumnSpan" | "gridRowSpan" | "maxLines" => int(0), - "scrollDirection" => e("NONE"), - "scrollBehavior" => e("SCROLLS"), - "parameterConsumptionMap" => o(vec![("entries", arr(Vec::new()))]), - "textTruncation" => e("DISABLED"), - "hyperlink" => o(vec![("url", s(""))]), - _ => return None, - }) -} /// An image's name in the file: the SHA-1 of its bytes (lowercase hex). Paints refer to images by /// these 20 bytes, and readers reject other hash lengths (the document's own SHA-256 key is not @@ -2673,168 +629,3 @@ pub(super) fn face_style(family: &str, style: &str) -> String { } ["Extra", "Ultra", "Semi", "Demi"].iter().fold(standard, |acc, p| acc.replace(&format!("{p} "), p)) } - -/// The pairs of a variant's name, `Property=Value, …` (parts without `=` are left out). -fn variant_pairs(name: &str) -> Vec<(String, String)> { - name.split(',') - .filter_map(|part| { - let (p, v) = part.split_once('=')?; - let (p, v) = (p.trim(), v.trim()); - (!p.is_empty()).then(|| (p.to_string(), v.to_string())) - }) - .collect() -} - -/// The `index`-th sort position of a list: one printable character from `"` on, two past the end -/// of that range. -fn sort_position(index: usize) -> String { - const FIRST: u32 = 34; - const SPAN: usize = 93; - let ch = |i: usize| char::from_u32(FIRST + u32::try_from(i % SPAN).unwrap_or(0)).unwrap_or('"'); - if index < SPAN { ch(index).to_string() } else { format!("~{}", ch(index - SPAN)) } -} - -/// Text as a property value: its characters and one plain line per paragraph. -fn text_value(t: &str) -> Kv<'static> { - let line = || { - o(vec![ - ("lineType", e("PLAIN")), - ("styleId", int(0)), - ("indentationLevel", int(0)), - ("sourceDirectionality", e("AUTO")), - ("listStartOffset", int(0)), - ("isFirstLineOfList", b(false)), - ]) - }; - o(vec![("characters", s(t)), ("lines", arr(t.split('\n').map(|_| line()).collect()))]) -} - -/// A property value as variable data (`varValue`). -fn var_data(value: (&'static str, Kv<'static>), ty: &'static str, resolved: &'static str) -> Kv<'static> { - o(vec![("value", o(vec![value])), ("dataType", e(ty)), ("resolvedDataType", e(resolved))]) -} -fn bool_data(x: bool) -> Kv<'static> { - var_data(("boolValue", b(x)), "BOOLEAN", "BOOLEAN") -} -fn text_data(t: &str) -> Kv<'static> { - var_data(("textDataValue", text_value(t)), "TEXT_DATA", "TEXT_DATA") -} -fn symbol_data(main: Kv<'static>) -> Kv<'static> { - var_data(("symbolIdValue", o(vec![("guid", main)])), "SYMBOL_ID", "SYMBOL_ID") -} - -/// The format's type of a component property (`None`: not written). -fn prop_type(d: &PropDef) -> Option<&'static str> { - match d { - PropDef::Boolean { .. } => Some("BOOL"), - PropDef::Text { .. } => Some("TEXT"), - PropDef::InstanceSwap { .. } => Some("INSTANCE_SWAP"), - PropDef::Variant { .. } | PropDef::Slot { .. } => None, - } -} - -/// The key codes of a key trigger (modifiers first), and whether it's a controller button. -fn key_codes(key: &str) -> Option<(Vec, bool)> { - if let Some(i) = GAMEPAD_BUTTONS.iter().position(|g| g.eq_ignore_ascii_case(key.trim())) { - return Some((vec![i as i64], true)); - } - let (mods, k): (KeyMods, &str) = KeyMods::split(key); - let code = (0..=255).find(|c| crate::rest::proto::key_name(f64::from(*c)).is_some_and(|n| n.eq_ignore_ascii_case(k)))?; - let mut codes: Vec = [(mods.shift, 16), (mods.ctrl, 17), (mods.alt, 18), (mods.cmd, 91)].into_iter().filter(|m| m.0).map(|m| m.1).collect(); - codes.push(i64::from(code)); - Some((codes, false)) -} - -/// Whether the decoration look has more than skip ink, which the file can't hold. -fn unsupported_decoration_look(st: &TextStyle) -> bool { - let mut look = st.look(); - look.skip_ink = false; - look != Default::default() -} - -/// The text style fields of a layer or of an override table entry. -fn style_fields(st: &TextStyle, f: &mut Fields) { - let line_height = match st.line_height { - // Percent units count from the font's own line height (100 % is "auto"); a share of the - // font size is a raw multiplier. - LineHeight::Auto => o(vec![("value", fl(100.0)), ("units", e("PERCENT"))]), - LineHeight::Percent(p) => o(vec![("value", fl(p / 100.0)), ("units", e("RAW"))]), - LineHeight::Pixels(v) => o(vec![("value", fl(v)), ("units", e("PIXELS"))]), - }; - let letter_spacing = match st.letter_spacing { - LetterSpacing::Percent(p) => o(vec![("value", fl(p)), ("units", e("PERCENT"))]), - LetterSpacing::Pixels(v) => o(vec![("value", fl(v)), ("units", e("PIXELS"))]), - }; - let case = match st.case { - TextCase::Original => "ORIGINAL", - TextCase::Upper => "UPPER", - TextCase::Lower => "LOWER", - TextCase::Title => "TITLE", - TextCase::SmallCaps => "SMALL_CAPS", - }; - let decoration = match st.decoration { - TextDecoration::None => "NONE", - TextDecoration::Underline => "UNDERLINE", - TextDecoration::Strikethrough => "STRIKETHROUGH", - }; - f.extend([ - ("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&face_style(&st.font_family, &st.font_style))), ("postscript", s(""))])), - ("fontSize", fl(st.font_size)), - ("lineHeight", line_height), - ("letterSpacing", letter_spacing), - ("textCase", e(case)), - ("textDecoration", e(decoration)), - // Skip ink only shows on an underline; other text gets the files' default (on), the value - // the import leaves out again. - ("textDecorationSkipInk", b(st.look().skip_ink || st.decoration != TextDecoration::Underline)), - ]); - if let Some(url) = &st.link { - f.push(("hyperlink", o(vec![("url", s(url))]))); - } -} - -/// Layout grid `index` of a layer, with the variables `bound` to its fields. -fn layout_grid(g: &figcraft_doc::LayoutGrid, index: usize, bound: &Bound) -> Kv<'static> { - let (pattern, axis) = match g.kind { - GridKind::Grid => ("GRID", "X"), - GridKind::Columns => ("STRIPES", "X"), - GridKind::Rows => ("STRIPES", "Y"), - }; - let align = match g.align { - GridAlign::Stretch => "STRETCH", - GridAlign::Min => "MIN", - GridAlign::Center => "CENTER", - GridAlign::Max => "MAX", - }; - let mut f: Fields = vec![ - ("type", e(align)), - ("axis", e(axis)), - ("visible", b(g.visible)), - ("numSections", int(g.count.into())), - ("offset", fl(g.offset)), - ("sectionSize", fl(g.size)), - ("gutterSize", fl(g.gutter)), - ("color", color(g.color)), - ("pattern", e(pattern)), - ]; - let vars = [("count", "numSectionsVar"), ("offset", "offsetVar"), ("size", "sectionSizeVar"), ("gutter", "gutterSizeVar")]; - f.extend(vars.into_iter().filter_map(|(ours, key)| Some((key, bound.grids.get(&(index, ours))?.clone())))); - o(f) -} - -fn export_setting(x: &figcraft_doc::ExportSetting) -> Kv<'static> { - let format = match x.format { - // The format has no WebP export setting. - figcraft_doc::ExportFormat::Png | figcraft_doc::ExportFormat::Webp => "PNG", - figcraft_doc::ExportFormat::Jpg => "JPEG", - // Nor an Android Vector Drawable one: the nearest is the other vector format. - figcraft_doc::ExportFormat::Svg | figcraft_doc::ExportFormat::VectorDrawable => "SVG", - figcraft_doc::ExportFormat::Pdf => "PDF", - }; - let (ty, value) = match x.size { - ExportSize::Scale { value } => ("CONTENT_SCALE", value), - ExportSize::Width { value } => ("CONTENT_WIDTH", value), - ExportSize::Height { value } => ("CONTENT_HEIGHT", value), - }; - o(vec![("suffix", s(&x.suffix)), ("imageType", e(format)), ("constraint", o(vec![("type", e(ty)), ("value", fl(value))]))]) -} diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs new file mode 100644 index 00000000..00c84639 --- /dev/null +++ b/crates/format/src/fig/export/assets.rs @@ -0,0 +1,365 @@ +use super::common::{layout_grid, node_field, prop_type, resolved_type, scope_name, variant_pairs}; +use super::*; + +impl<'d> Exporter<'d> { + /// GUIDs for every layer (pages first, then depth-first), component property definitions, + /// flow starting points and overlay settings, before anything refers to them. + pub(super) fn assign(&mut self) { + let doc = self.doc; + let mut stack: Vec = doc.pages.iter().rev().copied().collect(); + while let Some(id) = stack.pop() { + let Some(n) = doc.node(id) else { continue }; + if self.ids.len() >= MAX_NODES { + self.warn("the document has more layers than a .fig file is read with; the rest were left out"); + break; + } + let local = u32::try_from(self.ids.len() + 1).unwrap_or(u32::MAX); + self.ids.insert(id, local); + // An instance's layers aren't written (its main says them), so nothing may refer to them. + let mirrored = matches!(&n.kind, NodeKind::Instance { component, .. } if doc.node(*component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))); + if !mirrored { + stack.extend(n.children.iter().rev()); + } + match &n.kind { + NodeKind::Component { props, .. } => { + let set = self.set_of(id); + let owner = set.unwrap_or(id); + for (name, d) in props { + if let Some(ty) = prop_type(d) { + let key = (owner, name.clone(), ty); + if !self.defs.contains_key(&key) { + let def = self.next_aux(); + self.defs.insert(key, def); + } + if set.is_some() { + let child = self.next_aux(); + self.child_defs.insert((id, name.clone(), ty), child); + } + } + } + if let Some(set) = set { + for (prop, _) in variant_pairs(&n.name) { + let key = (set, prop, "VARIANT"); + if !self.defs.contains_key(&key) { + let def = self.next_aux(); + self.defs.insert(key, def); + } + } + } + } + NodeKind::Page { flows, .. } => { + for (i, f) in flows.iter().enumerate() { + self.starts.entry(f.start).or_insert((i, f.name.clone())); + } + } + _ => {} + } + for a in n.interactions.iter().flat_map(|it| figcraft_doc::proto::walk(&it.actions)) { + if let Action::OpenOverlay { dest, position, close_on_outside, background, anchor, .. } = a { + let position = if !anchor.is_screen() && *position != OverlayPosition::Manual { OverlayPosition::Manual } else { *position }; + let o = Overlay { position, close_on_outside: *close_on_outside, background: *background }; + match self.overlays.get(dest) { + Some(old) if (old.position, old.close_on_outside, old.background) != (o.position, o.close_on_outside, o.background) => { + self.warn("overlays opened with different settings keep the first one's"); + } + Some(_) => {} + None => { + self.overlays.insert(*dest, o); + } + } + } + } + } + // The page that holds styles and variables takes the next id, then their nodes. + let mut next = u32::try_from(self.ids.len() + 1).unwrap_or(u32::MAX); + let mut node = || { + next = next.saturating_add(1); + next + }; + for st in &doc.styles { + self.style_ids.entry(st.id.as_str()).or_insert_with(&mut node); + } + for c in &doc.variables.collections { + self.set_ids.entry(c.id.as_str()).or_insert_with(&mut node); + } + for v in &doc.variables.variables { + if self.set_ids.contains_key(v.collection.as_str()) { + self.var_ids.entry(v.id.as_str()).or_insert_with(&mut node); + } + } + for c in &doc.variables.collections { + for m in &c.modes { + if !self.mode_ids.contains_key(&(c.id.as_str(), m.id.as_str())) { + let id = self.next_aux(); + self.mode_ids.insert((c.id.as_str(), m.id.as_str()), id); + } + } + } + } + + /// The hidden page after the `pages` real ones that holds the shared styles, the variable + /// collections and the variables, each a node of its own. + pub(super) fn assets(&mut self, pages: usize) { + let doc = self.doc; + if self.style_ids.is_empty() && self.set_ids.is_empty() { + return; + } + let page = u32::try_from(self.ids.len() + 1).unwrap_or(u32::MAX); + let mut f: Fields = vec![ + ("guid", guid(LAYERS, page)), + ("phase", e("CREATED")), + ("parentIndex", o(vec![("guid", guid(0, 0)), ("position", s(&position(pages)))])), + ("type", e("CANVAS")), + ("name", s("Internal Only Canvas")), + ]; + f.extend(root_fields().into_iter().map(|(k, v)| if k == "visible" { (k, b(false)) } else { (k, v) })); + f.push(("internalOnly", b(true))); + self.nodes.push(o(f)); + let mut index = 0usize; + let mut head = |local: u32, ty: &'static str, name: &str| -> Fields { + index += 1; + vec![ + ("guid", guid(LAYERS, local)), + ("phase", e("CREATED")), + ("parentIndex", o(vec![("guid", guid(LAYERS, page)), ("position", s(&position(index)))])), + ("type", e(ty)), + ("name", s(name)), + ("sortPosition", s(&position(index))), + ] + }; + for st in &doc.styles { + let Some(&local) = self.style_ids.get(st.id.as_str()) else { continue }; + // A sample layer that shows the style: a square for paints and effects, a frame for + // grids, two letters for text. + let (ty, kind) = match &st.kind { + StyleKind::Paint { .. } => ("ROUNDED_RECTANGLE", "FILL"), + StyleKind::Effect { .. } => ("ROUNDED_RECTANGLE", "EFFECT"), + StyleKind::Grid { .. } => ("FRAME", "GRID"), + StyleKind::Text { .. } => ("TEXT", "TEXT"), + }; + let mut f = head(local, ty, &st.name); + f.extend([ + ("styleType", e(kind)), + ("visible", b(true)), + ("opacity", fl(1.0)), + ("size", xy(100.0, 100.0)), + ("transform", matrix(Affine::IDENTITY)), + ("strokeWeight", fl(0.0)), + ("strokeAlign", e("INSIDE")), + ("strokeJoin", e("MITER")), + ]); + if !st.description.is_empty() { + f.push(("description", s(&st.description))); + } + // The style's variable bindings ride on its sample layer, as a layer's do. + let none = if st.bound.is_empty() { + Bound::default() + } else { + let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0); + if let StyleKind::Text { style } = &st.kind { + sample.kind = NodeKind::Text(TextData { style: style.as_text_style(), ..TextData::new("Ag") }); + } + sample.bound.clone_from(&st.bound); + self.bound(&sample) + }; + let entries = none.entries.clone(); + if !entries.is_empty() { + f.push(("variableConsumptionMap", o(vec![("entries", arr(entries))]))); + } + match &st.kind { + StyleKind::Paint { paints } => f.push(("fillPaints", self.paints(paints, true, &none))), + StyleKind::Effect { effects } => { + let list = effects.iter().enumerate().map(|(i, x)| self.effect(x, i, &none)).collect(); + f.extend([("fillPaints", arr(Vec::new())), ("effects", arr(list))]); + } + StyleKind::Grid { grids } => { + let list = grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &none)).collect(); + f.extend([("fillPaints", arr(Vec::new())), ("layoutGrids", arr(list))]); + } + StyleKind::Text { style } => { + let td = TextData { style: style.as_text_style(), ..TextData::new("Ag") }; + let fills = [Paint::solid(Color::BLACK)]; + f.push(("fillPaints", self.paints(style.fills.as_deref().unwrap_or(&fills), true, &none))); + self.text(&td, &fills, &mut f); + } + } + self.nodes.push(o(f)); + } + for c in &doc.variables.collections { + let Some(&local) = self.set_ids.get(c.id.as_str()) else { continue }; + let mut f = head(local, "VARIABLE_SET", &c.name); + let modes = c.modes.iter().enumerate().filter_map(|(i, m)| { + let id = self.mode_ids.get(&(c.id.as_str(), m.id.as_str()))?; + Some(o(vec![("id", guid(AUX, *id)), ("name", s(&m.name)), ("sortPosition", s(&position(i)))])) + }); + f.push(("variableSetModes", arr(modes.collect()))); + self.nodes.push(o(f)); + if c.extends.is_some() { + self.warn("extended variable collections were written as collections of their own"); + } + } + for v in &doc.variables.variables { + let ids = (self.var_ids.get(v.id.as_str()), self.set_ids.get(v.collection.as_str())); + let (Some(&local), Some(&set), Some(c)) = (ids.0, ids.1, doc.variables.collections.iter().find(|c| c.id == v.collection)) else { + self.warn("variables whose collection isn't in the document were left out"); + continue; + }; + let resolved = resolved_type(v.kind); + let mut values = Vec::new(); + for m in &c.modes { + let (Some(mode), Some(val)) = (self.mode_ids.get(&(c.id.as_str(), m.id.as_str())), v.values.get(&m.id)) else { continue }; + let (ty, value) = match val { + VariableValue::Color(x) => ("COLOR", ("colorValue", color(*x))), + VariableValue::Number(x) => ("FLOAT", ("floatValue", fl(*x))), + // NULs can't be stored in a string. + VariableValue::String(x) => ("STRING", ("textValue", s(&x.replace('\0', "")))), + VariableValue::Boolean(x) => ("BOOLEAN", ("boolValue", b(*x))), + VariableValue::Alias(a) => match self.var_ids.get(a.as_str()) { + Some(t) => ("ALIAS", ("alias", o(vec![("guid", guid(LAYERS, *t))]))), + None => { + self.warn("variable aliases to variables that aren't in the document were left out"); + continue; + } + }, + }; + let data = o(vec![("value", o(vec![value])), ("dataType", e(ty)), ("resolvedDataType", e(resolved))]); + values.push(o(vec![("modeID", guid(AUX, *mode)), ("variableData", data)])); + } + let mut f = head(local, "VARIABLE", &v.name); + f.extend([ + ("variableSetID", o(vec![("guid", guid(LAYERS, set))])), + ("variableResolvedType", e(resolved)), + ("variableDataValues", o(vec![("entries", arr(values))])), + ]); + if !v.description.is_empty() { + f.push(("description", s(&v.description))); + } + if let Some(scopes) = &v.scopes { + let known: Vec = scopes.iter().filter_map(|sc| scope_name(sc)).map(e).collect(); + if known.len() < scopes.len() { + self.warn("variable scopes the format has no name for were left out"); + } + f.push(("variableScopes", arr(known))); + } + if v.hidden_from_publishing || !v.code_syntax.is_empty() || v.extends.is_some() { + self.warn("variables' publishing, code syntax and inheritance settings were left out"); + } + self.nodes.push(o(f)); + } + } + + /// A reference to variable `var` (its id or name) as variable data. + fn alias(&mut self, var: &str) -> Option> { + let found = self.doc.variables.get(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?))); + let Some((kind, local)) = found else { + self.warn("bindings to variables that aren't in the document were written as their values"); + return None; + }; + Some(o(vec![ + ("value", o(vec![("alias", o(vec![("guid", guid(LAYERS, local))]))])), + ("dataType", e("ALIAS")), + ("resolvedDataType", e(resolved_type(kind))), + ])) + } + + /// Layer `n`'s variable bindings, sorted into the fields that carry them. What the format has + /// no field for stays as the value the layer already shows, with a warning. + pub(super) fn bound(&mut self, n: &Node) -> Bound { + const BAKED: &str = "variables bound to properties the format has no field for were written as their values"; + let mut out = Bound::default(); + for (key, var) in &n.bound { + let parts: Vec<&str> = key.split('/').collect(); + let index = |i: &str| i.parse::().ok().filter(|_| i.bytes().all(|c| c.is_ascii_digit())); + let fields: &[&'static str] = match (parts.as_slice(), key.as_str()) { + (_, "fill" | "fills" | "fills/0" | "stroke") => { + if let Some(a) = self.alias(var) { + out.paints.insert((key != "stroke", 0), a); + } + continue; + } + ([list @ ("fills" | "strokes"), i, "color"], _) => { + if let (Some(i), Some(a)) = (index(i), self.alias(var)) { + out.paints.insert((*list == "fills", i), a); + } + continue; + } + ([list @ ("fills" | "strokes"), i, "stops", j, "color"], _) => { + if let (Some(i), Some(j), Some(a)) = (index(i), index(j), self.alias(var)) { + out.stops.insert((*list == "fills", i, j), a); + } + continue; + } + ([list @ ("effects" | "layoutGrids"), i, field], _) => { + let names: &[&'static str] = if *list == "effects" { + &["color", "radius", "spread", "offsetX", "offsetY", "startRadius"] + } else { + &["size", "count", "gutter", "offset"] + }; + match (index(i), names.iter().find(|f| *f == field)) { + (Some(i), Some(name)) => { + if let Some(a) = self.alias(var) { + if *list == "effects" { &mut out.effects } else { &mut out.grids }.insert((i, *name), a); + } + } + _ => self.warn(BAKED), + } + continue; + } + (_, "width") => &["WIDTH"], + (_, "height") => &["HEIGHT"], + (_, "gap") => &["STACK_SPACING"], + (_, "crossGap") => &["STACK_COUNTER_SPACING"], + (_, "paddingLeft") => &["STACK_PADDING_LEFT"], + (_, "paddingTop") => &["STACK_PADDING_TOP"], + (_, "paddingRight") => &["STACK_PADDING_RIGHT"], + (_, "paddingBottom") => &["STACK_PADDING_BOTTOM"], + (_, "paddingHorizontal") => &["STACK_PADDING_LEFT", "STACK_PADDING_RIGHT"], + (_, "paddingVertical") => &["STACK_PADDING_TOP", "STACK_PADDING_BOTTOM"], + (_, "padding") => &["STACK_PADDING_LEFT", "STACK_PADDING_TOP", "STACK_PADDING_RIGHT", "STACK_PADDING_BOTTOM"], + (_, "cornerRadius") => &[ + "CORNER_RADIUS", + "RECTANGLE_TOP_LEFT_CORNER_RADIUS", + "RECTANGLE_TOP_RIGHT_CORNER_RADIUS", + "RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS", + "RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS", + ], + (_, "topLeftRadius") => &["RECTANGLE_TOP_LEFT_CORNER_RADIUS"], + (_, "topRightRadius") => &["RECTANGLE_TOP_RIGHT_CORNER_RADIUS"], + (_, "bottomLeftRadius") => &["RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS"], + (_, "bottomRightRadius") => &["RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS"], + (_, "opacity") => &["OPACITY"], + (_, "visible") => &["VISIBLE"], + (_, "characters") => &["TEXT_DATA"], + (_, "strokeWeight") => &["STROKE_WEIGHT"], + (_, "strokeTopWeight") => &["BORDER_TOP_WEIGHT"], + (_, "strokeRightWeight") => &["BORDER_RIGHT_WEIGHT"], + (_, "strokeBottomWeight") => &["BORDER_BOTTOM_WEIGHT"], + (_, "strokeLeftWeight") => &["BORDER_LEFT_WEIGHT"], + (_, "minWidth") => &["MIN_WIDTH"], + (_, "maxWidth") => &["MAX_WIDTH"], + (_, "minHeight") => &["MIN_HEIGHT"], + (_, "maxHeight") => &["MAX_HEIGHT"], + (_, "fontSize") => &["FONT_SIZE"], + (_, "fontFamily") => &["FONT_FAMILY"], + (_, "fontStyle") => &["FONT_STYLE"], + (_, "letterSpacing") => &["LETTER_SPACING"], + (_, "lineHeight") => &["LINE_HEIGHT"], + (_, "paragraphSpacing") => &["PARAGRAPH_SPACING"], + (_, "paragraphIndent") => &["PARAGRAPH_INDENT"], + _ => { + self.warn(BAKED); + continue; + } + }; + // Each entry names the bound property and the number of the layer field that holds it. + for field in fields { + if let Some(a) = self.alias(var) { + let mut entry: Fields = node_field(field).into_iter().map(|id| ("nodeField", u(id))).collect(); + entry.extend([("variableData", a), ("variableField", e(field))]); + out.entries.push(o(entry)); + } + } + } + out + } +} diff --git a/crates/format/src/fig/export/common.rs b/crates/format/src/fig/export/common.rs new file mode 100644 index 00000000..2853dd72 --- /dev/null +++ b/crates/format/src/fig/export/common.rs @@ -0,0 +1,365 @@ +use super::*; + +/// Longest kept description of such a layer (bytes of JSON). +const MAX_STASH: usize = 1 << 20; + +/// Whether a width profile reaches further on one side of the path than on the other. +pub(super) fn one_sided(p: &figcraft_doc::WidthProfile) -> bool { + p.points.iter().any(|q| q.bias.abs() > 1e-6) +} + +/// A shape whose stroke is wider on one side of its path, as the area that stroke paints (a vector +/// filled with the stroke's paints), with what it was as JSON: its kind, paints and stroke. Only +/// for shapes that show nothing but that stroke; `None` for every other layer. +pub(super) fn outlined(n: &Node) -> Option<(Node, String)> { + let sp = &n.stroke; + let shape = matches!( + n.kind, + NodeKind::Line | NodeKind::Vector { .. } | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle + ); + let shown = |paints: &[Paint]| paints.iter().any(|p| p.visible); + if !(shape + && sp.brush.is_none() + && sp.profile().is_some_and(one_sided) + && sp.weight.is_finite() + && sp.weight > 0.0 + && shown(&n.strokes) + && !shown(&n.fills)) + { + return None; + } + let path = figcraft_doc::geometry::stroke_geometry(n)?; + let net = match &n.kind { + NodeKind::Vector { network } => Some(network), + _ => None, + }; + let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net); + if area.elements().is_empty() || area.elements().len() > MAX_NETWORK / 4 { + return None; + } + let original = serde_json::json!({"kind": n.kind, "fills": n.fills, "strokes": n.strokes, "stroke": n.stroke}).to_string(); + if original.len() > MAX_STASH { + return None; + } + let mut network = VectorNetwork::from_path(&area); + for r in &mut network.regions { + r.winding = WindingRule::NonZero; + } + let mut v = n.clone(); + v.kind = NodeKind::Vector { network }; + v.fills = std::mem::take(&mut v.strokes); + v.stroke = Default::default(); + v.styles.fill = v.styles.stroke.take(); + // Bindings name the stroke's paints, which are the fills now: the values stay. + v.bound.retain(|k, _| !(k == "stroke" || k == "fill" || k.starts_with("strokes/") || k.starts_with("fills/") || k.starts_with("stroke"))); + Some((v, original)) +} + +/// The number of the layer field a bound property (`VariableField`) is held in. +pub(super) fn node_field(field: &str) -> Option { + let name = match field { + "WIDTH" | "HEIGHT" => "size", + "MIN_WIDTH" | "MIN_HEIGHT" => "minSize", + "MAX_WIDTH" | "MAX_HEIGHT" => "maxSize", + "STACK_SPACING" => "stackSpacing", + "STACK_COUNTER_SPACING" => "stackCounterSpacing", + "STACK_PADDING_LEFT" => "stackHorizontalPadding", + "STACK_PADDING_TOP" => "stackVerticalPadding", + "STACK_PADDING_RIGHT" => "stackPaddingRight", + "STACK_PADDING_BOTTOM" => "stackPaddingBottom", + "CORNER_RADIUS" => "cornerRadius", + "RECTANGLE_TOP_LEFT_CORNER_RADIUS" => "rectangleTopLeftCornerRadius", + "RECTANGLE_TOP_RIGHT_CORNER_RADIUS" => "rectangleTopRightCornerRadius", + "RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS" => "rectangleBottomLeftCornerRadius", + "RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS" => "rectangleBottomRightCornerRadius", + "OPACITY" => "opacity", + "VISIBLE" => "visible", + "TEXT_DATA" => "textData", + "STROKE_WEIGHT" => "strokeWeight", + "BORDER_TOP_WEIGHT" => "borderTopWeight", + "BORDER_RIGHT_WEIGHT" => "borderRightWeight", + "BORDER_BOTTOM_WEIGHT" => "borderBottomWeight", + "BORDER_LEFT_WEIGHT" => "borderLeftWeight", + "FONT_SIZE" => "fontSize", + "FONT_FAMILY" | "FONT_STYLE" => "fontName", + "LETTER_SPACING" => "letterSpacing", + "LINE_HEIGHT" => "lineHeight", + "PARAGRAPH_SPACING" => "paragraphSpacing", + "PARAGRAPH_INDENT" => "paragraphIndent", + _ => return None, + }; + // The numbers are the schema's. + let layer = SCHEMA.lines().find(|l| l.starts_with("message NodeChange "))?; + layer.split(", ").find_map(|f| f.rsplit_once(' ')?.1.strip_prefix(name)?.strip_prefix('=')?.parse().ok()) +} + +/// Whether `a` is `b` with its lengths scaled by `k`: every number is `k` times the other's (within +/// `slack`, for sizes that snap to whole pixels) or the same, and the rest is equal. +pub(super) fn scaled(a: &Kv<'static>, b: &Kv<'static>, k: f64, slack: f64) -> bool { + match (a, b) { + (Kv::Float(x), Kv::Float(y)) => { + let (x, y) = (f64::from(*x), f64::from(*y)); + x == y || (x - y * k).abs() <= slack + 1e-3 * x.abs().max(1.0) + } + (Kv::Array(x), Kv::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| scaled(p, q, k, slack)), + (Kv::Object(x), Kv::Object(y)) => x.len() == y.len() && x.iter().zip(y).all(|((kp, p), (kq, q))| kp == kq && scaled(p, q, k, slack)), + _ => a == b, + } +} + +/// The fields of `mine` that differ from `theirs` (a layer and the main's layer it mirrors), and +/// the ones only `theirs` has at their empty values. +pub(super) fn changed(mine: Fields, theirs: &Fields) -> Fields { + let cleared: Fields = theirs.iter().filter(|(k, _)| !mine.iter().any(|(mk, _)| mk == k)).filter_map(|(k, _)| Some((*k, unset(k)?))).collect(); + let mut out: Fields = mine.into_iter().filter(|(k, v)| !theirs.iter().any(|(tk, tv)| tk == k && tv == v)).collect(); + out.extend(cleared); + out +} + +/// Whether paint list `v` (field `key`) is `theirs` but for the colours variables give: two layers +/// bound alike are the same, whatever mode each one shows. +pub(super) fn resolved_alike(key: &str, v: &Kv<'static>, theirs: &Fields) -> bool { + fn unresolved(v: &Kv<'static>) -> Vec> { + let bare = |p: &Kv<'static>| match p { + Kv::Object(f) if f.iter().any(|(k, _)| *k == "colorVar") => o(f.iter().filter(|(k, _)| *k != "color").cloned().collect()), + other => other.clone(), + }; + v.list().iter().map(bare).collect() + } + matches!(key, "fillPaints" | "strokePaints") && theirs.iter().any(|(k, t)| *k == key && unresolved(t) == unresolved(v)) +} + +/// The format's name of a variable type. +pub(super) fn resolved_type(kind: VariableType) -> &'static str { + match kind { + VariableType::Color => "COLOR", + VariableType::Number => "FLOAT", + VariableType::String => "STRING", + VariableType::Boolean => "BOOLEAN", + } +} + +/// The format's name of a variable scope (`None`: it has none). +pub(super) fn scope_name(scope: &str) -> Option<&'static str> { + const NAMES: [&str; 20] = [ + "ALL_SCOPES", + "TEXT_CONTENT", + "CORNER_RADIUS", + "WIDTH_HEIGHT", + "GAP", + "ALL_FILLS", + "FRAME_FILL", + "SHAPE_FILL", + "TEXT_FILL", + "STROKE_FLOAT", + "EFFECT_FLOAT", + "EFFECT_COLOR", + "OPACITY", + "FONT_STYLE", + "FONT_FAMILY", + "FONT_SIZE", + "LINE_HEIGHT", + "LETTER_SPACING", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT", + ]; + if scope == "STROKE_COLOR" { + return Some("STROKE"); + } + NAMES.into_iter().find(|n| *n == scope) +} + +/// The empty value of a field only written when set (an override clears it with this). +fn unset(key: &str) -> Option> { + Some(match key { + "styleIdForFill" | "styleIdForStrokeFill" | "styleIdForEffect" | "styleIdForGrid" | "styleIdForText" => { + o(vec![("guid", guid(u32::MAX, u32::MAX))]) + } + "variableConsumptionMap" | "variableModeBySetMap" | "gridColumnsSizing" | "gridRowsSizing" => o(vec![("entries", arr(Vec::new()))]), + "mask" | "rectangleCornerRadiiIndependent" | "borderStrokeWeightsIndependent" => b(false), + "effects" | "dashPattern" | "layoutGrids" | "exportSettings" | "prototypeInteractions" | "fillGeometry" | "variableWidthPoints" => { + arr(Vec::new()) + } + "cornerSmoothing" | "stackChildPrimaryGrow" => fl(0.0), + "stackMode" => e("NONE"), + "stackChildAlignSelf" | "stackPositioning" => e("AUTO"), + "minSize" => o(vec![("value", xy(0.0, 0.0))]), + // No maximum is infinite (a maximum of 0 collapses the layer). + "maxSize" => o(vec![("value", o(vec![("x", Kv::Float(f32::INFINITY)), ("y", Kv::Float(f32::INFINITY))]))]), + "gridColumnSpan" | "gridRowSpan" | "maxLines" => int(0), + "scrollDirection" => e("NONE"), + "scrollBehavior" => e("SCROLLS"), + "parameterConsumptionMap" => o(vec![("entries", arr(Vec::new()))]), + "textTruncation" => e("DISABLED"), + "hyperlink" => o(vec![("url", s(""))]), + _ => return None, + }) +} + +/// The pairs of a variant's name, `Property=Value, …` (parts without `=` are left out). +pub(super) fn variant_pairs(name: &str) -> Vec<(String, String)> { + name.split(',') + .filter_map(|part| { + let (p, v) = part.split_once('=')?; + let (p, v) = (p.trim(), v.trim()); + (!p.is_empty()).then(|| (p.to_string(), v.to_string())) + }) + .collect() +} + +/// The `index`-th sort position of a list: one printable character from `"` on, two past the end +/// of that range. +pub(super) fn sort_position(index: usize) -> String { + const FIRST: u32 = 34; + const SPAN: usize = 93; + let ch = |i: usize| char::from_u32(FIRST + u32::try_from(i % SPAN).unwrap_or(0)).unwrap_or('"'); + if index < SPAN { ch(index).to_string() } else { format!("~{}", ch(index - SPAN)) } +} + +/// Text as a property value: its characters and one plain line per paragraph. +pub(super) fn text_value(t: &str) -> Kv<'static> { + let line = || { + o(vec![ + ("lineType", e("PLAIN")), + ("styleId", int(0)), + ("indentationLevel", int(0)), + ("sourceDirectionality", e("AUTO")), + ("listStartOffset", int(0)), + ("isFirstLineOfList", b(false)), + ]) + }; + o(vec![("characters", s(t)), ("lines", arr(t.split('\n').map(|_| line()).collect()))]) +} + +/// A property value as variable data (`varValue`). +pub(super) fn var_data(value: (&'static str, Kv<'static>), ty: &'static str, resolved: &'static str) -> Kv<'static> { + o(vec![("value", o(vec![value])), ("dataType", e(ty)), ("resolvedDataType", e(resolved))]) +} + +pub(super) fn bool_data(x: bool) -> Kv<'static> { + var_data(("boolValue", b(x)), "BOOLEAN", "BOOLEAN") +} + +pub(super) fn text_data(t: &str) -> Kv<'static> { + var_data(("textDataValue", text_value(t)), "TEXT_DATA", "TEXT_DATA") +} + +pub(super) fn symbol_data(main: Kv<'static>) -> Kv<'static> { + var_data(("symbolIdValue", o(vec![("guid", main)])), "SYMBOL_ID", "SYMBOL_ID") +} + +/// The format's type of a component property (`None`: not written). +pub(super) fn prop_type(d: &PropDef) -> Option<&'static str> { + match d { + PropDef::Boolean { .. } => Some("BOOL"), + PropDef::Text { .. } => Some("TEXT"), + PropDef::InstanceSwap { .. } => Some("INSTANCE_SWAP"), + PropDef::Variant { .. } | PropDef::Slot { .. } => None, + } +} + +/// The key codes of a key trigger (modifiers first), and whether it's a controller button. +pub(super) fn key_codes(key: &str) -> Option<(Vec, bool)> { + if let Some(i) = GAMEPAD_BUTTONS.iter().position(|g| g.eq_ignore_ascii_case(key.trim())) { + return Some((vec![i as i64], true)); + } + let (mods, k): (KeyMods, &str) = KeyMods::split(key); + let code = (0..=255).find(|c| crate::rest::proto::key_name(f64::from(*c)).is_some_and(|n| n.eq_ignore_ascii_case(k)))?; + let mut codes: Vec = [(mods.shift, 16), (mods.ctrl, 17), (mods.alt, 18), (mods.cmd, 91)].into_iter().filter(|m| m.0).map(|m| m.1).collect(); + codes.push(i64::from(code)); + Some((codes, false)) +} + +/// Whether the decoration look has more than skip ink, which the file can't hold. +pub(super) fn unsupported_decoration_look(st: &TextStyle) -> bool { + let mut look = st.look(); + look.skip_ink = false; + look != Default::default() +} + +/// The text style fields of a layer or of an override table entry. +pub(super) fn style_fields(st: &TextStyle, f: &mut Fields) { + let line_height = match st.line_height { + // Percent units count from the font's own line height (100 % is "auto"); a share of the + // font size is a raw multiplier. + LineHeight::Auto => o(vec![("value", fl(100.0)), ("units", e("PERCENT"))]), + LineHeight::Percent(p) => o(vec![("value", fl(p / 100.0)), ("units", e("RAW"))]), + LineHeight::Pixels(v) => o(vec![("value", fl(v)), ("units", e("PIXELS"))]), + }; + let letter_spacing = match st.letter_spacing { + LetterSpacing::Percent(p) => o(vec![("value", fl(p)), ("units", e("PERCENT"))]), + LetterSpacing::Pixels(v) => o(vec![("value", fl(v)), ("units", e("PIXELS"))]), + }; + let case = match st.case { + TextCase::Original => "ORIGINAL", + TextCase::Upper => "UPPER", + TextCase::Lower => "LOWER", + TextCase::Title => "TITLE", + TextCase::SmallCaps => "SMALL_CAPS", + }; + let decoration = match st.decoration { + TextDecoration::None => "NONE", + TextDecoration::Underline => "UNDERLINE", + TextDecoration::Strikethrough => "STRIKETHROUGH", + }; + f.extend([ + ("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&face_style(&st.font_family, &st.font_style))), ("postscript", s(""))])), + ("fontSize", fl(st.font_size)), + ("lineHeight", line_height), + ("letterSpacing", letter_spacing), + ("textCase", e(case)), + ("textDecoration", e(decoration)), + // Skip ink only shows on an underline; other text gets the files' default (on), the value + // the import leaves out again. + ("textDecorationSkipInk", b(st.look().skip_ink || st.decoration != TextDecoration::Underline)), + ]); + if let Some(url) = &st.link { + f.push(("hyperlink", o(vec![("url", s(url))]))); + } +} + +/// Layout grid `index` of a layer, with the variables `bound` to its fields. +pub(super) fn layout_grid(g: &figcraft_doc::LayoutGrid, index: usize, bound: &Bound) -> Kv<'static> { + let (pattern, axis) = match g.kind { + GridKind::Grid => ("GRID", "X"), + GridKind::Columns => ("STRIPES", "X"), + GridKind::Rows => ("STRIPES", "Y"), + }; + let align = match g.align { + GridAlign::Stretch => "STRETCH", + GridAlign::Min => "MIN", + GridAlign::Center => "CENTER", + GridAlign::Max => "MAX", + }; + let mut f: Fields = vec![ + ("type", e(align)), + ("axis", e(axis)), + ("visible", b(g.visible)), + ("numSections", int(g.count.into())), + ("offset", fl(g.offset)), + ("sectionSize", fl(g.size)), + ("gutterSize", fl(g.gutter)), + ("color", color(g.color)), + ("pattern", e(pattern)), + ]; + let vars = [("count", "numSectionsVar"), ("offset", "offsetVar"), ("size", "sectionSizeVar"), ("gutter", "gutterSizeVar")]; + f.extend(vars.into_iter().filter_map(|(ours, key)| Some((key, bound.grids.get(&(index, ours))?.clone())))); + o(f) +} + +pub(super) fn export_setting(x: &figcraft_doc::ExportSetting) -> Kv<'static> { + let format = match x.format { + // The format has no WebP export setting. + figcraft_doc::ExportFormat::Png | figcraft_doc::ExportFormat::Webp => "PNG", + figcraft_doc::ExportFormat::Jpg => "JPEG", + // Nor an Android Vector Drawable one: the nearest is the other vector format. + figcraft_doc::ExportFormat::Svg | figcraft_doc::ExportFormat::VectorDrawable => "SVG", + figcraft_doc::ExportFormat::Pdf => "PDF", + }; + let (ty, value) = match x.size { + ExportSize::Scale { value } => ("CONTENT_SCALE", value), + ExportSize::Width { value } => ("CONTENT_WIDTH", value), + ExportSize::Height { value } => ("CONTENT_HEIGHT", value), + }; + o(vec![("suffix", s(&x.suffix)), ("imageType", e(format)), ("constraint", o(vec![("type", e(ty)), ("value", fl(value))]))]) +} diff --git a/crates/format/src/fig/export/component.rs b/crates/format/src/fig/export/component.rs new file mode 100644 index 00000000..7d88dcc8 --- /dev/null +++ b/crates/format/src/fig/export/component.rs @@ -0,0 +1,324 @@ +use super::common::{ + bool_data, changed, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs, +}; +use super::*; + +impl<'d> Exporter<'d> { + /// A component property definition of `owner` (a component, or a set), `index`-th in its list. + /// The default value is written twice: as `initialValue` and as `varValue`, which is what + /// readers that know variables take. + pub(super) fn prop_def(&mut self, owner: NodeId, name: &str, d: &PropDef, index: usize) -> Option> { + let ty = prop_type(d)?; + let id = *self.defs.get(&(owner, name.to_string(), ty))?; + let (value, var) = match d { + PropDef::Boolean { default } => (o(vec![("boolValue", b(*default))]), bool_data(*default)), + PropDef::Text { default } => (o(vec![("textValue", text_value(default))]), text_data(default)), + PropDef::InstanceSwap { default, preferred } => { + if !preferred.is_empty() { + self.warn("preferred values of instance swap properties were left out"); + } + let main = self.guid_of(*default)?; + (o(vec![("guidValue", main.clone())]), symbol_data(main)) + } + PropDef::Variant { .. } | PropDef::Slot { .. } => return None, + }; + Some(o(vec![ + ("id", guid(AUX, id)), + ("name", s(name)), + ("initialValue", value), + ("sortPosition", s(&sort_position(index))), + ("type", e(ty)), + ("preferredValues", o(Vec::new())), + ("varValue", var), + ])) + } + + /// A component set's property definitions: its variant properties (from the variants' names, + /// in the order they first appear, with their values in order) and the other properties its + /// variants share; written on the set, as the format keeps them. + pub(super) fn set_props(&mut self, set: &Node, f: &mut Fields) { + let doc = self.doc; + let variants: Vec<&Node> = + set.children.iter().filter_map(|c| doc.node(*c)).filter(|c| matches!(c.kind, NodeKind::Component { .. })).collect(); + let mut order: Vec<(String, Vec)> = Vec::new(); + for v in &variants { + for (prop, value) in variant_pairs(&v.name) { + let i = match order.iter().position(|(p, _)| *p == prop) { + Some(i) => i, + None => { + order.push((prop, Vec::new())); + order.len() - 1 + } + }; + if let Some((_, values)) = order.get_mut(i) + && !values.contains(&value) + { + values.push(value); + } + } + } + let mut defs = Vec::new(); + for (prop, _) in &order { + if let Some(&id) = self.defs.get(&(set.id, prop.clone(), "VARIANT")) { + defs.push(o(vec![ + ("id", guid(AUX, id)), + ("name", s(prop)), + ("initialValue", o(Vec::new())), + ("sortPosition", s(&sort_position(defs.len()))), + ("type", e("VARIANT")), + ("preferredValues", o(Vec::new())), + ("varValue", var_data(("textValue", s("")), "STRING", "STRING")), + ])); + } + } + // The format keeps one default per property of a set (a variant's copy holds none): the first + // variant's, with a warning when another variant's differs. + // A name used with two types is two properties (their definitions have their own ids). + let mut seen: BTreeMap<(String, &'static str), PropDef> = BTreeMap::new(); + for v in &variants { + let NodeKind::Component { props, .. } = &v.kind else { continue }; + for (name, d) in props { + let Some(ty) = prop_type(d) else { continue }; + match seen.get(&(name.clone(), ty)) { + Some(first) if first != d => self.warn("variants with different defaults for one property were written with the first variant's"), + Some(_) => {} + None => { + seen.insert((name.clone(), ty), d.clone()); + let index = defs.len(); + defs.extend(self.prop_def(set.id, name, d, index)); + } + } + } + } + if !defs.is_empty() { + f.push(("componentPropDefs", arr(defs))); + } + if !order.is_empty() { + let orders = order.into_iter().map(|(p, values)| o(vec![("property", s(&p)), ("values", arr(values.iter().map(|x| s(x)).collect()))])); + f.push(("stateGroupPropertyValueOrders", arr(orders.collect()))); + } + } + + /// Which property controls a layer's `field`: the entry of its parameter consumption map and + /// its component property reference (readers take one or the other), both naming the + /// definition of the component the layer is in (a variant's own copy, in a set). + pub(super) fn prop_ref(&mut self, n: &Node, field: &str, prop: &str) -> Option<(Kv<'static>, Kv<'static>)> { + let (var_field, ty, resolved) = match field { + "visible" => ("VISIBLE", "BOOL", "BOOLEAN"), + "characters" => ("TEXT_DATA", "TEXT", "TEXT_DATA"), + "mainComponent" => ("OVERRIDDEN_SYMBOL_ID", "INSTANCE_SWAP", "SYMBOL_ID"), + _ => { + self.warn("slot layers were written as plain layers"); + return None; + } + }; + let id = self.def_for(self.component_of(n)?, prop, ty)?; + let data = o(vec![ + ("value", o(vec![("propRefValue", o(vec![("defId", guid(AUX, id))]))])), + ("dataType", e("PROP_REF")), + ("resolvedDataType", e(resolved)), + ]); + let entry = o(vec![("variableData", data), ("variableField", e(var_field))]); + let reference = o(vec![("defID", guid(AUX, id)), ("componentPropNodeField", e(var_field))]); + Some((entry, reference)) + } + + /// An instance: its main, the fields it and its layers override, the size, place and outline + /// its layers get from that (derived data), its property values. `f`: its own fields so far. + pub(super) fn instance(&mut self, n: &Node, main: NodeId, props: &BTreeMap, f: &mut Fields) { + // Which properties control the instance itself (a nested instance in a main component) is + // its own too, not an override of its main. + const OWN: [&str; 9] = + ["guid", "phase", "parentIndex", "type", "transform", "fillGeometry", "strokeGeometry", "parameterConsumptionMap", "componentPropRefs"]; + let doc = self.doc; + let (Some(m), Some(symbol)) = (doc.node(main), self.guid_of(main)) else { return }; + // What the instance itself changes from its main (its size, its paints, …) is an override + // addressed by the main's id: a reader takes everything else from the main. Where it sits + // is its own. + let mut root_theirs = Vec::new(); + self.layer(m, m.parent.and_then(|p| doc.node(p)), &mut root_theirs, true); + root_theirs.retain(|(k, _)| !OWN.contains(k)); + // An instance is named after its component (the set, for a variant) until renamed. + let set = m.parent.and_then(|p| doc.node(p)).filter(|p| matches!(p.kind, NodeKind::ComponentSet { .. })); + let named = set.is_none_or(|s| s.name != n.name); + let root_mine: Fields = f.iter().filter(|(k, _)| !OWN.contains(k) && (named || *k != "name")).cloned().collect(); + // Each layer below: its id path, the fields that differ from the main's layer, that layer's + // fields, whether its size is its own (a nested instance, a size override) and the + // component a nested instance was swapped for. + struct Row { + path: Kv<'static>, + changed: Fields, + theirs: Fields, + sized: bool, + swap: Option>, + } + let mut rows: Vec = Vec::new(); + let mut stack: Vec<(NodeId, NodeId)> = n.children.iter().rev().map(|c| (*c, n.id)).collect(); + let mut seen = 0usize; + while let Some((id, parent)) = stack.pop() { + let Some(d) = doc.node(id) else { continue }; + seen += 1; + if seen > MAX_NODES { + break; + } + stack.extend(d.children.iter().rev().map(|c| (*c, id))); + let path = self.layer_path(d, n.id, main, 0); + let guids: Option> = path.and_then(|p| p.iter().map(|x| self.guid_of(*x)).collect()); + let (Some(guids), Some(src)) = (guids, d.source.and_then(|src| doc.node(src))) else { + self.warn("instance layers that don't come from their main component were left out"); + continue; + }; + let (mut mine, mut theirs) = (Vec::new(), Vec::new()); + self.layer(d, doc.node(parent), &mut mine, true); + self.layer(src, src.parent.and_then(|p| doc.node(p)), &mut theirs, true); + // A nested instance swapped for another component. + let swap = match (&d.kind, &src.kind) { + (NodeKind::Instance { component, .. }, NodeKind::Instance { component: was, .. }) if component == was => None, + (NodeKind::Instance { component, .. }, _) => self.guid_of(*component), + _ => None, + }; + let sized = matches!(d.kind, NodeKind::Instance { .. }) || d.overrides.contains("size"); + rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, sized, swap }); + } + // An instance scaled as a whole (the Scale tool) is `factor` times its main in every length: + // sizes, places and stroke weights, its own and its layers'. A resized one keeps its weights. + let field = |fields: &Fields, key: &str| fields.iter().find(|(k, _)| *k == key).map(|(_, v)| v.clone()); + let factor = Some(n.width / m.width).filter(|k| k.is_finite() && *k > 0.0 && (k - 1.0).abs() > 1e-6).filter(|k| { + let grown = |mine: &Fields, theirs: &Fields| { + let weight = field(theirs, "strokeWeight").and_then(|w| w.f64()).unwrap_or(0.0); + (weight == 0.0 || field(mine, "strokeWeight").is_some_and(|w| scaled(&w, &fl(weight), *k, 0.0))) + && field(mine, "size").zip(field(theirs, "size")).is_some_and(|(a, b)| scaled(&a, &b, *k, 1.0)) + && field(mine, "transform").zip(field(theirs, "transform")).is_none_or(|(a, b)| scaled(&a, &b, *k, 1.0)) + }; + (n.height / m.height - k).abs() <= 1e-6 * k.max(1.0) + && grown(&changed(root_mine.clone(), &root_theirs), &root_theirs) + && rows.iter().all(|r| grown(&r.changed, &r.theirs)) + }); + // What follows from the overrides goes with the derived data: a layer's size, place and + // outline (a vector's network is the main's, drawn at the layer's size), the colours its + // variables give here, and what the scale factor makes of the main's values. + let follows = |k: &str, v: &Kv<'static>, theirs: &Fields| { + ["size", "transform", "fillGeometry", "strokeGeometry", "vectorData"].contains(&k) + || resolved_alike(k, v, theirs) + || factor.is_some_and(|x| field(theirs, k).is_some_and(|t| scaled(v, &t, x, 0.0))) + }; + let mut overrides = Vec::new(); + let mut derived = Vec::new(); + let mut of: Fields = vec![("guidPath", o(vec![("guids", arr(vec![symbol.clone()]))]))]; + of.extend(changed(root_mine, &root_theirs).into_iter().filter(|(k, v)| *k == "size" || !follows(k, v, &root_theirs))); + if of.len() > 1 { + overrides.push(o(of)); + } + for r in rows { + let (mut of, mut df): (Fields, Fields) = (vec![("guidPath", r.path.clone())], vec![("guidPath", r.path)]); + for (k, v) in r.changed { + let follows = follows(k, &v, &r.theirs); + // A nested instance's size is its own to set, as is a size the layer overrides. + if follows && r.sized && k == "size" { + of.push((k, v.clone())); + } + if follows { &mut df } else { &mut of }.push((k, v)); + } + if df.len() > 1 { + derived.push(o(df)); + } + of.extend(r.swap.map(|g| ("overriddenSymbolID", g))); + if of.len() > 1 { + overrides.push(o(of)); + } + } + f.push(("symbolData", o(vec![("symbolID", symbol), ("symbolOverrides", arr(overrides)), ("uniformScaleFactor", fl(factor.unwrap_or(1.0)))]))); + if !derived.is_empty() { + f.push(("derivedSymbolData", arr(derived))); + } + let mut assignments = Vec::new(); + for (name, v) in props { + let (ty, value, var) = match v { + Value::Bool(x) => ("BOOL", o(vec![("boolValue", b(*x))]), bool_data(*x)), + Value::String(t) => ("TEXT", o(vec![("textValue", text_value(t))]), text_data(t)), + Value::Number(c) => { + let Some(g) = c.as_u64().and_then(|c| self.guid_of(NodeId(c))) else { continue }; + ("INSTANCE_SWAP", o(vec![("guidValue", g.clone())]), symbol_data(g)) + } + _ => continue, + }; + // Variant values have no definition of their own: the main they show says them. A + // variant's instance names the variant's own copy of the definition. + if let Some(id) = self.def_for(main, name, ty) { + assignments.push(o(vec![("defID", guid(AUX, id)), ("value", value), ("varValue", var)])); + } + } + if !assignments.is_empty() { + f.push(("componentPropAssignments", arr(assignments))); + } + if n.children.iter().filter_map(|c| doc.node(*c)).any(|c| c.source.is_none()) { + self.warn("instance layers that don't come from their main component were left out"); + } + } + + /// The main component layer an instance or instance layer mirrors (through nested instances), + /// or `id` itself. + pub(super) fn main_layer(&self, id: NodeId) -> NodeId { + let mut cur = id; + for _ in 0..32 { + let next = self.doc.node(cur).and_then(|n| match &n.kind { + _ if n.source.is_some() => n.source, + NodeKind::Instance { component, .. } => Some(*component), + _ => None, + }); + match next.filter(|s| self.doc.contains(*s)) { + Some(s) => cur = s, + None => break, + } + } + cur + } + + /// The id path of layer `d` of instance `inst` (of `main`): that of the main's layer it mirrors, + /// or, inside a nested instance swapped for another component, the path of that instance and + /// then the one inside its new main. + fn layer_path(&self, d: &Node, inst: NodeId, main: NodeId, depth: usize) -> Option> { + let src = d.source?; + if let Some(p) = self.path_in(src, main, 0) { + return Some(p); + } + if depth > 32 { + return None; + } + let doc = self.doc; + let nested = doc.ancestors(d.id).into_iter().take_while(|a| *a != inst).filter_map(|a| doc.node(a)).find_map(|a| match &a.kind { + NodeKind::Instance { component, .. } => Some((a, *component)), + _ => None, + }); + let (outer, component) = nested?; + let mut p = self.layer_path(outer, inst, main, depth + 1)?; + p.extend(self.path_in(src, component, 0)?); + Some(p) + } + + /// The id path of layer `s` inside component `comp`: `s` itself, or through the nested + /// instance it is expanded in, then on inside that instance's main. + fn path_in(&self, s: NodeId, comp: NodeId, depth: usize) -> Option> { + if depth > 32 { + return None; + } + let doc = self.doc; + let up = doc.ancestors(s); + let below: Vec = up.iter().take_while(|a| **a != comp).copied().collect(); + if below.len() == up.len() { + return None; + } + // The outermost nested instance between the component and the layer. + match below.iter().rev().find_map(|a| match doc.node(*a).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => Some((*a, *component)), + _ => None, + }) { + None => Some(vec![s]), + Some((inst, main)) => { + let mut p = vec![inst]; + p.extend(self.path_in(doc.node(s)?.source?, main, depth + 1)?); + Some(p) + } + } + } +} diff --git a/crates/format/src/fig/export/layer.rs b/crates/format/src/fig/export/layer.rs new file mode 100644 index 00000000..9925042f --- /dev/null +++ b/crates/format/src/fig/export/layer.rs @@ -0,0 +1,440 @@ +use super::common::{export_setting, layout_grid, outlined, prop_type, variant_pairs}; +use super::*; + +impl<'d> Exporter<'d> { + /// Write layer `id` (child `index` of the layer with GUID `parent_guid`) and its subtree. + pub(super) fn emit(&mut self, id: NodeId, parent_guid: Kv<'static>, index: usize, parent: Option<&Node>, depth: usize) { + let doc = self.doc; + let (Some(&local), Some(n)) = (self.ids.get(&id), doc.node(id)) else { return }; + if depth > MAX_DEPTH { + self.warn("layers nested deeper than 256 levels were left out"); + return; + } + let mut f: Fields = vec![ + ("guid", guid(LAYERS, local)), + ("phase", e("CREATED")), + ("parentIndex", o(vec![("guid", parent_guid), ("position", s(&position(index)))])), + ]; + let children = if let NodeKind::Page { background, guides, prototype, .. } = &n.kind { + f.extend([("type", e("CANVAS")), ("name", s(&n.name))]); + f.extend(root_fields()); + f.extend([("backgroundColor", color(*background)), ("backgroundOpacity", fl(background.a.into())), ("backgroundEnabled", b(true))]); + if !guides.is_empty() { + self.warn("ruler guides were left out"); + } + if *prototype != Default::default() { + self.warn("prototype device settings were left out"); + } + self.modes(n, &mut f); + true + } else { + self.layer(n, parent, &mut f, false) + }; + self.nodes.push(o(f)); + if children { + for (i, c) in n.children.iter().enumerate() { + self.emit(*c, guid(LAYERS, local), i, Some(n), depth + 1); + } + } + } + + /// The fields of layer `n` (whose parent is `parent`). `nested`: for an instance's override + /// (no type, no instance or component data). Returns whether its children are written as + /// layers (an instance's come from its main). + pub(super) fn layer(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields, nested: bool) -> bool { + let doc = self.doc; + if let Some((outline, original)) = outlined(n) { + // The format's variable width is the same on both sides of the path: a stroke wider on + // one side is written as the area it paints, with the stroke itself kept as plugin data + // (which the import puts back). + self.warn("strokes wider on one side of their path were written as filled outlines (the stroke itself is kept for FigCraft)"); + let children = self.layer(&outline, parent, f, nested); + f.push(("pluginData", arr(vec![o(vec![("pluginID", s(STASH.0)), ("value", s(&original)), ("key", s(STASH.1))])]))); + return children; + } + let main = match &n.kind { + NodeKind::Instance { component, .. } => { + let found = self.ids.contains_key(component) && doc.node(*component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. })); + if !found && !nested { + self.warn("instances of components that aren't in the file were written as frames"); + } + found.then_some(*component) + } + _ => None, + }; + let ty = match &n.kind { + NodeKind::Page { .. } | NodeKind::Frame | NodeKind::Group | NodeKind::ComponentSet { .. } => "FRAME", + NodeKind::Instance { .. } if main.is_none() => "FRAME", + NodeKind::Section { .. } => "SECTION", + NodeKind::Rectangle => "ROUNDED_RECTANGLE", + NodeKind::Ellipse { .. } => "ELLIPSE", + NodeKind::Polygon { .. } => "REGULAR_POLYGON", + NodeKind::Star { .. } => "STAR", + NodeKind::Line => "LINE", + NodeKind::Vector { .. } => "VECTOR", + NodeKind::Text(_) => "TEXT", + NodeKind::BooleanOp { .. } => "BOOLEAN_OPERATION", + NodeKind::Component { .. } => "SYMBOL", + NodeKind::Instance { .. } => "INSTANCE", + NodeKind::Slice => "SLICE", + }; + if !nested { + f.push(("type", e(ty))); + } + f.push(("name", s(&n.name))); + self.common(n, parent, f); + match &n.kind { + NodeKind::Group => f.push(("resizeToFit", b(true))), + NodeKind::Section { contents_hidden } => f.push(("sectionContentsHidden", b(*contents_hidden))), + NodeKind::Ellipse { start, sweep, inner } => { + f.push(("arcData", o(vec![("startingAngle", fl(*start)), ("endingAngle", fl(start + sweep)), ("innerRadius", fl(*inner))]))) + } + NodeKind::Polygon { count } => f.push(("count", u(*count))), + NodeKind::Star { count, inner_ratio } => f.extend([("count", u(*count)), ("starInnerScale", fl(*inner_ratio))]), + NodeKind::Vector { network } => self.vector(n, network, f), + NodeKind::Text(td) => self.text(td, &n.fills, f), + NodeKind::BooleanOp { op } => { + let name = match op { + BoolOp::Union => "UNION", + BoolOp::Subtract => "SUBTRACT", + BoolOp::Intersect => "INTERSECT", + BoolOp::Exclude => "XOR", + }; + f.push(("booleanOperation", e(name))); + if n.children.iter().filter_map(|c| doc.node(*c)).any(|c| c.bool_op.is_some()) { + self.warn("boolean groups whose layers combine in different ways use the group's operation"); + } + } + NodeKind::Component { description, props } if !nested => { + if let Some(set) = self.set_of(n.id) { + // A variant: its set holds the definitions; the variant has its own copy of + // each (pointing at the set's), and its values of the variant properties. + let own: Vec = props + .iter() + .filter_map(|(name, d)| { + let ty = prop_type(d)?; + let id = *self.child_defs.get(&(n.id, name.clone(), ty))?; + let parent = *self.defs.get(&(set, name.clone(), ty))?; + Some(o(vec![("id", guid(AUX, id)), ("parentPropDefId", guid(AUX, parent))])) + }) + .collect(); + if !own.is_empty() { + f.push(("componentPropDefs", arr(own))); + } + let specs: Vec = variant_pairs(&n.name) + .into_iter() + .filter_map(|(prop, value)| { + let id = *self.defs.get(&(set, prop, "VARIANT"))?; + Some(o(vec![("propDefId", guid(AUX, id)), ("value", s(&value))])) + }) + .collect(); + if !specs.is_empty() { + f.push(("variantPropSpecs", arr(specs))); + } + } else { + let defs: Vec = props.iter().enumerate().filter_map(|(i, (name, d))| self.prop_def(n.id, name, d, i)).collect(); + if !defs.is_empty() { + f.push(("componentPropDefs", arr(defs))); + } + } + if props.values().any(|d| matches!(d, PropDef::Slot { .. })) { + self.warn("slot properties were left out"); + } + if !description.is_empty() { + f.push(("symbolDescription", s(description))); + } + } + NodeKind::ComponentSet { description } if !nested => { + f.push(("isStateGroup", b(true))); + self.set_props(n, f); + if !description.is_empty() { + f.push(("symbolDescription", s(description))); + } + } + NodeKind::Instance { props, .. } if !nested => { + if let Some(main) = main { + self.instance(n, main, props, f); + } + } + _ => {} + } + let boxy = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle | NodeKind::Section { .. }); + if (boxy || matches!(n.kind, NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. })) + && let Some(p) = figcraft_doc::geometry::shape_path(n) + { + self.geometry("fillGeometry", &p, WindingRule::NonZero, f); + } + self.stroke_geometry(n, boxy, f); + main.is_none() + } + + /// Fields every layer kind shares. + fn common(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { + f.extend([ + ("visible", b(n.visible)), + ("locked", b(n.locked)), + ("opacity", fl(n.opacity.into())), + ("blendMode", e(blend_name(n.blend))), + ("size", xy(n.width, n.height)), + ("transform", matrix(n.transform)), + ]); + if n.is_mask { + let ty = match n.mask_type { + MaskType::Alpha => "ALPHA", + MaskType::Vector => "OUTLINE", + MaskType::Luminance => "LUMINANCE", + }; + f.extend([("mask", b(true)), ("maskType", e(ty))]); + } + let bound = self.bound(n); + // Text whose own style sets fills paints with them (the layer's fills only show through + // runs); a variable bound to the layer's fill colours the layer's. + let fills = match &n.kind { + NodeKind::Text(TextData { style: TextStyle { fills: Some(own), .. }, .. }) => self.paints(own, true, &Bound::default()), + _ => self.paints(&n.fills, true, &bound), + }; + let strokes = self.paints(&n.strokes, false, &bound); + f.extend([("fillPaints", fills), ("strokePaints", strokes)]); + self.stroke(n, f); + if !n.effects.is_empty() { + let effects = n.effects.iter().enumerate().map(|(i, x)| self.effect(x, i, &bound)).collect(); + f.push(("effects", arr(effects))); + } + if n.kind.has_corner_radius() { + let r = n.corners.radii; + f.push(("cornerRadius", fl(r[0]))); + let differ = r.iter().any(|x| *x != r[0]); + if n.kind.has_independent_corners() && (differ || r[0] > 0.0) { + f.extend(differ.then(|| ("rectangleCornerRadiiIndependent", b(true)))); + f.extend([ + ("rectangleTopLeftCornerRadius", fl(r[0])), + ("rectangleTopRightCornerRadius", fl(r[1])), + ("rectangleBottomRightCornerRadius", fl(r[2])), + ("rectangleBottomLeftCornerRadius", fl(r[3])), + ]); + } + if n.corners.smoothing > 0.0 { + f.push(("cornerSmoothing", fl(n.corners.smoothing))); + } + } + let h = match n.constraints.horizontal { + HConstraint::Left => "MIN", + HConstraint::Right => "MAX", + HConstraint::LeftRight => "STRETCH", + HConstraint::Center => "CENTER", + HConstraint::Scale => "SCALE", + }; + let v = match n.constraints.vertical { + VConstraint::Top => "MIN", + VConstraint::Bottom => "MAX", + VConstraint::TopBottom => "STRETCH", + VConstraint::Center => "CENTER", + VConstraint::Scale => "SCALE", + }; + f.extend([("horizontalConstraint", e(h)), ("verticalConstraint", e(v))]); + if n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }) { + f.push(("frameMaskDisabled", b(!n.clip))); + } + if n.kind.is_frame_like() { + if let Some(l) = &n.layout { + self.auto_layout(n, l, f); + } + if !n.grids.is_empty() { + f.push(("layoutGrids", arr(n.grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &bound)).collect()))); + } + } + self.child_layout(n, parent, f); + if !n.exports.is_empty() { + if n.exports.iter().any(|x| x.format == figcraft_doc::ExportFormat::Webp) { + self.warn("WebP export settings were written as PNG"); + } + if n.exports.iter().any(|x| x.format == figcraft_doc::ExportFormat::VectorDrawable) { + self.warn("Android Vector Drawable (XML) export settings were written as SVG"); + } + f.push(("exportSettings", arr(n.exports.iter().map(export_setting).collect()))); + } + self.prototype(n, f); + let (entries, refs): (Vec, Vec) = n.prop_refs.iter().filter_map(|(field, prop)| self.prop_ref(n, field, prop)).unzip(); + if !entries.is_empty() { + f.push(("parameterConsumptionMap", o(vec![("entries", arr(entries))]))); + f.push(("componentPropRefs", arr(refs))); + } + let text = matches!(n.kind, NodeKind::Text(_)); + let links = [ + ("styleIdForFill", &n.styles.fill, true), + ("styleIdForStrokeFill", &n.styles.stroke, true), + ("styleIdForEffect", &n.styles.effect, true), + ("styleIdForGrid", &n.styles.grid, n.kind.is_frame_like()), + ("styleIdForText", &n.styles.text, text), + ]; + for (key, id, fits) in links { + let Some(id) = id else { continue }; + match self.style_ids.get(id.as_str()).filter(|_| fits) { + Some(local) => f.push((key, o(vec![("guid", guid(LAYERS, *local))]))), + None => self.warn("links to shared styles that aren't in the document were left out (their layers keep the values)"), + } + } + if let NodeKind::Text(td) = &n.kind + && n.styles.text.is_none() + && std::iter::once(&td.style).chain(td.runs.iter().map(|r| &r.style)).any(|st| st.style_id.is_some()) + { + self.warn("text styles applied to part of a text were written as their values"); + } + if !bound.entries.is_empty() { + f.push(("variableConsumptionMap", o(vec![("entries", arr(bound.entries))]))); + } + self.modes(n, f); + if !n.guides.is_empty() { + self.warn("ruler guides were left out"); + } + if !n.annotations.is_empty() + || !n.dev_resources.is_empty() + || !n.code_links.is_empty() + || !n.measurements.is_empty() + || !n.dev_status.is_none() + { + self.warn("Dev Mode annotations, statuses, resources, measurements and code links were left out"); + } + if !n.plugin_data.is_empty() { + self.warn("plugin data was left out"); + } + } + + /// The variable modes layer (or page) `n` sets for what is in it. + fn modes(&mut self, n: &Node, f: &mut Fields) { + let mut entries = Vec::new(); + for (coll, mode) in &n.var_modes { + match (self.set_ids.get(coll.as_str()), self.mode_ids.get(&(coll.as_str(), mode.as_str()))) { + (Some(set), Some(mode)) => { + entries.push(o(vec![("variableSetID", o(vec![("guid", guid(LAYERS, *set))])), ("variableModeID", guid(AUX, *mode))])); + } + _ => self.warn("variable modes of collections that aren't in the document were left out"), + } + } + if !entries.is_empty() { + f.push(("variableModeBySetMap", o(vec![("entries", arr(entries))]))); + } + } + + fn auto_layout(&mut self, n: &Node, l: &AutoLayout, f: &mut Fields) { + let grid = l.mode == LayoutMode::Grid; + let mode = match l.mode { + LayoutMode::Horizontal => "HORIZONTAL", + LayoutMode::Vertical => "VERTICAL", + LayoutMode::Grid => "GRID", + }; + let [top, right, bottom, left] = l.padding; + let main = match l.main_align { + MainAlign::Min => "MIN", + MainAlign::Center => "CENTER", + MainAlign::Max => "MAX", + MainAlign::SpaceBetween => "SPACE_BETWEEN", + MainAlign::SpaceAround => "SPACE_AROUND", + MainAlign::SpaceEvenly => "SPACE_EVENLY_CSS", + }; + let cross = match l.cross_align { + CrossAlign::Min => "MIN", + CrossAlign::Center => "CENTER", + CrossAlign::Max => "MAX", + CrossAlign::Baseline => "BASELINE", + }; + // The main axis is the width for horizontal and grid layouts. + let (main_hug, cross_hug) = { + let (h, v) = (n.child_layout.sizing_h == Sizing::Hug, n.child_layout.sizing_v == Sizing::Hug); + if l.mode == LayoutMode::Vertical { (v, h) } else { (h, v) } + }; + let size = |hug: bool| e(if hug { "RESIZE_TO_FIT" } else { "FIXED" }); + f.extend([ + ("stackMode", e(mode)), + ("stackWrap", e(if l.wrap { "WRAP" } else { "NO_WRAP" })), + ("stackHorizontalPadding", fl(left)), + ("stackVerticalPadding", fl(top)), + ("stackPaddingRight", fl(right)), + ("stackPaddingBottom", fl(bottom)), + ("stackPrimaryAlignItems", e(main)), + ("stackCounterAlignItems", e(cross)), + ("stackPrimarySizing", size(main_hug)), + ("stackCounterSizing", size(cross_hug)), + ("bordersTakeSpace", b(l.strokes_included)), + ("stackReverseZIndex", b(l.reverse_z)), + ]); + if grid { + self.grid_layout(n, l, f); + } else { + let spread = l.content_align == ContentAlign::SpaceBetween; + f.extend([ + ("stackSpacing", fl(l.gap)), + ("stackCounterSpacing", fl(l.cross_gap)), + ("stackCounterAlignContent", e(if spread { "SPACE_BETWEEN" } else { "AUTO" })), + ]); + } + } + + /// A grid layout's track gaps, order and sizes. + fn grid_layout(&mut self, n: &Node, l: &AutoLayout, f: &mut Fields) { + // Instance layers share their main layer's track ids, so unchanged tracks and the cells + // their layers sit in still match. + let key = self.main_layer(n.id); + let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize); + let (cols, rows) = match self.tracks.get(&key) { + Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()), + _ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()), + }; + let map = |ids: &[u32]| { + o(vec![("entries", arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()))]) + }; + f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); + let mut dropped = false; + let column_sizing = grid_sizing(&l.column_tracks, &cols, &mut dropped); + let row_sizing = grid_sizing(&l.row_tracks, &rows, &mut dropped); + if dropped { + self.warn("grid tracks whose size the format can't hold were written as equal tracks"); + } + for (name, sizing) in [("gridColumnsSizing", column_sizing), ("gridRowsSizing", row_sizing)] { + if let Some(v) = sizing { + f.push((name, v)); + } + } + self.tracks.insert(key, (cols, rows)); + } + + /// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own + /// and are written whatever its parent is. + fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { + let cl = &n.child_layout; + // An axis without a minimum is 0; one without a maximum is infinite (0 would be a maximum + // of 0, which collapses the layer in readers that apply it). + for (key, w, h, none) in [("minSize", cl.min_w, cl.min_h, 0.0), ("maxSize", cl.max_w, cl.max_h, f32::INFINITY)] { + if w.is_some() || h.is_some() { + let axis = |x: Option| x.map_or(Kv::Float(none), fl); + f.push((key, o(vec![("value", o(vec![("x", axis(w)), ("y", axis(h))]))]))); + } + } + let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return }; + let (fill_h, fill_v) = (cl.sizing_h == Sizing::Fill, cl.sizing_v == Sizing::Fill); + let (grow, stretch) = if pl.mode == LayoutMode::Vertical { (fill_v, fill_h) } else { (fill_h, fill_v) }; + if grow { + f.push(("stackChildPrimaryGrow", fl(1.0))); + } + if stretch { + f.push(("stackChildAlignSelf", e("STRETCH"))); + } + if cl.absolute { + f.push(("stackPositioning", e("ABSOLUTE"))); + } + if pl.mode == LayoutMode::Grid + && let Some((cols, rows)) = parent.and_then(|p| self.tracks.get(&self.main_layer(p.id))) + { + let anchor = |ids: &[u32], i: Option| i.and_then(|i| ids.get(i as usize)).map(|t| guid(AUX, *t)); + let (c, r) = (anchor(cols, cl.grid_column), anchor(rows, cl.grid_row)); + f.extend(c.map(|g| ("gridColumnAnchor", g))); + f.extend(r.map(|g| ("gridRowAnchor", g))); + if cl.grid_column_span > 0 { + f.push(("gridColumnSpan", int(cl.grid_column_span.into()))); + } + if cl.grid_row_span > 0 { + f.push(("gridRowSpan", int(cl.grid_row_span.into()))); + } + } + } +} diff --git a/crates/format/src/fig/export/paint.rs b/crates/format/src/fig/export/paint.rs new file mode 100644 index 00000000..f0f40c18 --- /dev/null +++ b/crates/format/src/fig/export/paint.rs @@ -0,0 +1,239 @@ +use super::common::one_sided; +use super::*; + +impl<'d> Exporter<'d> { + /// A paint list: a layer's fills (`fills`) or strokes with the variables `bound` to them. + pub(super) fn paints(&mut self, list: &[Paint], fills: bool, bound: &Bound) -> Kv<'static> { + arr(list.iter().enumerate().filter_map(|(i, p)| self.paint(p, (fills, i), bound)).collect()) + } + + fn paint(&mut self, p: &Paint, at: (bool, usize), bound: &Bound) -> Option> { + let mut f: Fields = vec![("visible", b(p.visible)), ("blendMode", e(blend_name(p.blend)))]; + let mut opacity = f64::from(p.opacity); + match &p.kind { + PaintKind::Solid { color: c } => { + opacity *= f64::from(c.a); + f.extend([("type", e("SOLID")), ("color", color(c.with_alpha(1.0)))]); + f.extend(bound.paints.get(&at).map(|a| ("colorVar", a.clone()))); + } + PaintKind::Gradient(g) => { + let ty = match g.kind { + GradientKind::Linear => "GRADIENT_LINEAR", + GradientKind::Radial => "GRADIENT_RADIAL", + GradientKind::Angular => "GRADIENT_ANGULAR", + GradientKind::Diamond => "GRADIENT_DIAMOND", + }; + // The handles in the unit box come from the inverse of the paint transform: it maps + // (0, ½) → start, (1, ½) → end, (0, 1) → width for linear gradients, and the centre + // (½, ½) and the points half a box away from it for the others. + let (from, to, width) = (g.from, g.to, g.width); + let (ax, ay) = + if g.kind == GradientKind::Linear { (to.0 - from.0, to.1 - from.1) } else { (2.0 * (to.0 - from.0), 2.0 * (to.1 - from.1)) }; + let (cx, cy) = (2.0 * (width.0 - from.0), 2.0 * (width.1 - from.1)); + let (ex, ey) = if g.kind == GradientKind::Linear { + (from.0 - 0.5 * cx, from.1 - 0.5 * cy) + } else { + (from.0 - 0.5 * (ax + cx), from.1 - 0.5 * (ay + cy)) + }; + let handles = Affine::new([ax, ay, cx, cy, ex, ey]); + let t = if handles.determinant().abs() > 1e-12 && handles.as_coeffs().iter().all(|x| x.is_finite()) { + handles.inverse() + } else { + self.warn("gradients with handles on one line were written with the default direction"); + Affine::IDENTITY + }; + let stops = g.stops.iter().map(|st| o(vec![("color", color(st.color)), ("position", fl(st.position.into()))])).collect(); + f.extend([("type", e(ty)), ("stops", arr(stops)), ("transform", matrix(t))]); + // Stops bound to variables: every stop again, the bound ones with their variable. + if bound.stops.range((at.0, at.1, 0)..=(at.0, at.1, usize::MAX)).next().is_some() { + let vars = g.stops.iter().enumerate().map(|(j, st)| { + let mut sf: Fields = vec![("color", color(st.color))]; + sf.extend(bound.stops.get(&(at.0, at.1, j)).map(|a| ("colorVar", a.clone()))); + sf.push(("position", fl(st.position.into()))); + o(sf) + }); + f.push(("stopsVar", arr(vars.collect()))); + } + } + PaintKind::Image(img) => { + let hash = self.image(&img.image); + let mode = match img.scale_mode { + ImageScaleMode::Fill => "FILL", + ImageScaleMode::Fit => "FIT", + ImageScaleMode::Crop => "STRETCH", + ImageScaleMode::Tile => "TILE", + }; + f.extend([ + ("type", e("IMAGE")), + ("image", o(vec![("hash", Kv::Bytes(hash)), ("name", s(""))])), + ("imageScaleMode", e(mode)), + ("rotation", fl(f64::from(img.rotation.rem_euclid(4)) * 90.0)), + ("scale", fl(img.scale)), + ]); + if let (ImageScaleMode::Crop, Some(c)) = (img.scale_mode, img.crop) { + f.push(("transform", matrix(Affine::new(c)))); + } + if img.filters != Default::default() { + self.warn("image adjustments (exposure, contrast, …) were left out"); + } + } + PaintKind::Pattern(_) => { + self.warn("pattern fills were left out"); + return None; + } + } + f.push(("opacity", fl(opacity))); + Some(o(f)) + } + + /// The hash bytes of image `key`, writing its file once (empty when its bytes aren't there). + fn image(&mut self, key: &str) -> Vec { + let hex = match self.images.get(key) { + Some(h) => h.clone(), + None => { + let h = match self.doc.images.get(key) { + Some(img) if img.bytes.len() as u64 <= MAX_IMAGE => { + let h = sha1_hex(&img.bytes); + if !self.files.iter().any(|(x, _)| *x == h) { + self.files.push((h.clone(), img.bytes.clone())); + } + Some(h) + } + Some(_) => { + self.warn("images larger than 256 MB were left out"); + None + } + None => { + self.warn("image fills whose image isn't in the document were written without one"); + None + } + }; + self.images.insert(key.to_string(), h.clone()); + h + } + }; + let hex = hex.unwrap_or_default(); + (0..hex.len() / 2).filter_map(|i| hex.get(2 * i..2 * i + 2).and_then(|d| u8::from_str_radix(d, 16).ok())).collect() + } + + pub(super) fn stroke(&mut self, n: &Node, f: &mut Fields) { + let sp = &n.stroke; + let align = match sp.align { + StrokeAlign::Inside => "INSIDE", + StrokeAlign::Center => "CENTER", + StrokeAlign::Outside => "OUTSIDE", + }; + f.extend([ + ("strokeWeight", fl(sp.weight)), + ("strokeAlign", e(align)), + ("strokeJoin", e(join_name(sp.join))), + ("strokeCap", e(cap_name(sp.cap_start))), + ("miterLimit", fl(sp.miter_limit)), + ]); + if !sp.dashes.is_empty() { + f.push(("dashPattern", arr(sp.dashes.iter().map(|d| fl(*d)).collect()))); + } + if let Some([t, r, bo, l]) = sp.sides { + f.extend([ + ("borderStrokeWeightsIndependent", b(true)), + ("borderTopWeight", fl(t)), + ("borderRightWeight", fl(r)), + ("borderBottomWeight", fl(bo)), + ("borderLeftWeight", fl(l)), + ]); + } + // A variable width: how far the stroke reaches on each side of the path at positions along + // its length, as a share of the stroke weight (half of it each for the full weight). The + // two sides are the same: a reader draws nothing for a point with unequal sides. + if let Some(p) = sp.profile().filter(|_| sp.weight.is_finite() && sp.weight > 0.0) { + let point = |q: &figcraft_doc::WidthPoint| o(vec![("position", fl(q.t)), ("ascent", fl(q.scale / 2.0)), ("descent", fl(q.scale / 2.0))]); + let p = p.sanitized(); + if one_sided(&p) { + self.warn("strokes wider on one side of their path were written with that width on both sides"); + } + f.push(("variableWidthPoints", arr(p.points.iter().map(point).collect()))); + } + if sp.brush.is_some() { + self.warn("brush strokes were written as plain strokes"); + } + if [sp.cap_start, sp.cap_end].contains(&StrokeCap::ReversedTriangle) { + self.warn("reversed triangle stroke ends were written as triangle arrows"); + } + // Vectors keep each end's cap on their end points; other layers have one cap. + if sp.cap_start != sp.cap_end && !matches!(n.kind, NodeKind::Vector { .. }) { + self.warn("lines with different stroke ends use the start's at both ends"); + } + } + + /// Effect `index` of a layer, with the variables `bound` to its fields. + pub(super) fn effect(&mut self, x: &figcraft_doc::Effect, index: usize, bound: &Bound) -> Kv<'static> { + let mut f: Fields = vec![("visible", b(x.visible))]; + let vars = [ + ("color", "colorVar"), + ("radius", "radiusVar"), + ("spread", "spreadVar"), + ("offsetX", "xVar"), + ("offsetY", "yVar"), + ("startRadius", "startRadiusVar"), + ]; + f.extend(vars.into_iter().filter_map(|(ours, key)| Some((key, bound.effects.get(&(index, ours))?.clone())))); + let blur = |f: &mut Fields, ty: &'static str, bl: &figcraft_doc::Blur| { + f.extend([("type", e(ty)), ("radius", fl(bl.radius))]); + if bl.blur_type == figcraft_doc::BlurType::Progressive { + f.extend([ + ("blurOpType", e("PROGRESSIVE")), + ("startRadius", fl(bl.start_radius)), + ("startOffset", xy(bl.start.0, bl.start.1)), + ("endOffset", xy(bl.end.0, bl.end.1)), + ]); + } + }; + let grain = |size: f64, size_y: Option| xy(size, size_y.unwrap_or(size)); + match &x.kind { + EffectKind::DropShadow(sh) | EffectKind::InnerShadow(sh) => { + let ty = if matches!(x.kind, EffectKind::DropShadow(_)) { "DROP_SHADOW" } else { "INNER_SHADOW" }; + f.extend([ + ("type", e(ty)), + ("color", color(sh.color)), + ("offset", xy(sh.offset.0, sh.offset.1)), + ("radius", fl(sh.radius)), + ("spread", fl(sh.spread)), + ("blendMode", e(blend_name(sh.blend))), + ("showShadowBehindNode", b(sh.show_behind)), + ]); + } + EffectKind::LayerBlur(bl) => blur(&mut f, "FOREGROUND_BLUR", bl), + EffectKind::BackgroundBlur(bl) => blur(&mut f, "BACKGROUND_BLUR", bl), + EffectKind::Noise(nz) => { + let mode = match nz.mode { + NoiseMode::Mono => "MONOTONE", + NoiseMode::Duo => "DUOTONE", + NoiseMode::Multi => "MULTITONE", + }; + f.extend([ + ("type", e("NOISE")), + ("noiseType", e(mode)), + ("noiseSize", grain(nz.size, nz.size_y)), + ("density", fl(nz.density)), + ("color", color(nz.color)), + ("secondaryColor", color(nz.secondary)), + ("opacity", fl(nz.opacity.into())), + ("blendMode", e(blend_name(nz.blend))), + ]); + } + EffectKind::Texture(t) => { + f.extend([ + ("type", e("GRAIN")), + ("noiseSize", grain(t.size, t.size_y)), + ("radius", fl(t.radius)), + ("clipToShape", b(t.clip_to_shape)), + ]); + } + EffectKind::Glass(g) => { + self.warn("glass effects keep only their frost"); + f.extend([("type", e("GLASS")), ("radius", fl(g.frost))]); + } + } + o(f) + } +} diff --git a/crates/format/src/fig/export/prototype.rs b/crates/format/src/fig/export/prototype.rs new file mode 100644 index 00000000..866b03c4 --- /dev/null +++ b/crates/format/src/fig/export/prototype.rs @@ -0,0 +1,174 @@ +use super::common::key_codes; +use super::*; + +impl<'d> Exporter<'d> { + /// Interactions, scrolling, flow starts and the overlay settings this layer is opened with. + pub(super) fn prototype(&mut self, n: &Node, f: &mut Fields) { + self.trigger_size = [n.width, n.height]; + let list: Vec = n.interactions.iter().filter_map(|it| self.interaction(it)).collect(); + if !list.is_empty() { + f.push(("prototypeInteractions", arr(list))); + } + let dir = match n.overflow { + Overflow::None => None, + Overflow::Horizontal => Some("HORIZONTAL"), + Overflow::Vertical => Some("VERTICAL"), + Overflow::Both => Some("BOTH"), + }; + f.extend(dir.map(|d| ("scrollDirection", e(d)))); + let behavior = match n.scroll_position { + ScrollPosition::Scrolls => None, + ScrollPosition::Fixed => Some("FIXED_WHEN_CHILD_OF_SCROLLING_FRAME"), + ScrollPosition::Sticky => Some("STICKY_SCROLLS"), + }; + f.extend(behavior.map(|x| ("scrollBehavior", e(x)))); + if let Some((i, name)) = self.starts.get(&n.id) { + f.push(("prototypeStartingPoint", o(vec![("name", s(name)), ("position", s(&position(*i)))]))); + } + if let Some(ov) = self.overlays.get(&n.id) { + const POSITIONS: [&str; 8] = ["CENTER", "TOP_LEFT", "TOP_CENTER", "TOP_RIGHT", "BOTTOM_LEFT", "BOTTOM_CENTER", "BOTTOM_RIGHT", "MANUAL"]; + let pos = POSITIONS.into_iter().find(|p| crate::rest::proto::overlay_position(p) == Some(ov.position)).unwrap_or("CENTER"); + let bg = match ov.background { + Some(c) => o(vec![("backgroundType", e("SOLID_COLOR")), ("backgroundColor", color(c))]), + None => o(vec![("backgroundType", e("NONE"))]), + }; + f.extend([ + ("overlayPositionType", e(pos)), + ("overlayBackgroundInteraction", e(if ov.close_on_outside { "CLOSE_ON_CLICK_OUTSIDE" } else { "NONE" })), + ("overlayBackgroundAppearance", bg), + ]); + } + } + + fn interaction(&mut self, it: &Interaction) -> Option> { + let mut event: Fields = Vec::new(); + let ty = match &it.trigger { + Trigger::OnClick => "ON_CLICK", + Trigger::OnHover => "ON_HOVER", + Trigger::OnPress => "ON_PRESS", + Trigger::OnDrag => "DRAG", + Trigger::MouseEnter => "MOUSE_IN", + Trigger::MouseLeave => "MOUSE_OUT", + Trigger::MouseDown => "MOUSE_DOWN", + Trigger::MouseUp => "MOUSE_UP", + Trigger::AfterDelay { ms } => { + event.push(("transitionTimeout", fl(f64::from(*ms) / 1000.0))); + "AFTER_TIMEOUT" + } + Trigger::KeyPress { key } => { + let Some((codes, pad)) = key_codes(key) else { + self.warn("key triggers on keys the format has no code for were left out"); + return None; + }; + let device = e(if pad { "UNKNOWN_CONTROLLER" } else { "KEYBOARD" }); + event.push(("keyTrigger", o(vec![("keyCodes", arr(codes.into_iter().map(int).collect())), ("triggerDevice", device)]))); + "ON_KEY_DOWN" + } + }; + // A mouse trigger's delay is the event's timeout. + if it.delay_secs() > 0.0 { + event.push(("transitionTimeout", fl(it.delay_secs()))); + } + event.insert(0, ("interactionType", e(ty))); + let actions: Vec = it.actions.iter().filter_map(|a| self.action(a, it)).collect(); + (!actions.is_empty()).then(|| o(vec![("event", o(event)), ("actions", arr(actions))])) + } + + fn action(&mut self, a: &Action, it: &Interaction) -> Option> { + let node = |x: &mut Self, dest: &NodeId, nav: &'static str| -> Option> { + let Some(g) = x.guid_of(*dest) else { + x.warn("prototype actions whose destination isn't written were left out"); + return None; + }; + let mut f: Fields = vec![("transitionNodeID", g), ("connectionType", e("INTERNAL_NODE")), ("navigationType", e(nav))]; + x.transition(it, &mut f); + Some(o(f)) + }; + let plain = |ty: &'static str| Some(o(vec![("connectionType", e(ty))])); + match a { + Action::Navigate { dest, preserve_scroll } => { + let mut act = node(self, dest, "NAVIGATE")?; + if let Kv::Object(f) = &mut act { + f.push(("transitionPreserveScroll", b(*preserve_scroll))); + } + Some(act) + } + Action::OpenOverlay { dest, position, offset, anchor, .. } => { + let mut act = node(self, dest, "OVERLAY")?; + let size = self.doc.node(*dest).map_or([0.0, 0.0], |d| [d.width, d.height]); + let offset = match anchor.offset(*position, self.trigger_size, size) { + Some(o) => Some(o), + None if *position == OverlayPosition::Manual => *offset, + None => None, + }; + if let (Kv::Object(f), Some([x, y])) = (&mut act, offset) { + f.push(("overlayRelativePosition", o(vec![("x", fl(x)), ("y", fl(y))]))); + } + Some(act) + } + Action::SwapOverlay { dest } => node(self, dest, "SWAP"), + Action::ChangeTo { dest } => node(self, dest, "SWAP_STATE"), + Action::ScrollTo { dest, offset } => { + if *offset != [0.0, 0.0] { + self.warn("scroll-to offsets were left out"); + } + node(self, dest, "SCROLL_TO") + } + Action::Back => plain("BACK"), + Action::CloseOverlay => plain("CLOSE"), + Action::OpenUrl { url } => Some(o(vec![("connectionType", e("URL")), ("connectionURL", s(url))])), + Action::SetVariable { .. } | Action::SetVariableMode { .. } | Action::Conditional { .. } => { + self.warn("variable and conditional prototype actions were left out"); + None + } + } + } + + /// An interaction's transition on one of its actions. + fn transition(&mut self, it: &Interaction, f: &mut Fields) { + let side_from = |d: Direction, l: &'static str, r: &'static str, t: &'static str, bo: &'static str| match d { + // The direction names where the incoming screen moves: it comes from the other side. + Direction::Right => l, + Direction::Left => r, + Direction::Bottom => t, + Direction::Top => bo, + }; + let side_to = |d: Direction, l: &'static str, r: &'static str, t: &'static str, bo: &'static str| match d { + Direction::Left => l, + Direction::Right => r, + Direction::Top => t, + Direction::Bottom => bo, + }; + let ty = match it.transition { + Transition::Instant => "INSTANT_TRANSITION", + Transition::Dissolve => "DISSOLVE", + Transition::SmartAnimate => "SMART_ANIMATE", + Transition::SlideIn { direction } => side_from(direction, "SLIDE_FROM_LEFT", "SLIDE_FROM_RIGHT", "SLIDE_FROM_TOP", "SLIDE_FROM_BOTTOM"), + Transition::Push { direction } => side_from(direction, "PUSH_FROM_LEFT", "PUSH_FROM_RIGHT", "PUSH_FROM_TOP", "PUSH_FROM_BOTTOM"), + Transition::MoveIn { direction } => side_from(direction, "MOVE_FROM_LEFT", "MOVE_FROM_RIGHT", "MOVE_FROM_TOP", "MOVE_FROM_BOTTOM"), + Transition::SlideOut { direction } => { + side_to(direction, "SLIDE_OUT_TO_LEFT", "SLIDE_OUT_TO_RIGHT", "SLIDE_OUT_TO_TOP", "SLIDE_OUT_TO_BOTTOM") + } + Transition::MoveOut { direction } => side_to(direction, "MOVE_OUT_TO_LEFT", "MOVE_OUT_TO_RIGHT", "MOVE_OUT_TO_TOP", "MOVE_OUT_TO_BOTTOM"), + }; + let easing = match it.easing { + Easing::Linear => "LINEAR", + Easing::EaseIn => "IN_CUBIC", + Easing::EaseOut => "OUT_CUBIC", + Easing::EaseInOut => "INOUT_CUBIC", + Easing::EaseInBack => "IN_BACK_CUBIC", + Easing::EaseOutBack => "OUT_BACK_CUBIC", + Easing::EaseInOutBack => "INOUT_BACK_CUBIC", + Easing::Gentle => "GENTLE_SPRING", + Easing::CustomBezier { .. } => "CUSTOM_CUBIC", + Easing::Quick | Easing::Bouncy | Easing::Slow | Easing::CustomSpring { .. } => { + self.warn("spring animations other than Gentle were written as ease out"); + "OUT_CUBIC" + } + }; + f.extend([("transitionType", e(ty)), ("transitionDuration", fl(f64::from(it.duration_ms) / 1000.0)), ("easingType", e(easing))]); + if let Easing::CustomBezier { x1, y1, x2, y2 } = it.easing { + f.push(("easingFunction", arr(vec![fl(x1), fl(y1), fl(x2), fl(y2)]))); + } + } +} diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs new file mode 100644 index 00000000..7ef79a78 --- /dev/null +++ b/crates/format/src/fig/export/shape.rs @@ -0,0 +1,341 @@ +use super::common::{style_fields, unsupported_decoration_look}; +use super::*; + +impl<'d> Exporter<'d> { + /// Text: its style, box settings, and characters with their styled runs. `fills`: the layer's + /// (what runs without fills of their own show when the text's own style sets other fills). + pub(super) fn text(&mut self, td: &TextData, fills: &[Paint], f: &mut Fields) { + style_fields(&td.style, f); + let (ha, va) = ( + match td.align_h { + TextAlignH::Left => "LEFT", + TextAlignH::Center => "CENTER", + TextAlignH::Right => "RIGHT", + TextAlignH::Justified => "JUSTIFIED", + }, + match td.align_v { + TextAlignV::Top => "TOP", + TextAlignV::Middle => "CENTER", + TextAlignV::Bottom => "BOTTOM", + }, + ); + let resize = match td.auto_resize { + TextAutoResize::WidthAndHeight => "WIDTH_AND_HEIGHT", + TextAutoResize::Height => "HEIGHT", + TextAutoResize::None | TextAutoResize::Truncate => "NONE", + }; + f.extend([ + ("textAlignHorizontal", e(ha)), + ("textAlignVertical", e(va)), + ("textAutoResize", e(resize)), + ("paragraphSpacing", fl(td.paragraph_spacing)), + ("paragraphIndent", fl(td.paragraph_indent)), + ("autoRename", b(false)), + ("fontVariantCommonLigatures", b(true)), + ("fontVariantContextualLigatures", b(true)), + ("fontVersion", s("")), + ("textUserLayoutVersion", u(5)), + ("textExplicitLayoutVersion", u(1)), + ("textBidiVersion", u(1)), + ("textTracking", fl(0.0)), + ("emojiImageSet", e("APPLE")), + ]); + if td.truncates() { + f.push(("textTruncation", e("ENDING"))); + } + if td.max_lines > 0 { + f.push(("maxLines", int(td.max_lines.into()))); + } + // Styles per UTF-16 unit: 0 for the base style, else an entry of the override table. + if td.char_count() > MAX_CHARS { + self.warn("text longer than 1,048,576 characters was cut"); + } + let spans = td.spans(); + let mut table: Vec<&TextStyle> = Vec::new(); + let mut span_ids = Vec::with_capacity(spans.len()); + for (_, _, st) in &spans { + span_ids.push(if **st == td.style { + 0 + } else { + let i = table.iter().position(|t| *t == *st).unwrap_or_else(|| { + table.push(st); + table.len() - 1 + }); + i as u32 + 1 + }); + } + let mut chars = String::new(); + let mut ids: Vec = Vec::new(); + let mut span = 0; + for (i, ch) in td.characters.chars().take(MAX_CHARS).enumerate() { + while spans.get(span).is_some_and(|sp| i >= sp.1) { + span += 1; + } + // NULs can't be stored in a string. + if ch == '\0' { + continue; + } + chars.push(ch); + let id = span_ids.get(span).copied().unwrap_or(0); + ids.extend((0..ch.len_utf16()).map(|_| u(id))); + } + let mut data: Fields = vec![("characters", Kv::Str(chars))]; + if !table.is_empty() { + let entries = table + .iter() + .zip(1u32..) + .map(|(st, id)| { + let mut ef: Fields = vec![("styleID", u(id))]; + style_fields(st, &mut ef); + // An empty link clears the text's own (a missing one would inherit it). + if st.link.is_none() && td.style.link.is_some() { + ef.push(("hyperlink", o(vec![("url", s(""))]))); + } + if let Some(p) = st.fills.as_deref().or(td.style.fills.as_ref().map(|_| fills)) { + ef.push(("fillPaints", self.paints(p, true, &Bound::default()))); + } + o(ef) + }) + .collect(); + data.extend([("characterStyleIDs", arr(ids)), ("styleOverrideTable", arr(entries))]); + } + let lists: Vec = (0..td.paragraph_count()).map(|p| td.para_list(p).list).collect(); + let first = |i: usize, l: &ListStyle| *l != ListStyle::None && (i == 0 || lists.get(i - 1) != Some(l)); + // Where each line's numbered list starts counting, less one: its first item's start, on + // every line of the list. + let mut run = 0; + let starts: Vec = lists + .iter() + .enumerate() + .map(|(i, l)| { + if first(i, l) { + run = td.para_list(i).start_offset; + } + if *l == ListStyle::Numbered { run } else { 0 } + }) + .collect(); + // One entry per paragraph, each with every field a reader expects. + let line = |(i, l): (usize, &ListStyle)| { + let ty = match l { + ListStyle::None => "PLAIN", + ListStyle::Numbered => "ORDERED_LIST", + ListStyle::Bulleted => "UNORDERED_LIST", + }; + let listed = *l != ListStyle::None; + let begins = first(i, l); + let start = starts.get(i).copied().unwrap_or(0); + o(vec![ + ("lineType", e(ty)), + ("styleId", int(0)), + ("indentationLevel", int(i64::from(listed))), + ("sourceDirectionality", e("AUTO")), + ("listStartOffset", int(i64::from(start))), + ("isFirstLineOfList", b(begins)), + ]) + }; + data.push(("lines", arr(lists.iter().enumerate().map(line).collect()))); + f.push(("textData", o(data))); + if (0..td.paragraph_count()).any(|p| td.para_list(p).indent > 0) { + self.warn("nested list levels were written as the first level"); + } + let styles = std::iter::once(&td.style).chain(td.runs.iter().map(|r| &r.style)); + if styles.clone().any(|st| unsupported_decoration_look(st) || !st.variations.is_empty()) { + self.warn("text decoration looks and variable font settings were left out"); + } + if td.path.is_some() { + self.warn("text on a path was written as plain text"); + } + if td.list_spacing != 0.0 || td.hanging_punctuation || td.hanging_list || td.leading_trim != Default::default() || !td.features.is_empty() { + self.warn("list spacing, hanging punctuation, leading trim and OpenType features were left out"); + } + } + + /// The editable network (with per-point corner radii, handle mirroring and end caps) and, + /// when it has filled areas, their outline. + pub(super) fn vector(&mut self, n: &Node, net: &VectorNetwork, f: &mut Fields) { + let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum(); + if net.vertices.len() + net.segments.len() + entries > MAX_NETWORK { + self.warn("vector networks too large for the format were written as outlines"); + } else { + // The stroke starts at the first end point and stops at the last: the layer's end caps go + // there (where the point has none of its own). + let mut degree = vec![0usize; net.vertices.len()]; + for sg in &net.segments { + for i in [sg.a, sg.b] { + if let Some(d) = degree.get_mut(i) { + *d += 1; + } + } + } + let ends: Vec = (0..degree.len()).filter(|i| degree.get(*i) == Some(&1)).collect(); + let (first, last) = (ends.first().copied(), ends.last().copied()); + let caps_differ = n.stroke.cap_start != n.stroke.cap_end || net.vertices.iter().any(|v| v.cap.is_some()); + let mut styles: Vec = Vec::new(); + let mut style_of = |fields: Fields| -> u32 { + if fields.is_empty() { + return 0; + } + let i = styles.iter().position(|x| *x == fields).unwrap_or_else(|| { + styles.push(fields); + styles.len() - 1 + }); + i as u32 + 1 + }; + let vertices = net + .vertices + .iter() + .enumerate() + .map(|(i, v)| { + let mut st: Fields = Vec::new(); + if v.radius > 0.0 { + st.push(("cornerRadius", fl(v.radius))); + } + if let Some(m) = v.mirror { + st.push(( + "handleMirroring", + e(match m { + figcraft_doc::HandleMirroring::None => "NONE", + figcraft_doc::HandleMirroring::Angle => "ANGLE", + figcraft_doc::HandleMirroring::AngleAndLength => "ANGLE_AND_LENGTH", + }), + )); + } + let end_cap = if Some(i) == first { + Some(n.stroke.cap_start) + } else if Some(i) == last { + Some(n.stroke.cap_end) + } else { + None + }; + if let Some(c) = v.cap.or(end_cap.filter(|_| caps_differ)) { + st.push(("strokeCap", e(cap_name(c)))); + } + if let Some(j) = v.join { + st.push(("strokeJoin", e(join_name(j)))); + } + network::Vertex { style: style_of(st), x: finite(v.x), y: finite(v.y) } + }) + .collect(); + let segments = net + .segments + .iter() + .map(|sg| network::Segment { + style: 0, + a: sg.a, + ta: (finite(sg.ta.0), finite(sg.ta.1)), + b: sg.b, + tb: (finite(sg.tb.0), finite(sg.tb.1)), + }) + .collect(); + let regions = net + .regions + .iter() + .map(|r| network::Region { style: 0, non_zero: r.winding == WindingRule::NonZero, loops: r.loops.clone() }) + .collect(); + let blob = network::encode(&network::Network { vertices, segments, regions }); + let table = styles.into_iter().zip(1u32..).map(|(mut st, id)| { + st.insert(0, ("styleID", u(id))); + o(st) + }); + let table: Vec = table.collect(); + if let Some(i) = self.blob(blob) { + f.push(( + "vectorData", + o(vec![("vectorNetworkBlob", u(i)), ("normalizedSize", xy(n.width, n.height)), ("styleOverrideTable", arr(table))]), + )); + } + } + if !net.regions.is_empty() { + let winding = net.regions.first().map(|r| r.winding).unwrap_or_default(); + if net.regions.iter().any(|r| r.winding != winding) { + self.warn("vectors whose filled areas use different winding rules use the first one's in their outline"); + } + self.geometry("fillGeometry", &net.fill_path(), winding, f); + } + } + + /// `fillGeometry` or `strokeGeometry` (`key`) from a local path. + pub(super) fn geometry(&mut self, key: &'static str, p: &BezPath, winding: WindingRule, f: &mut Fields) { + if p.elements().is_empty() { + return; + } + if let Some(i) = self.blob(path_blob(p)) { + let rule = if winding == WindingRule::EvenOdd { "ODD" } else { "NONZERO" }; + f.push((key, arr(vec![o(vec![("windingRule", e(rule)), ("commandsBlob", u(i)), ("styleID", u(0))])]))); + } + } + + /// `strokeGeometry`, the outline of a plain stroke: a ring around boxes and whole ellipses, the + /// widened path of other centred strokes. Dashed, per-side and other strokes get none (a reader + /// builds theirs from the stroke settings). + pub(super) fn stroke_geometry(&mut self, n: &Node, boxy: bool, f: &mut Fields) { + let sp = &n.stroke; + let (w, h, weight) = (n.width, n.height, sp.weight); + let plain = n.strokes.iter().any(|p| p.visible) && sp.dashes.is_empty() && sp.sides.is_none() && sp.profile().is_none() && sp.brush.is_none(); + if !(plain && weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) { + return; + } + let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep.abs() >= std::f64::consts::TAU - 1e-9); + if boxy || whole { + let (out, inset) = match sp.align { + StrokeAlign::Inside => (0.0, weight), + StrokeAlign::Center => (weight / 2.0, weight / 2.0), + StrokeAlign::Outside => (weight, 0.0), + }; + // The shape grown by `d` (shrunk when negative), or nothing when it shrinks away. + let grown = |d: f64| -> Option { + let (gw, gh) = (w + 2.0 * d, h + 2.0 * d); + if gw <= 0.0 || gh <= 0.0 { + return None; + } + let mut p = if whole { + figcraft_doc::geom::ellipse(gw, gh, 0.0, std::f64::consts::TAU, 0.0) + } else { + let radii = n.corners.radii.map(|r| if r > 0.0 { (r + d).max(0.0) } else { 0.0 }); + figcraft_doc::geom::rounded_rect(gw, gh, radii, n.corners.smoothing) + }; + p.apply_affine(Affine::translate((-d, -d))); + Some(p) + }; + let Some(mut ring) = grown(out) else { return }; + ring.extend(grown(-inset).iter().flat_map(|p| p.elements().iter().copied())); + self.geometry("strokeGeometry", &ring, WindingRule::EvenOdd, f); + } else if sp.align == StrokeAlign::Center { + let path = match &n.kind { + NodeKind::Vector { network } => Some(network.stroke_path()), + NodeKind::Text(_) => None, + _ => figcraft_doc::geometry::shape_path(n), + }; + let Some(path) = path.filter(|p| !p.elements().is_empty()) else { return }; + use figcraft_doc::geom::kurbo::{Cap, Join, Stroke, StrokeOpts, stroke}; + let cap = match sp.cap_start { + StrokeCap::Round => Cap::Round, + StrokeCap::Square => Cap::Square, + _ => Cap::Butt, + }; + let join = match sp.join { + StrokeJoin::Miter => Join::Miter, + StrokeJoin::Bevel => Join::Bevel, + StrokeJoin::Round => Join::Round, + }; + let limit = if sp.miter_limit.is_finite() { sp.miter_limit.max(1.0) } else { 4.0 }; + let style = Stroke::new(weight).with_caps(cap).with_join(join).with_miter_limit(limit); + self.geometry("strokeGeometry", &stroke(path, &style, &StrokeOpts::default(), 0.05), WindingRule::NonZero, f); + } + } + + /// Add a blob; its index (`None` once the blobs would make the file too large). + fn blob(&mut self, bytes: Vec) -> Option { + if let Some(i) = self.blobs.get(&bytes) { + return Some(*i); + } + self.blob_bytes += bytes.len() as u64; + if self.blob_bytes > MAX_CANVAS { + self.warn("vector data too large for the format was left out"); + return None; + } + let i = u32::try_from(self.blobs.len()).ok()?; + self.blobs.insert(bytes, i); + Some(i) + } +}