M6: .fig export writes a page's prototype background (#2326) (#2328)

* M6: .fig export writes a page's prototype background (#2326)

The export writes a page's prototype background as prototypeBackgroundColor (field 141, a plain colour) when it differs from the default, as Figma does for a page whose background was set. The device-settings warning now fires only for the device, rotation, colour, custom size and responsive settings. Measured on a synthetic file saved from Figma's web app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 measured and wrote at medium

* M6: the .fig export writes a prototype background opaque and warns for its opacity, and its tests read the written record (#2326)

The colour is written with alpha 1, as the measured record has it and as the import reads it, and a background that isn't opaque warns that its opacity was left out. The tests now read the written record, and each device setting alone is checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Fable 5.2 reviewed; Sonnet 5.5 wrote at medium

* M6: the .fig export writes every page's prototype background, so Figma shows the same colour (#2326)

A page at FigCraft's default background used to write no field, so Figma showed its own default instead. Every page now writes its colour, opaque. The test for a default page reads the written record and the import.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-13 09:34:55 +01:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent 7b34dbab00
commit fb9df220b1
5 changed files with 129 additions and 5 deletions
+2
View File
@@ -38,6 +38,8 @@ mod paste_style_tests;
#[cfg(test)]
mod prop_value_tests;
#[cfg(test)]
mod proto_background_tests;
#[cfg(test)]
mod scale_factor_tests;
#[cfg(test)]
mod slot_tests;
+1 -1
View File
@@ -190,7 +190,7 @@ 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, float listSpacing=264, LeadingTrim leadingTrim=322, bool hangingPunctuation=337, bool hangingList=339, 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
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, float listSpacing=264, LeadingTrim leadingTrim=322, bool hangingPunctuation=337, bool hangingList=339, 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, Color prototypeBackgroundColor=141
struct Blob byte[] bytes
message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6
";
+9 -1
View File
@@ -26,7 +26,15 @@ impl<'d> Exporter<'d> {
if !n.guides.is_empty() {
self.warn("ruler guides were left out");
}
if *prototype != Default::default() {
let defaults = figcraft_doc::proto::PrototypeSettings::default();
// Every page writes its colour, so Figma shows the same one (a page without the field shows Figma's own default).
// The colour is written opaque: the record measured has alpha 1, and the import reads the colour as opaque and
// an alpha of 0 as no background.
f.push(("prototypeBackgroundColor", color(prototype.background.with_alpha(1.0))));
if prototype.background.a < 1.0 {
self.warn("a prototype background's opacity was left out");
}
if (figcraft_doc::proto::PrototypeSettings { background: defaults.background, ..prototype.clone() }) != defaults {
self.warn("prototype device settings were left out");
}
self.modes(n, &mut f);
@@ -0,0 +1,113 @@
//! A page's prototype background in `.fig` exports.
use figcraft_doc::color::Color;
use figcraft_doc::proto::{DeviceRotation, PrototypeSettings};
use figcraft_doc::{Document, NodeKind};
use super::kiwi::Kv;
use super::{Archive, decode_canvas, export, import};
fn blue() -> Color {
Color::from_rgb8_const(0x33, 0x66, 0x99)
}
/// The background of the first page, and its device, after exporting and importing `doc`.
fn back(doc: &Document) -> (Color, String, Vec<String>) {
let (bytes, warnings) = export(doc, None).unwrap();
let (imported, _) = import(&bytes, "x").unwrap();
let page = imported.first_page().and_then(|p| imported.node(p)).unwrap();
let NodeKind::Page { prototype, .. } = &page.kind else { panic!("not a page") };
(prototype.background, prototype.device.clone(), warnings)
}
/// The `prototypeBackgroundColor` (r, g, b, a) in the record the export writes for the page of `doc`.
fn written(doc: &Document) -> Option<[f32; 4]> {
let (bytes, _) = export(doc, None).unwrap();
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let page = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("type").and_then(Kv::str) == Some("CANVAS")).unwrap();
page.get("prototypeBackgroundColor").map(|c| ["r", "g", "b", "a"].map(|k| c.get(k).and_then(Kv::f64).unwrap() as f32))
}
fn with_prototype(set: impl FnOnce(&mut figcraft_doc::proto::PrototypeSettings)) -> Document {
let mut doc = Document::new("t");
let page = doc.first_page().unwrap();
if let Some(NodeKind::Page { prototype, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
set(prototype);
}
doc
}
#[test]
fn a_pages_prototype_background_survives_a_fig_export() {
let doc = with_prototype(|p| p.background = blue());
assert_eq!(written(&doc), Some([blue().r, blue().g, blue().b, 1.0]));
let (background, _, warnings) = back(&doc);
assert_eq!(background, blue());
assert!(warnings.iter().all(|w| !w.contains("prototype")), "{warnings:?}");
}
#[test]
fn a_page_with_a_device_keeps_its_background_and_warns_for_the_device() {
let doc = with_prototype(|p| {
p.background = blue();
p.device = "iPhone 17".into();
});
let (background, _, warnings) = back(&doc);
assert_eq!(background.to_hex(), blue().to_hex());
assert!(warnings.iter().any(|w| w.contains("prototype device settings were left out")), "{warnings:?}");
}
#[test]
fn a_default_page_writes_its_prototype_background_so_figma_shows_the_same_colour() {
let doc = with_prototype(|_| {});
let default = PrototypeSettings::default().background;
assert_eq!(default.to_hex(), "1E1E1E");
assert_eq!(written(&doc), Some([default.r, default.g, default.b, 1.0]));
let (background, _, warnings) = back(&doc);
assert_eq!(background, default);
assert!(warnings.iter().all(|w| !w.contains("prototype")), "{warnings:?}");
}
#[test]
fn the_schema_numbers_the_prototype_background() {
let s = super::kiwi::Schema::parse(super::export::SCHEMA).unwrap();
let def = |n: &str| s.defs.iter().find(|d| d.name == n).unwrap();
let id = |f: &str| def("NodeChange").fields.iter().find(|x| x.name == f).unwrap().value;
assert_eq!(id("prototypeBackgroundColor"), 141);
}
#[test]
fn each_device_setting_alone_warns_and_keeps_the_default_background() {
let settings: [fn(&mut PrototypeSettings); 5] = [
|p| p.device = "iPhone 17".into(),
|p| p.rotation = DeviceRotation::Landscape,
|p| p.color = "White".into(),
|p| p.custom_size = [320.0, 480.0],
|p| p.responsive = true,
];
for (i, set) in settings.into_iter().enumerate() {
let doc = with_prototype(set);
let default = PrototypeSettings::default().background;
assert_eq!(written(&doc), Some([default.r, default.g, default.b, 1.0]), "setting {i}");
let (_, _, warnings) = back(&doc);
assert!(warnings.iter().any(|w| w == "prototype device settings were left out"), "setting {i}: {warnings:?}");
}
}
#[test]
fn a_prototype_background_is_written_opaque() {
// The import reads an alpha of 0 as no background, and any other alpha as 1.
for alpha in [0.0, 0.5] {
let doc = with_prototype(|p| p.background = blue().with_alpha(alpha));
assert_eq!(written(&doc), Some([blue().r, blue().g, blue().b, 1.0]), "alpha {alpha}");
let (background, _, warnings) = back(&doc);
assert_eq!(background, blue(), "alpha {alpha}");
assert!(warnings.iter().any(|w| w == "a prototype background's opacity was left out"), "alpha {alpha}: {warnings:?}");
}
// The default colour is written opaque too.
let doc = with_prototype(|p| p.background = p.background.with_alpha(0.5));
let default = PrototypeSettings::default().background;
assert_eq!(written(&doc), Some([default.r, default.g, default.b, 1.0]));
}
File diff suppressed because one or more lines are too long