Files
figcraft/crates/format/src/fig/export.rs
T
Trevor BrownandClaude Fable 5.1 cd5c9aeb09 M4: a paragraph's base direction can be forced: Text ▸ Text direction (#1830)
Direction came from the letters only; Add right-to-left text says controls override it per paragraph (Text > Text direction, Left to right or Right to left). `TextDirection` on `ParaList`, `Bidi::of` with a base level per paragraph, `text.direction` and the three menu commands (the edited paragraphs while editing), `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect `textDirection` / `lineDirections`, the CSS `direction`, and the `.fig` lines' `sourceDirectionality` in and out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-05 03:23:07 -05:00

632 lines
36 KiB
Rust

//! 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/<hash>`. 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, GridTrack, HConstraint, LayoutMode,
LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap,
StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextStyle, VConstraint,
VariableType, VariableValue, VectorNetwork, WindingRule,
};
use serde_json::Value;
use sha1::Digest;
use super::kiwi::{Kv, Schema, encode};
use super::network;
use crate::FormatError;
mod assets;
mod common;
mod component;
mod layer;
mod paint;
mod prototype;
mod shape;
/// Most layers written, and deepest nesting followed (the import's limits).
const MAX_NODES: usize = crate::rest::MAX_NODES;
const MAX_DEPTH: usize = 256;
/// 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 SPACE_EVENLY_CSS=6
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 SYMBOL_ID=7 TEXT_DATA=9 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 ComponentPropPreferredValues string[] stringValues=1
message ComponentPropDef GUID id=1, string name=2, ComponentPropValue initialValue=3, string sortPosition=4, GUID parentPropDefId=5, ComponentPropType type=6, ComponentPropPreferredValues preferredValues=8, VariableData varValue=9
message ComponentPropAssignment GUID defID=1, ComponentPropValue value=2, VariableData varValue=3
enum ComponentPropNodeField VISIBLE=0 TEXT_DATA=1 OVERRIDDEN_SYMBOL_ID=2
message ComponentPropRef GUID defID=2, ComponentPropNodeField componentPropNodeField=4
message VariantPropSpec GUID propDefId=1, string value=2
message StateGroupPropertyValueOrder string property=1, string[] values=2
message PropRefValue GUID defId=1
message SymbolId GUID guid=1
message VariableAnyValue bool boolValue=1, string textValue=2, float floatValue=3, VariableID alias=4, Color colorValue=5, SymbolId symbolIdValue=8, TextData textDataValue=10, 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
enum GridTrackSizingType FLEX=0 FIXED=1 HUG=2 UNKNOWN=3
message GridTrackSizingFunction GridTrackSizingType type=1, float value=2
message GridTrackSize GridTrackSizingFunction minSizing=1, GridTrackSizingFunction maxSizing=2
message GUIDGridTrackSizeMapEntry GUID id=1, GridTrackSize trackSize=2
message GUIDGridTrackSizeMap GUIDGridTrackSizeMapEntry[] 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, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, 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, GUIDGridTrackSizeMap gridColumnsSizing=474, GUIDGridTrackSizeMap gridRowsSizing=475, 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, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, 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<u8>, Vec<String>), 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.
pub(super) fn canvas(schema: &[u8], message: &[u8]) -> Result<Vec<u8>, 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<Vec<u8>>)],
thumbnail: Option<&[u8]>,
) -> Result<Vec<u8>, 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<'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)
}
/// A track's size as the format's enum name and value, when both sizing functions agree (the
/// importer keeps only those). `None`: the format has no name for it, so an equal track stands in.
fn grid_track_size(track: &GridTrack) -> Option<(&'static str, f64)> {
let value = track.value;
let fits = value.is_finite() && (0.0..=crate::fig::map::MAX_GRID_TRACK_VALUE).contains(&value);
match track.sizing {
Sizing::Fixed if fits => Some(("FIXED", value)),
Sizing::Fill if fits && value > 0.0 => Some(("FLEX", value)),
Sizing::Hug => Some(("HUG", 0.0)),
_ => None,
}
}
/// A grid axis's track sizes, keyed by track id like the order map. A track the document doesn't
/// set is left out, and one the format can't hold keeps its id (the import equalises it) and sets
/// `dropped`.
fn grid_sizing(tracks: &[GridTrack], ids: &[u32], dropped: &mut bool) -> Option<Kv<'static>> {
let mut entries = Vec::new();
for (track, id) in tracks.iter().zip(ids) {
match grid_track_size(track) {
Some((kind, value)) => {
let size = || o(vec![("type", e(kind)), ("value", fl(value))]);
entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))]));
}
None => *dropped = true,
}
}
(!entries.is_empty()).then(|| o(vec![("entries", arr(entries))]))
}
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<u8> {
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<Color>,
}
struct Exporter<'d> {
doc: &'d Document,
/// Layer → local id of its GUID (session [`LAYERS`]).
ids: BTreeMap<NodeId, u32>,
nodes: Vec<Kv<'static>>,
/// Blob bytes → index (identical blobs are written once).
blobs: BTreeMap<Vec<u8>, u32>,
blob_bytes: u64,
/// Image key → its hash (lowercase hex) once written, `None` when its bytes aren't there.
images: BTreeMap<String, Option<String>>,
/// `images/<hash>` members.
files: Vec<(String, Arc<Vec<u8>>)>,
/// Component property (owner, name, type) → local id of its definition's GUID (session [`AUX`]).
/// The owner is the component, or the set for a variant (variant properties included, type
/// `VARIANT`).
defs: BTreeMap<(NodeId, String, &'static str), u32>,
/// A variant's own definition of each of its set's properties (variant, name, type) → local
/// id (session [`AUX`]): what its layers and its instances refer to.
child_defs: BTreeMap<(NodeId, String, &'static str), u32>,
/// Grid tracks of written grid frames: (columns, rows) local ids (session [`AUX`]).
tracks: BTreeMap<NodeId, (Vec<u32>, Vec<u32>)>,
aux: u32,
overlays: BTreeMap<NodeId, Overlay>,
trigger_size: [f64; 2],
/// Flow starting points: layer → (order, name).
starts: BTreeMap<NodeId, (usize, String)>,
/// 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<String>,
}
/// 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<Kv<'static>>,
}
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(),
child_defs: BTreeMap::new(),
tracks: BTreeMap::new(),
aux: 0,
overlays: BTreeMap::new(),
trigger_size: [0.0, 0.0],
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<Kv<'static>> {
self.ids.get(&id).map(|l| guid(LAYERS, *l))
}
/// The component set component `id` is a variant of.
fn set_of(&self, id: NodeId) -> Option<NodeId> {
let parent = self.doc.node(id)?.parent.and_then(|p| self.doc.node(p))?;
matches!(parent.kind, NodeKind::ComponentSet { .. }).then_some(parent.id)
}
/// The local id a layer of component `comp` (or an instance of it) uses for its property
/// `name` of type `ty`: a variant's own definition, else the component's.
fn def_for(&self, comp: NodeId, name: &str, ty: &'static str) -> Option<u32> {
let key = (comp, name.to_string(), ty);
let defs = if self.set_of(comp).is_some() { &self.child_defs } else { &self.defs };
defs.get(&key).copied()
}
/// The main component a layer belongs to (itself when it is one). A layer inside an instance
/// mirrors its main's layer (`source`, through nested instances): that layer's component, so
/// the instance's layers refer to what the main's do. An instance placed in a component is the
/// component's own layer (its `source` is its main, which it doesn't belong to).
fn component_of(&self, n: &Node) -> Option<NodeId> {
let in_instance = |x: &Node| {
let mut p = x.parent.and_then(|p| self.doc.node(p));
for _ in 0..MAX_DEPTH {
match p {
Some(a) if matches!(a.kind, NodeKind::Instance { .. }) => return true,
Some(a) => p = a.parent.and_then(|q| self.doc.node(q)),
None => return false,
}
}
false
};
let mut n = n;
for _ in 0..MAX_DEPTH {
match n.source.and_then(|s| self.doc.node(s)) {
Some(src) if in_instance(n) => n = src,
_ => break,
}
}
let mut cur = Some(n);
for _ in 0..MAX_DEPTH {
let c = cur?;
if matches!(c.kind, NodeKind::Component { .. }) {
return Some(c.id);
}
cur = c.parent.and_then(|p| self.doc.node(p));
}
None
}
/// 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<u8>)> = 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())),
])
}
}
/// 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");
/// An image's name in the file: the SHA-1 of its bytes (lowercase hex). Paints refer to images by
/// these 20 bytes, and readers reject other hash lengths (the document's own SHA-256 key is not
/// used here).
pub(super) fn sha1_hex(bytes: &[u8]) -> String {
sha1::Sha1::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// A style name as readers look the face up: the standard name ([`super::style_name`]) with the
/// compound weights spelt the way most font families name their faces ("SemiBold", "ExtraBold",
/// "ExtraLight Italic"), except Inter, whose faces are named with a space ("Semi Bold"). A name
/// the family doesn't have reads as a missing font, and its text isn't drawn.
pub(super) fn face_style(family: &str, style: &str) -> String {
let standard = super::style_name(style);
if family == "Inter" || family.starts_with("Inter ") {
return standard;
}
["Extra", "Ultra", "Semi", "Demi"].iter().fold(standard, |acc, p| acc.replace(&format!("{p} "), p))
}