//! Export to `.fig` (experimental, best effort), from the same public format knowledge as the //! import: the inverse of its mapping ([`super::map`]). //! //! The file is the zip the import reads: `canvas.fig` (the fig-kiwi header, then the binary schema //! and the message, each a raw-deflate chunk), `meta.json`, an optional `thumbnail.png` and //! `images/`. The schema travels in the file, so it describes only the fields written here //! ([`SCHEMA`]). The message lists one node change per layer (its id, its parent and its position //! among the siblings, then its fields), parents first, plus blobs (vector networks and outlines). //! Instances are written as their main, the fields they and their layers override (by id path) and //! what follows from those (derived data: sizes, places, outlines), like the files the import //! reads. Shared styles, variable collections and variables are nodes of their own on a hidden //! page; layers, paints, effects and layout grids name the ones they use. What the format can't //! carry yet (Dev Mode data, comments, …) is left out and listed in the warnings. Round trips //! through our own importer are tested, and written files were opened in other software //! (`docs/file-format-parity.md` says what was checked); other apps may not open every file. use std::collections::BTreeMap; use std::io::Write; use std::sync::Arc; use figcraft_doc::color::{BlendMode, Color, GradientKind, ImageScaleMode, Paint, PaintKind}; use figcraft_doc::geom::kurbo::PathEl; use figcraft_doc::geom::{Affine, BezPath}; use figcraft_doc::proto::{ Action, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, }; use figcraft_doc::{ AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, HConstraint, LayoutMode, LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, VariableValue, VectorNetwork, WindingRule, }; use serde_json::Value; use super::kiwi::{Kv, Schema, encode}; use super::network; use crate::FormatError; /// Most layers written, and deepest nesting followed (the import's limits). const MAX_NODES: usize = crate::rest::MAX_NODES; const MAX_DEPTH: usize = 256; /// Largest `canvas.fig` and image written (what the import reads). const MAX_CANVAS: u64 = super::MAX_CANVAS; const MAX_IMAGE: u64 = super::MAX_IMAGE; /// Largest whole file written. const MAX_FILE: u64 = 2 * 1024 * 1024 * 1024; /// Most characters of one text layer, and vertices + segments + loop entries of one vector /// network (what the import reads). const MAX_CHARS: usize = 1 << 20; const MAX_NETWORK: usize = 200_000; /// Largest magnitude written for a float (the format stores 32-bit floats). const MAX_FLOAT: f64 = 1e30; /// The version number in the `canvas.fig` header. const VERSION: u32 = 101; /// Session ids of the GUIDs: layers, and grid tracks and component properties. const LAYERS: u32 = 1; const AUX: u32 = 2; /// The schema written into every file: `enum Name A=value …`, `struct Name type field, …` (every /// field, in order) and `message Name type field=id, …`, with the names, kinds and numbers the /// format gives them. Only what the export writes. pub(super) const SCHEMA: &str = " enum MessageType JOIN_START=0 NODE_CHANGES=1 enum NodePhase CREATED=0 REMOVED=1 enum NodeType NONE=0 DOCUMENT=1 CANVAS=2 GROUP=3 FRAME=4 BOOLEAN_OPERATION=5 VECTOR=6 STAR=7 LINE=8 ELLIPSE=9 RECTANGLE=10 REGULAR_POLYGON=11 ROUNDED_RECTANGLE=12 TEXT=13 SLICE=14 SYMBOL=15 INSTANCE=16 SECTION=25 VARIABLE=28 VARIABLE_SET=31 enum BlendMode PASS_THROUGH=0 NORMAL=1 DARKEN=2 MULTIPLY=3 LINEAR_BURN=4 COLOR_BURN=5 LIGHTEN=6 SCREEN=7 LINEAR_DODGE=8 COLOR_DODGE=9 OVERLAY=10 SOFT_LIGHT=11 HARD_LIGHT=12 DIFFERENCE=13 EXCLUSION=14 HUE=15 SATURATION=16 COLOR=17 LUMINOSITY=18 enum PaintType SOLID=0 GRADIENT_LINEAR=1 GRADIENT_RADIAL=2 GRADIENT_ANGULAR=3 GRADIENT_DIAMOND=4 IMAGE=5 enum ImageScaleMode STRETCH=0 FIT=1 FILL=2 TILE=3 enum WindingRule NONZERO=0 ODD=1 enum StrokeAlign CENTER=0 INSIDE=1 OUTSIDE=2 enum StrokeCap NONE=0 ROUND=1 SQUARE=2 ARROW_LINES=3 ARROW_EQUILATERAL=4 TRIANGLE_FILLED=6 DIAMOND_FILLED=5 CIRCLE_FILLED=14 enum StrokeJoin MITER=0 BEVEL=1 ROUND=2 enum ConstraintType MIN=0 CENTER=1 MAX=2 STRETCH=3 SCALE=4 enum StackMode NONE=0 HORIZONTAL=1 VERTICAL=2 GRID=3 enum StackAlign MIN=0 CENTER=1 MAX=2 BASELINE=3 enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5 enum StackCounterAlign MIN=0 CENTER=1 MAX=2 STRETCH=3 AUTO=4 BASELINE=5 enum StackSize FIXED=0 RESIZE_TO_FIT=1 enum StackWrap NO_WRAP=0 WRAP=1 enum StackCounterAlignContent AUTO=0 SPACE_BETWEEN=1 enum StackPositioning AUTO=0 ABSOLUTE=1 enum EffectType INNER_SHADOW=0 DROP_SHADOW=1 FOREGROUND_BLUR=2 BACKGROUND_BLUR=3 NOISE=7 GRAIN=6 GLASS=8 enum BlurOpType NORMAL=0 PROGRESSIVE=1 enum NoiseType MONOTONE=1 DUOTONE=2 MULTITONE=0 enum TextAlignHorizontal LEFT=0 CENTER=1 RIGHT=2 JUSTIFIED=3 enum TextAlignVertical TOP=0 CENTER=1 BOTTOM=2 enum TextAutoResize NONE=0 WIDTH_AND_HEIGHT=1 HEIGHT=2 enum TextTruncation DISABLED=0 ENDING=1 enum TextCase ORIGINAL=0 UPPER=1 LOWER=2 TITLE=3 SMALL_CAPS=4 enum TextDecoration NONE=0 UNDERLINE=1 STRIKETHROUGH=2 enum NumberUnits RAW=0 PIXELS=1 PERCENT=2 enum LineType PLAIN=0 ORDERED_LIST=1 UNORDERED_LIST=2 enum SourceDirectionality AUTO=0 LTR=1 RTL=2 enum DocumentColorProfile LEGACY=0 SRGB=1 DISPLAY_P3=2 enum EmojiImageSet APPLE=0 NOTO=1 enum MaskType ALPHA=0 OUTLINE=1 LUMINANCE=2 enum VariableResolvedDataType BOOLEAN=0 FLOAT=1 STRING=2 COLOR=4 SYMBOL_ID=6 TEXT_DATA=8 enum VectorMirror NONE=0 ANGLE=1 ANGLE_AND_LENGTH=2 enum BooleanOperation UNION=0 INTERSECT=1 SUBTRACT=2 XOR=3 enum LayoutGridType MIN=0 CENTER=1 STRETCH=2 MAX=3 enum LayoutGridPattern STRIPES=0 GRID=1 enum Axis X=0 Y=1 enum ImageType PNG=0 JPEG=1 SVG=2 PDF=3 enum ExportConstraintType CONTENT_SCALE=0 CONTENT_WIDTH=1 CONTENT_HEIGHT=2 enum ComponentPropType BOOL=0 TEXT=1 INSTANCE_SWAP=3 VARIANT=4 enum VariableField MISSING=0 CORNER_RADIUS=1 PARAGRAPH_SPACING=2 PARAGRAPH_INDENT=3 STROKE_WEIGHT=4 STACK_SPACING=5 STACK_PADDING_LEFT=6 STACK_PADDING_TOP=7 STACK_PADDING_RIGHT=8 STACK_PADDING_BOTTOM=9 VISIBLE=10 TEXT_DATA=11 WIDTH=12 HEIGHT=13 RECTANGLE_TOP_LEFT_CORNER_RADIUS=14 RECTANGLE_TOP_RIGHT_CORNER_RADIUS=15 RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS=16 RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS=17 BORDER_TOP_WEIGHT=18 BORDER_BOTTOM_WEIGHT=19 BORDER_LEFT_WEIGHT=20 BORDER_RIGHT_WEIGHT=21 STACK_COUNTER_SPACING=23 MIN_WIDTH=24 MAX_WIDTH=25 MIN_HEIGHT=26 MAX_HEIGHT=27 FONT_FAMILY=28 FONT_STYLE=29 OPACITY=31 FONT_SIZE=32 LETTER_SPACING=34 LINE_HEIGHT=36 OVERRIDDEN_SYMBOL_ID=37 enum VariableDataType BOOLEAN=0 FLOAT=1 STRING=2 ALIAS=3 COLOR=4 PROP_REF=13 enum VariableScope ALL_SCOPES=0 TEXT_CONTENT=1 CORNER_RADIUS=2 WIDTH_HEIGHT=3 GAP=4 ALL_FILLS=5 FRAME_FILL=6 SHAPE_FILL=7 TEXT_FILL=8 STROKE=9 STROKE_FLOAT=10 EFFECT_FLOAT=11 EFFECT_COLOR=12 OPACITY=13 FONT_STYLE=14 FONT_FAMILY=15 FONT_SIZE=16 LINE_HEIGHT=17 LETTER_SPACING=18 PARAGRAPH_SPACING=19 PARAGRAPH_INDENT=20 enum StyleType NONE=0 FILL=1 STROKE=2 TEXT=3 EFFECT=4 EXPORT=5 GRID=6 enum InteractionType ON_CLICK=0 DRAG=9 ON_HOVER=4 ON_PRESS=7 MOUSE_IN=2 MOUSE_OUT=3 MOUSE_DOWN=5 MOUSE_UP=6 AFTER_TIMEOUT=1 ON_KEY_DOWN=10 enum TriggerDevice KEYBOARD=0 UNKNOWN_CONTROLLER=1 enum ConnectionType NONE=0 INTERNAL_NODE=1 URL=2 BACK=3 CLOSE=4 enum NavigationType NAVIGATE=0 OVERLAY=1 SWAP=2 SWAP_STATE=3 SCROLL_TO=4 enum TransitionType INSTANT_TRANSITION=0 DISSOLVE=1 SMART_ANIMATE=24 SLIDE_FROM_LEFT=3 SLIDE_FROM_RIGHT=4 SLIDE_FROM_TOP=5 SLIDE_FROM_BOTTOM=6 PUSH_FROM_LEFT=7 PUSH_FROM_RIGHT=8 PUSH_FROM_TOP=9 PUSH_FROM_BOTTOM=10 MOVE_FROM_LEFT=11 MOVE_FROM_RIGHT=12 MOVE_FROM_TOP=13 MOVE_FROM_BOTTOM=14 SLIDE_OUT_TO_LEFT=15 SLIDE_OUT_TO_RIGHT=16 SLIDE_OUT_TO_TOP=17 SLIDE_OUT_TO_BOTTOM=18 MOVE_OUT_TO_LEFT=19 MOVE_OUT_TO_RIGHT=20 MOVE_OUT_TO_TOP=21 MOVE_OUT_TO_BOTTOM=22 enum EasingType IN_CUBIC=0 OUT_CUBIC=1 INOUT_CUBIC=2 LINEAR=3 IN_BACK_CUBIC=4 OUT_BACK_CUBIC=5 INOUT_BACK_CUBIC=6 CUSTOM_CUBIC=7 GENTLE_SPRING=9 enum ScrollDirection NONE=0 HORIZONTAL=1 VERTICAL=2 BOTH=3 enum ScrollBehavior SCROLLS=0 FIXED_WHEN_CHILD_OF_SCROLLING_FRAME=1 STICKY_SCROLLS=2 enum OverlayPositionType CENTER=0 TOP_LEFT=1 TOP_CENTER=2 TOP_RIGHT=3 BOTTOM_LEFT=4 BOTTOM_CENTER=5 BOTTOM_RIGHT=6 MANUAL=7 enum OverlayBackgroundInteraction NONE=0 CLOSE_ON_CLICK_OUTSIDE=1 enum OverlayBackgroundType NONE=0 SOLID_COLOR=1 struct GUID uint sessionID, uint localID struct Color float r, float g, float b, float a struct Vector float x, float y struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 struct ColorStop Color color, float position struct ParentIndex GUID guid, string position message Image byte[] hash=1, string name=2 message VariableWidthPoint float position=1, float ascent=2, float descent=3 message PluginData string pluginID=1, string value=2, string key=3 message StyleId GUID guid=1 message VariableID GUID guid=1 message VariableSetID GUID guid=1 message ColorStopVar Color color=1, VariableData colorVar=2, float position=3 message Paint PaintType type=1, Color color=2, float opacity=3, bool visible=4, BlendMode blendMode=5, ColorStop[] stops=6, Matrix transform=7, Image image=8, ImageScaleMode imageScaleMode=10, float rotation=11, float scale=12, VariableData colorVar=21, ColorStopVar[] stopsVar=23 message Effect EffectType type=1, Color color=2, Vector offset=3, float radius=4, bool visible=5, BlendMode blendMode=6, float spread=7, bool showShadowBehindNode=8, BlurOpType blurOpType=19, float startRadius=21, Vector startOffset=20, Vector endOffset=28, NoiseType noiseType=26, Vector noiseSize=22, float density=25, float opacity=27, Color secondaryColor=24, bool clipToShape=23, VariableData radiusVar=9, VariableData colorVar=10, VariableData spreadVar=11, VariableData xVar=12, VariableData yVar=13, VariableData startRadiusVar=45 struct FontName string family, string style, string postscript struct Number float value, NumberUnits units message Hyperlink string url=1 message TextLineData LineType lineType=1, int styleId=10, int indentationLevel=2, SourceDirectionality sourceDirectionality=9, int listStartOffset=7, bool isFirstLineOfList=8 message TextData string characters=1, uint[] characterStyleIDs=2, NodeChange[] styleOverrideTable=3, TextLineData[] lines=12 message Path WindingRule windingRule=1, uint commandsBlob=2, uint styleID=3 message VectorData uint vectorNetworkBlob=1, Vector normalizedSize=2, NodeChange[] styleOverrideTable=3 message ArcData float startingAngle=1, float endingAngle=2, float innerRadius=3 message OptionalVector Vector value=1 message LayoutGrid LayoutGridType type=1, Axis axis=2, bool visible=3, int numSections=4, float offset=5, float sectionSize=6, float gutterSize=7, Color color=8, LayoutGridPattern pattern=9, VariableData numSectionsVar=10, VariableData offsetVar=11, VariableData sectionSizeVar=12, VariableData gutterSizeVar=13 struct ExportConstraint ExportConstraintType type, float value message ExportSettings string suffix=1, ImageType imageType=2, ExportConstraint constraint=3 message GUIDPath GUID[] guids=1 message SymbolData GUID symbolID=1, NodeChange[] symbolOverrides=2, float uniformScaleFactor=3 message ComponentPropValue bool boolValue=1, TextData textValue=2, GUID guidValue=3 message ComponentPropDef GUID id=1, string name=2, ComponentPropValue initialValue=3, ComponentPropType type=6 message ComponentPropAssignment GUID defID=1, ComponentPropValue value=2 message PropRefValue GUID defId=1 message VariableAnyValue bool boolValue=1, string textValue=2, float floatValue=3, VariableID alias=4, Color colorValue=5, PropRefValue propRefValue=13 message VariableData VariableAnyValue value=1, VariableDataType dataType=2, VariableResolvedDataType resolvedDataType=3 message VariableDataMapEntry uint nodeField=1, VariableData variableData=2, VariableField variableField=3 message VariableDataMap VariableDataMapEntry[] entries=1 message VariableModeBySetMapEntry VariableSetID variableSetID=1, GUID variableModeID=2 message VariableModeBySetMap VariableModeBySetMapEntry[] entries=1 message VariableSetMode GUID id=1, string name=2, string sortPosition=3 message VariableDataValuesEntry GUID modeID=1, VariableData variableData=2 message VariableDataValues VariableDataValuesEntry[] entries=1 message KeyTrigger int[] keyCodes=1, TriggerDevice triggerDevice=2 message PrototypeEvent InteractionType interactionType=1, float transitionTimeout=6, KeyTrigger keyTrigger=4 message PrototypeAction GUID transitionNodeID=1, TransitionType transitionType=2, float transitionDuration=3, EasingType easingType=4, float[] easingFunction=12, ConnectionType connectionType=7, Vector overlayRelativePosition=9, NavigationType navigationType=10, string connectionURL=8, bool transitionPreserveScroll=11 message PrototypeInteraction PrototypeEvent event=2, PrototypeAction[] actions=3 message PrototypeStartingPoint string name=1, string position=3 message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2 message GUIDPositionMapEntry GUID id=1, string position=2 message GUIDPositionMap GUIDPositionMapEntry[] entries=1 message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 struct Blob byte[] bytes message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6 "; /// Write `doc` as a `.fig` file, with `thumbnail` (a PNG) when given. Returns the bytes and the /// warnings about what was left out or approximated. pub fn export(doc: &Document, thumbnail: Option<&[u8]>) -> Result<(Vec, Vec), FormatError> { let mut x = Exporter::new(doc); let message = x.message(); let fail = |e: super::kiwi::KiwiError| FormatError::Io(format!(".fig export: {e}")); let schema = Schema::parse(SCHEMA).map_err(fail)?; let root = schema.find("Message").ok_or_else(|| FormatError::Io(".fig export: the schema has no Message".into()))?; let body = encode::message(&schema, root, &message).map_err(fail)?; let canvas = canvas(&encode::schema(&schema), &body)?; let background = doc.pages.first().and_then(|p| doc.node(*p)).and_then(|p| match &p.kind { NodeKind::Page { background, .. } => Some(*background), _ => None, }); let bytes = archive(&doc.name, background.unwrap_or(Color::WHITE), &canvas, &x.files, thumbnail)?; Ok((bytes, x.warnings)) } fn too_large() -> FormatError { FormatError::Io("the document is too large for a .fig file".into()) } /// `canvas.fig`: the magic, the version and the two deflated chunks, each after its length. fn canvas(schema: &[u8], message: &[u8]) -> Result, FormatError> { let mut out = b"fig-kiwi".to_vec(); out.extend_from_slice(&VERSION.to_le_bytes()); for chunk in [schema, message] { let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); z.write_all(chunk).map_err(crate::io_err)?; let c = z.finish().map_err(crate::io_err)?; let len = u32::try_from(c.len()).map_err(|_| too_large())?; out.extend_from_slice(&len.to_le_bytes()); out.extend_from_slice(&c); } if out.len() as u64 > MAX_CANVAS { return Err(too_large()); } Ok(out) } /// The zip around `canvas.fig`. fn archive( name: &str, background: Color, canvas: &[u8], images: &[(String, Arc>)], thumbnail: Option<&[u8]>, ) -> Result, FormatError> { let mut buf = std::io::Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); crate::put(&mut z, "canvas.fig", stored, canvas)?; if let Some(t) = thumbnail { crate::put(&mut z, "thumbnail.png", stored, t)?; } let mut client = serde_json::json!({"background_color": {"r": background.r, "g": background.g, "b": background.b, "a": background.a}}); // A PNG says its size right after its signature and header chunk name. let be = |at: usize| thumbnail.and_then(|t| t.get(at..at + 4)).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); if let (Some(w), Some(h)) = (be(16), be(20)) { client["thumbnail_size"] = serde_json::json!({"width": w, "height": h}); } let meta = serde_json::to_vec(&serde_json::json!({"client_meta": client, "file_name": name})).map_err(crate::io_err)?; crate::put(&mut z, "meta.json", crate::json_options(), &meta)?; let mut total = canvas.len() as u64; for (hash, bytes) in images { total += bytes.len() as u64; if total > MAX_FILE { return Err(too_large()); } crate::put(&mut z, &format!("images/{hash}"), stored, bytes)?; } z.finish().map_err(crate::io_err)?; } Ok(buf.into_inner()) } type Fields = Vec<(&'static str, Kv<'static>)>; fn o(f: Fields) -> Kv<'static> { Kv::Object(f) } fn arr(v: Vec>) -> Kv<'static> { Kv::Array(v) } /// A float the format can hold: finite (non-finite values become 0) and within range. fn fl(x: f64) -> Kv<'static> { Kv::Float(if x.is_finite() { x.clamp(-MAX_FLOAT, MAX_FLOAT) as f32 } else { 0.0 }) } fn u(x: u32) -> Kv<'static> { Kv::Uint(u64::from(x)) } fn int(x: i64) -> Kv<'static> { Kv::Int(x.clamp(i64::from(i32::MIN), i64::from(i32::MAX))) } fn e(name: &'static str) -> Kv<'static> { Kv::Enum(name) } fn s(x: &str) -> Kv<'static> { Kv::Str(x.to_string()) } fn b(x: bool) -> Kv<'static> { Kv::Bool(x) } fn guid(session: u32, local: u32) -> Kv<'static> { o(vec![("sessionID", u(session)), ("localID", u(local))]) } fn xy(x: f64, y: f64) -> Kv<'static> { o(vec![("x", fl(x)), ("y", fl(y))]) } fn color(c: Color) -> Kv<'static> { o(vec![("r", fl(c.r.into())), ("g", fl(c.g.into())), ("b", fl(c.b.into())), ("a", fl(c.a.into()))]) } fn matrix(m: Affine) -> Kv<'static> { let [a, b, c, d, e, f] = m.as_coeffs(); o(vec![("m00", fl(a)), ("m01", fl(c)), ("m02", fl(e)), ("m10", fl(b)), ("m11", fl(d)), ("m12", fl(f))]) } /// What the document and its pages say besides their names: left out, a reader takes them as /// hidden and fully transparent, and draws nothing below them. fn root_fields() -> Fields { vec![ ("visible", b(true)), ("opacity", fl(1.0)), ("transform", matrix(Affine::IDENTITY)), ("strokeWeight", fl(0.0)), ("strokeAlign", e("CENTER")), ("strokeJoin", e("BEVEL")), ] } /// A finite coordinate (vector network blobs store raw floats). fn finite(x: f64) -> f64 { if x.is_finite() { x.clamp(-MAX_FLOAT, MAX_FLOAT) } else { 0.0 } } /// A position string that sorts as child `i` does among its siblings (fixed width, printable). fn position(i: usize) -> String { let mut out = [0u8; 4]; let mut v = i; for c in out.iter_mut().rev() { *c = b'!' + (v % 94) as u8; v /= 94; } String::from_utf8_lossy(&out).into_owned() } /// The format's name of a blend mode: the one the import reads back as `m`. fn blend_name(m: BlendMode) -> &'static str { const NAMES: [&str; 19] = [ "PASS_THROUGH", "NORMAL", "DARKEN", "MULTIPLY", "LINEAR_BURN", "COLOR_BURN", "LIGHTEN", "SCREEN", "LINEAR_DODGE", "COLOR_DODGE", "OVERLAY", "SOFT_LIGHT", "HARD_LIGHT", "DIFFERENCE", "EXCLUSION", "HUE", "SATURATION", "COLOR", "LUMINOSITY", ]; NAMES.into_iter().find(|n| crate::rest::props::blend_mode(n) == Some(m)).unwrap_or("NORMAL") } fn cap_name(c: StrokeCap) -> &'static str { match c { StrokeCap::None => "NONE", StrokeCap::Round => "ROUND", StrokeCap::Square => "SQUARE", StrokeCap::LineArrow => "ARROW_LINES", StrokeCap::TriangleArrow | StrokeCap::ReversedTriangle => "ARROW_EQUILATERAL", StrokeCap::CircleFilled => "CIRCLE_FILLED", StrokeCap::DiamondFilled => "DIAMOND_FILLED", } } fn join_name(j: StrokeJoin) -> &'static str { match j { StrokeJoin::Miter => "MITER", StrokeJoin::Bevel => "BEVEL", StrokeJoin::Round => "ROUND", } } /// A geometry blob (the import's `svg_path` reads it): commands `0` close, `1` move, `2` line, /// `3` quadratic, `4` cubic, each followed by its points as little-endian `f32` pairs. fn path_blob(p: &BezPath) -> Vec { let mut out = Vec::new(); let mut put = |cmd: u8, pts: &[figcraft_doc::geom::Point]| { out.push(cmd); for q in pts { out.extend_from_slice(&(finite(q.x) as f32).to_le_bytes()); out.extend_from_slice(&(finite(q.y) as f32).to_le_bytes()); } }; for el in p.elements() { match *el { PathEl::MoveTo(a) => put(1, &[a]), PathEl::LineTo(a) => put(2, &[a]), PathEl::QuadTo(a, c) => put(3, &[a, c]), PathEl::CurveTo(a, c, d) => put(4, &[a, c, d]), PathEl::ClosePath => put(0, &[]), } } out } /// Settings an overlay's destination carries. struct Overlay { position: OverlayPosition, close_on_outside: bool, background: Option, } struct Exporter<'d> { doc: &'d Document, /// Layer → local id of its GUID (session [`LAYERS`]). ids: BTreeMap, nodes: Vec>, /// Blob bytes → index (identical blobs are written once). blobs: BTreeMap, u32>, blob_bytes: u64, /// Image key → its hash (lowercase hex) once written, `None` when its bytes aren't there. images: BTreeMap>, /// `images/` members. files: Vec<(String, Arc>)>, /// Component property (name, type) → local id of its definition's GUID (session [`AUX`]). defs: BTreeMap<(String, &'static str), u32>, /// Grid tracks of written grid frames: (columns, rows) local ids (session [`AUX`]). tracks: BTreeMap, Vec)>, aux: u32, overlays: BTreeMap, /// Flow starting points: layer → (order, name). starts: BTreeMap, /// Shared styles, variable collections and variables → local id of their node's GUID (session /// [`LAYERS`], after the layers'), and (collection, mode) → local id of the mode's GUID /// (session [`AUX`]). style_ids: BTreeMap<&'d str, u32>, set_ids: BTreeMap<&'d str, u32>, var_ids: BTreeMap<&'d str, u32>, mode_ids: BTreeMap<(&'d str, &'d str), u32>, warnings: Vec, } /// The variables bound to one layer, as the fields that carry them: paints by (fills?, index), /// gradient stops by (fills?, paint, stop), effect and layout grid fields by (index, field), and /// the entries of the layer's own map. #[derive(Default)] struct Bound { paints: BTreeMap<(bool, usize), Kv<'static>>, stops: BTreeMap<(bool, usize, usize), Kv<'static>>, effects: BTreeMap<(usize, &'static str), Kv<'static>>, grids: BTreeMap<(usize, &'static str), Kv<'static>>, entries: Vec>, } impl<'d> Exporter<'d> { fn new(doc: &'d Document) -> Self { Self { doc, ids: BTreeMap::new(), nodes: Vec::new(), blobs: BTreeMap::new(), blob_bytes: 0, images: BTreeMap::new(), files: Vec::new(), defs: BTreeMap::new(), tracks: BTreeMap::new(), aux: 0, overlays: BTreeMap::new(), starts: BTreeMap::new(), style_ids: BTreeMap::new(), set_ids: BTreeMap::new(), var_ids: BTreeMap::new(), mode_ids: BTreeMap::new(), warnings: Vec::new(), } } fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } fn next_aux(&mut self) -> u32 { self.aux += 1; self.aux } fn guid_of(&self, id: NodeId) -> Option> { self.ids.get(&id).map(|l| guid(LAYERS, *l)) } /// The message: the document, its pages and their layers, and the blobs. fn message(&mut self) -> Kv<'static> { let doc = self.doc; self.assign(); let profile = if doc.color_profile == figcraft_doc::color::ColorProfile::DisplayP3 { "DISPLAY_P3" } else { "SRGB" }; let mut root: Fields = vec![("guid", guid(0, 0)), ("phase", e("CREATED")), ("type", e("DOCUMENT")), ("name", s(&doc.name))]; root.extend(root_fields()); root.push(("documentColorProfile", e(profile))); self.nodes.push(o(root)); for (i, p) in doc.pages.iter().enumerate() { self.emit(*p, guid(0, 0), i, None, 0); } self.assets(doc.pages.len()); if !doc.comments.is_empty() { self.warn("comments were left out"); } let mut blobs: Vec<(u32, Vec)> = std::mem::take(&mut self.blobs).into_iter().map(|(bytes, i)| (i, bytes)).collect(); blobs.sort_by_key(|x| x.0); o(vec![ ("type", e("NODE_CHANGES")), ("sessionID", u(0)), ("nodeChanges", arr(std::mem::take(&mut self.nodes))), ("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, .. } => { for (name, d) in props { if let Some(ty) = prop_type(d) { let key = (name.clone(), ty); if !self.defs.contains_key(&key) { let id = self.next_aux(); self.defs.insert(key, id); } } } } 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, .. } = a { let o = Overlay { position: *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 => { let defs: Vec = props.iter().filter_map(|(name, d)| self.prop_def(name, d)).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))); 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"); } f.push(("exportSettings", arr(n.exports.iter().map(export_setting).collect()))); } self.prototype(n, f); let refs: Vec = n.prop_refs.iter().filter_map(|(field, prop)| self.prop_ref(field, prop)).collect(); if !refs.is_empty() { f.push(("parameterConsumptionMap", o(vec![("entries", 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.dev_status.is_none() { self.warn("Dev Mode annotations, statuses, resources 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 = crate::image_hash(&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", }; 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 { // 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))]); if l.column_tracks.iter().chain(&l.row_tracks).any(|t| t.sizing != Sizing::Fill) { self.warn("grid tracks of fixed or content size were written as equal tracks"); } self.tracks.insert(key, (cols, rows)); } 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" })), ]); } } /// 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; for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] { if w.is_some() || h.is_some() { f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))]))); } } 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)), ("textDecorationSkipInk", b(true)), ("emojiImageSet", e("APPLE")), ]); if td.auto_resize == TextAutoResize::Truncate { 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(); // 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 first = listed && (i == 0 || lists.get(i - 1) != Some(l)); o(vec![ ("lineType", e(ty)), ("styleId", int(0)), ("indentationLevel", int(i64::from(listed))), ("sourceDirectionality", e("AUTO")), ("listStartOffset", int(0)), ("isFirstLineOfList", b(first)), ]) }; 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| st.decoration_look.is_some() || !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 { 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 (variant properties are implied by the variants' names). fn prop_def(&mut self, name: &str, d: &PropDef) -> Option> { let ty = prop_type(d)?; let id = *self.defs.get(&(name.to_string(), ty))?; let value = match d { PropDef::Boolean { default } => o(vec![("boolValue", b(*default))]), PropDef::Text { default } => o(vec![("textValue", o(vec![("characters", s(default))]))]), PropDef::InstanceSwap { default, preferred } => { if !preferred.is_empty() { self.warn("preferred values of instance swap properties were left out"); } o(vec![("guidValue", self.guid_of(*default)?)]) } PropDef::Variant { .. } | PropDef::Slot { .. } => return None, }; Some(o(vec![("id", guid(AUX, id)), ("name", s(name)), ("initialValue", value), ("type", e(ty))])) } /// Which property controls a layer's `field`. fn prop_ref(&mut self, field: &str, prop: &str) -> Option> { 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.defs.get(&(prop.to_string(), ty))?; let data = o(vec![ ("value", o(vec![("propRefValue", o(vec![("defId", guid(AUX, id))]))])), ("dataType", e("PROP_REF")), ("resolvedDataType", e(resolved)), ]); Some(o(vec![("variableData", data), ("variableField", e(var_field))])) } /// 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) { const OWN: [&str; 7] = ["guid", "phase", "parentIndex", "type", "transform", "fillGeometry", "strokeGeometry"]; 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, and the component a nested instance was swapped for. struct Row { path: Kv<'static>, changed: Fields, theirs: Fields, nested: 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 nested = matches!(d.kind, NodeKind::Instance { .. }); rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, nested, 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. if follows && r.nested && 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) = match v { Value::Bool(x) => ("BOOL", Some(o(vec![("boolValue", b(*x))]))), Value::String(t) => ("TEXT", Some(o(vec![("textValue", o(vec![("characters", s(t))]))]))), Value::Number(c) => ("INSTANCE_SWAP", c.as_u64().and_then(|c| self.guid_of(NodeId(c))).map(|g| o(vec![("guidValue", g)]))), _ => continue, }; // Variant values have no definition of their own: the main they show says them. if let (Some(&id), Some(value)) = (self.defs.get(&(name.clone(), ty)), value) { assignments.push(o(vec![("defID", guid(AUX, id)), ("value", value)])); } } 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) { 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, .. } => { let mut act = node(self, dest, "OVERLAY")?; if let (Kv::Object(f), OverlayPosition::Manual, Some([x, y])) = (&mut act, position, 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_some() || !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" => 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" | "maxSize" => o(vec![("value", xy(0.0, 0.0))]), "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 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)) } /// 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(&super::style_name(&st.font_style))), ("postscript", s(""))])), ("fontSize", fl(st.font_size)), ("lineHeight", line_height), ("letterSpacing", letter_spacing), ("textCase", e(case)), ("textDecoration", e(decoration)), ]); 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", figcraft_doc::ExportFormat::Svg => "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))]))]) }