mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
Merge main into #1397
# Conflicts: # ROADMAP.md # contributors/people.toml # crates/format/src/fig/map/layer.rs # docs/file-format-parity.md # docs/gaps.md
This commit is contained in:
+33
-12
@@ -47,6 +47,7 @@ mod text_style_tests;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::Document;
|
||||
|
||||
@@ -59,7 +60,7 @@ use kiwi::{Kv, Schema};
|
||||
const MAX_CANVAS: u64 = 256 * 1024 * 1024;
|
||||
const MAX_CHUNK: u64 = 512 * 1024 * 1024;
|
||||
/// Largest image member read.
|
||||
const MAX_IMAGE: u64 = 256 * 1024 * 1024;
|
||||
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
|
||||
/// Most chunks read from `canvas.fig`.
|
||||
const MAX_CHUNKS: usize = 16;
|
||||
/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its
|
||||
@@ -109,9 +110,9 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
|
||||
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
|
||||
let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
|
||||
// The message goes before the layers are built, so the images it carries are copied out first.
|
||||
let embedded: BTreeMap<String, Vec<u8>> = embedded_images(&msg).into_iter().map(|(hash, bytes)| (hash, bytes.to_vec())).collect();
|
||||
let embedded = embedded_copies(&msg);
|
||||
drop(msg);
|
||||
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned());
|
||||
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
|
||||
let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
|
||||
// Both stages stop at the same cap and say so in the same words: one note (#981).
|
||||
for w in more.into_iter().filter_map(map::adapt_warning) {
|
||||
@@ -151,17 +152,17 @@ fn corrupt(msg: impl Into<String>) -> FormatError {
|
||||
/// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's
|
||||
/// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed
|
||||
/// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The
|
||||
/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. The
|
||||
/// bytes stay borrowed from the message.
|
||||
fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap<String, &'m [u8]> {
|
||||
/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`].
|
||||
/// Returns the index of each hash's blob.
|
||||
fn embedded_images(msg: &Kv) -> BTreeMap<String, usize> {
|
||||
let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
|
||||
let mut found = BTreeMap::new();
|
||||
let mut pending = vec![msg];
|
||||
while let Some(value) = pending.pop() {
|
||||
match value {
|
||||
Kv::Object(fields) => {
|
||||
if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) {
|
||||
found.entry(hash).or_insert(bytes);
|
||||
if let Some((hash, index)) = value.get("image").and_then(|image| embedded_image(image, blobs)) {
|
||||
found.entry(hash).or_insert(index);
|
||||
}
|
||||
pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field));
|
||||
}
|
||||
@@ -172,15 +173,35 @@ fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap<String, &'m [u8]> {
|
||||
found
|
||||
}
|
||||
|
||||
/// The hash and bytes of an `Image` value whose `dataBlob` points at a usable blob.
|
||||
fn embedded_image<'k>(image: &Kv, blobs: &'k [Kv]) -> Option<(String, &'k [u8])> {
|
||||
/// The images that travel inside the message ([`embedded_images`]), copied out of it once per
|
||||
/// blob: every hash naming the same blob shares its copy. A file can point any number of hashes
|
||||
/// at one large blob, and a copy for each would multiply it (#1059).
|
||||
fn embedded_copies(msg: &Kv) -> BTreeMap<String, Arc<[u8]>> {
|
||||
let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
|
||||
let mut copies: BTreeMap<usize, Arc<[u8]>> = BTreeMap::new();
|
||||
let mut out = BTreeMap::new();
|
||||
for (hash, index) in embedded_images(msg) {
|
||||
let Some(bytes) = blob(blobs, index) else { continue };
|
||||
out.insert(hash, Arc::clone(copies.entry(index).or_insert_with(|| Arc::from(bytes))));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The hash and blob index of an `Image` value whose `dataBlob` points at a usable blob.
|
||||
fn embedded_image(image: &Kv, blobs: &[Kv]) -> Option<(String, usize)> {
|
||||
let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?;
|
||||
let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None };
|
||||
let bytes = blobs.get(usize::try_from(*index).ok()?)?.get("bytes").and_then(Kv::bytes)?;
|
||||
let index = usize::try_from(*index).ok()?;
|
||||
let bytes = blob(blobs, index)?;
|
||||
if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE {
|
||||
return None;
|
||||
}
|
||||
Some((hash.iter().map(|b| format!("{b:02x}")).collect(), bytes))
|
||||
Some((hash.iter().map(|b| format!("{b:02x}")).collect(), index))
|
||||
}
|
||||
|
||||
/// The bytes of blob `index`.
|
||||
fn blob<'k>(blobs: &'k [Kv], index: usize) -> Option<&'k [u8]> {
|
||||
blobs.get(index)?.get("bytes").and_then(Kv::bytes)
|
||||
}
|
||||
|
||||
/// The parts of a `.fig` archive the import reads.
|
||||
|
||||
@@ -274,19 +274,30 @@ fn variables_modes_and_bindings_round_trip() {
|
||||
#[test]
|
||||
fn variables_are_written_as_nodes_on_a_hidden_page() {
|
||||
let nodes = written(&themed());
|
||||
let hidden = named(&nodes, "Internal Only Canvas");
|
||||
assert_hidden_theme(&nodes);
|
||||
assert_variable_values(&nodes);
|
||||
assert_variable_bindings(&nodes);
|
||||
}
|
||||
|
||||
fn assert_hidden_theme(nodes: &[Value]) {
|
||||
let hidden = named(nodes, "Internal Only Canvas");
|
||||
assert_eq!((hidden["type"].as_str(), hidden["internalOnly"].as_bool(), hidden["visible"].as_bool()), (Some("CANVAS"), Some(true), Some(false)));
|
||||
let set = named(&nodes, "Theme");
|
||||
let set = named(nodes, "Theme");
|
||||
assert_eq!(set["type"], "VARIABLE_SET");
|
||||
assert_eq!(set["parentIndex"]["guid"], hidden["guid"]);
|
||||
let modes = set["variableSetModes"].as_array().unwrap();
|
||||
assert_eq!(modes.iter().map(|m| m["name"].as_str().unwrap()).collect::<Vec<_>>(), ["Light", "Dark"]);
|
||||
let bg = named(&nodes, "color/bg");
|
||||
}
|
||||
|
||||
fn assert_variable_values(nodes: &[Value]) {
|
||||
let set = named(nodes, "Theme");
|
||||
let modes = set["variableSetModes"].as_array().unwrap();
|
||||
let bg = named(nodes, "color/bg");
|
||||
assert_eq!((bg["type"].as_str(), bg["variableResolvedType"].as_str()), (Some("VARIABLE"), Some("COLOR")));
|
||||
assert_eq!(bg["variableSetID"]["guid"], set["guid"]);
|
||||
assert_eq!(bg["description"], "Behind everything");
|
||||
assert_eq!(bg["variableScopes"], json!(["FRAME_FILL", "SHAPE_FILL"]));
|
||||
assert_eq!(named(&nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"]));
|
||||
assert_eq!(named(nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"]));
|
||||
let values = bg["variableDataValues"]["entries"].as_array().unwrap();
|
||||
assert_eq!(values.len(), 2);
|
||||
assert_eq!(values[1]["modeID"], modes[1]["id"]);
|
||||
@@ -295,19 +306,25 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {
|
||||
(Some("COLOR"), Some(0.25))
|
||||
);
|
||||
// An alias names the variable's node; every value says the type it resolves to.
|
||||
let accent = &named(&nodes, "color/accent")["variableDataValues"]["entries"];
|
||||
let accent = &named(nodes, "color/accent")["variableDataValues"]["entries"];
|
||||
assert_eq!(accent[0]["variableData"]["dataType"], "ALIAS");
|
||||
assert_eq!(accent[0]["variableData"]["resolvedDataType"], "COLOR");
|
||||
assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(&nodes, "color/fg")["guid"]);
|
||||
assert_eq!(named(&nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀");
|
||||
assert_eq!(named(&nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false);
|
||||
assert_eq!(named(&nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0);
|
||||
assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(nodes, "color/fg")["guid"]);
|
||||
assert_eq!(named(nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀");
|
||||
assert_eq!(named(nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false);
|
||||
assert_eq!(named(nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0);
|
||||
}
|
||||
|
||||
fn assert_variable_bindings(nodes: &[Value]) {
|
||||
let bg = named(nodes, "color/bg");
|
||||
let set = named(nodes, "Theme");
|
||||
let modes = set["variableSetModes"].as_array().unwrap();
|
||||
// Bindings: on the paint, the gradient stop, the effect and the grid, and in the layer's map.
|
||||
let card = named(&nodes, "Card");
|
||||
let card = named(nodes, "Card");
|
||||
assert_eq!(card["fillPaints"][0]["colorVar"]["value"]["alias"]["guid"], bg["guid"]);
|
||||
assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], set["guid"]);
|
||||
assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableModeID"], modes[1]["id"]);
|
||||
assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(&nodes, "space/gap")["guid"]);
|
||||
assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(nodes, "space/gap")["guid"]);
|
||||
let fields: Vec<&str> =
|
||||
card["variableConsumptionMap"]["entries"].as_array().unwrap().iter().map(|e| e["variableField"].as_str().unwrap()).collect();
|
||||
for f in
|
||||
@@ -315,7 +332,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {
|
||||
{
|
||||
assert!(fields.contains(&f), "{f} in {fields:?}");
|
||||
}
|
||||
let swatch = named(&nodes, "Swatch");
|
||||
let swatch = named(nodes, "Swatch");
|
||||
assert!(swatch["fillPaints"][1]["stopsVar"][0].get("colorVar").is_none());
|
||||
assert_eq!(swatch["fillPaints"][1]["stopsVar"][1]["colorVar"]["value"]["alias"]["guid"], bg["guid"]);
|
||||
assert_eq!(swatch["fillPaints"][1]["stopsVar"].as_array().unwrap().len(), 2);
|
||||
@@ -324,7 +341,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {
|
||||
assert_eq!(swatch["effects"][0][key]["dataType"], "ALIAS", "{key}");
|
||||
}
|
||||
// The page sets a mode too.
|
||||
assert_eq!(named(&nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(&nodes, "Spacing")["guid"]);
|
||||
assert_eq!(named(nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(nodes, "Spacing")["guid"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::common::{layout_grid, node_field, prop_type, resolved_type, scope_name, variant_pairs};
|
||||
use super::*;
|
||||
use super::{
|
||||
AUX, Action, Affine, Bound, Color, Exporter, Fields, Kv, LAYERS, MAX_NODES, Node, NodeId, NodeKind, Overlay, OverlayPosition, Paint, StyleKind,
|
||||
TextData, VariableValue, arr, b, color, e, fl, guid, matrix, o, position, root_fields, s, u, xy,
|
||||
};
|
||||
|
||||
impl<'d> Exporter<'d> {
|
||||
/// GUIDs for every layer (pages first, then depth-first), component property definitions,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Bound, ExportSize, Fields, GAMEPAD_BUTTONS, GridAlign, GridKind, KeyMods, Kv, LetterSpacing, LineHeight, MAX_NETWORK, Node, NodeKind, Paint,
|
||||
PropDef, SCHEMA, TextCase, TextDecoration, TextStyle, VariableType, VectorNetwork, WindingRule, arr, b, color, e, face_style, fl, guid, int, o,
|
||||
s, xy,
|
||||
};
|
||||
|
||||
/// Longest kept description of such a layer (bytes of JSON).
|
||||
const MAX_STASH: usize = 1 << 20;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::common::{
|
||||
bool_data, changed, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs,
|
||||
};
|
||||
use super::*;
|
||||
use super::{AUX, BTreeMap, Exporter, Fields, Kv, MAX_NODES, Node, NodeId, NodeKind, PropDef, Value, arr, b, e, fl, guid, o, s};
|
||||
|
||||
impl<'d> Exporter<'d> {
|
||||
/// A component property definition of `owner` (a component, or a set), `index`-th in its list.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use super::common::{export_setting, layout_grid, outlined, prop_type, variant_pairs};
|
||||
use super::*;
|
||||
use super::{
|
||||
AUX, AutoLayout, BoolOp, Bound, ContentAlign, CrossAlign, Exporter, Fields, HConstraint, Kv, LAYERS, LayoutMode, MAX_DEPTH, MainAlign, MaskType,
|
||||
Node, NodeId, NodeKind, PropDef, STASH, Sizing, TextData, TextStyle, VConstraint, WindingRule, arr, b, blend_name, color, e, fl, grid_sizing,
|
||||
guid, int, matrix, o, position, root_fields, s, u, xy,
|
||||
};
|
||||
|
||||
impl<'d> Exporter<'d> {
|
||||
/// Write layer `id` (child `index` of the layer with GUID `parent_guid`) and its subtree.
|
||||
@@ -15,11 +19,11 @@ impl<'d> Exporter<'d> {
|
||||
("phase", e("CREATED")),
|
||||
("parentIndex", o(vec![("guid", parent_guid), ("position", s(&position(index)))])),
|
||||
];
|
||||
let children = if let NodeKind::Page { background, guides, prototype, .. } = &n.kind {
|
||||
let children = if let NodeKind::Page { background, 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() {
|
||||
if !n.guides.is_empty() {
|
||||
self.warn("ruler guides were left out");
|
||||
}
|
||||
if *prototype != Default::default() {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::common::one_sided;
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, Bound, EffectKind, Exporter, Fields, GradientKind, ImageScaleMode, Kv, MAX_IMAGE, Node, NodeKind, NoiseMode, Paint, PaintKind,
|
||||
StrokeAlign, StrokeCap, arr, b, blend_name, cap_name, color, e, fl, join_name, matrix, o, s, sha1_hex, xy,
|
||||
};
|
||||
|
||||
impl<'d> Exporter<'d> {
|
||||
/// A paint list: a layer's fills (`fills`) or strokes with the variables `bound` to them.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::common::key_codes;
|
||||
use super::*;
|
||||
use super::{
|
||||
Action, Direction, Easing, Exporter, Fields, Interaction, Kv, Node, NodeId, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, arr,
|
||||
b, color, e, fl, int, o, position, s,
|
||||
};
|
||||
|
||||
impl<'d> Exporter<'d> {
|
||||
/// Interactions, scrolling, flow starts and the overlay settings this layer is opened with.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use super::common::{style_fields, unsupported_decoration_look};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap,
|
||||
StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int,
|
||||
join_name, network, o, path_blob, s, u, xy,
|
||||
};
|
||||
|
||||
impl<'d> Exporter<'d> {
|
||||
/// Text: its style, box settings, and characters with their styled runs. `fills`: the layer's
|
||||
|
||||
@@ -400,7 +400,7 @@ impl<'s> Decoder<'s, '_> {
|
||||
/// An encoder for the same format: the `.fig` export writes its schema and message with it (and
|
||||
/// test fixtures are built with it). Values are checked against the schema; it never panics.
|
||||
pub mod encode {
|
||||
use super::*;
|
||||
use super::{BUILTINS, Field, Kind, KiwiError, Kv, MAX_DEPTH, Schema, Type, err};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Writer(pub Vec<u8>);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::layer::own_text;
|
||||
use super::text::char_styles;
|
||||
use super::*;
|
||||
use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Rc, Value, View, guid, json};
|
||||
|
||||
/// An override row names a layer of the main by its current GUID or by the `overrideKey` the
|
||||
/// layer had when the row was written: the key becomes the current GUID, unless the layer is
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::layer::{cap_name, own_text, svg_path, valid_size};
|
||||
use super::*;
|
||||
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, guid, json, matrix, network, num, rows};
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
/// A connector's end caps, and when it has no saved outline or network a straight line
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::component::{assigned, prop_guid};
|
||||
use super::paint::color;
|
||||
use super::variables::variable_value;
|
||||
use super::*;
|
||||
use super::{Affine, BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, rows};
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
/// The DOCUMENT with its pages.
|
||||
|
||||
@@ -2,7 +2,10 @@ use super::component::{Entry, Inst, OverrideRow, prop_ref};
|
||||
use super::connector::connector_has_saved_label;
|
||||
use super::layout::{layout_grid, tracks};
|
||||
use super::paint::{effect, export_setting, paint, paint_bound, stroke};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, guid, json, matrix, network, num, rows,
|
||||
size,
|
||||
};
|
||||
|
||||
/// How the layers of a resized container follow it when the file gives no derived layout for
|
||||
/// them: a frame's by their constraints, from its main's size to its own; a group's scale with it.
|
||||
@@ -61,6 +64,22 @@ impl Fit {
|
||||
}
|
||||
}
|
||||
|
||||
struct LayerChildren<'s, 'a> {
|
||||
id: &'s str,
|
||||
inst: Option<&'s Inst<'a>>,
|
||||
base: &'s Kv<'a>,
|
||||
child_space: Affine,
|
||||
view: &'s View<'a>,
|
||||
object: &'s mut Obj,
|
||||
ty: &'s str,
|
||||
rest_id: &'s str,
|
||||
connector_snapshot: bool,
|
||||
connector_terminal: bool,
|
||||
depth: usize,
|
||||
included: Option<&'s BTreeSet<String>>,
|
||||
slot_content: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
/// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the
|
||||
/// parent's coordinates to the enclosing frame's (groups and booleans place their children in
|
||||
@@ -136,16 +155,7 @@ impl<'a> Mapper<'a> {
|
||||
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget());
|
||||
// A supported connector snapshot has its own note below instead of the generic stand-in one.
|
||||
let stand = stand_in(own).filter(|_| !connector_snapshot);
|
||||
let ty = match own {
|
||||
"ROUNDED_RECTANGLE" => "RECTANGLE",
|
||||
"SYMBOL" => "COMPONENT",
|
||||
"FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET",
|
||||
"FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP",
|
||||
"VARIABLE" | "VARIABLE_SET" => return None,
|
||||
"CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR",
|
||||
"CONNECTOR" if connector_snapshot => "FRAME",
|
||||
t => stand.map_or(t, |s| s.0),
|
||||
};
|
||||
let ty = Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)?;
|
||||
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") {
|
||||
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
|
||||
}
|
||||
@@ -225,18 +235,63 @@ impl<'a> Mapper<'a> {
|
||||
source.insert("slotOverride".into(), json!(true));
|
||||
}
|
||||
let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY };
|
||||
let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() };
|
||||
let mut kids = self.map_layer_children(LayerChildren {
|
||||
id,
|
||||
inst,
|
||||
base,
|
||||
child_space,
|
||||
view: &view,
|
||||
object: &mut o,
|
||||
ty,
|
||||
rest_id: &rest_id,
|
||||
connector_snapshot,
|
||||
connector_terminal,
|
||||
depth,
|
||||
included,
|
||||
slot_content,
|
||||
});
|
||||
if stand.is_some_and(|s| s.0 == "FRAME") {
|
||||
if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
|
||||
kids.splice(0..0, snapshot);
|
||||
} else {
|
||||
kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
|
||||
kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
|
||||
}
|
||||
}
|
||||
if !kids.is_empty() {
|
||||
o.insert("children".into(), Value::Array(kids));
|
||||
}
|
||||
Some(Value::Object(o))
|
||||
}
|
||||
|
||||
fn map_layer_children(&mut self, context: LayerChildren<'_, 'a>) -> Vec<Value> {
|
||||
let LayerChildren {
|
||||
id,
|
||||
inst,
|
||||
base,
|
||||
child_space,
|
||||
view,
|
||||
object: o,
|
||||
ty,
|
||||
rest_id,
|
||||
connector_snapshot,
|
||||
connector_terminal,
|
||||
depth,
|
||||
included,
|
||||
slot_content,
|
||||
} = context;
|
||||
let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(view, rest_id) } else { Vec::new() };
|
||||
if connector_terminal {
|
||||
// The saved vector is flattened into the connector node when no child slot remains.
|
||||
} else if ty == "INSTANCE" {
|
||||
if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {
|
||||
if let Some((sub, sym)) = self.instance(o, view, id, inst, rest_id) {
|
||||
let main = self.nodes.get(&sym).map(|m| View::of(m));
|
||||
let fit = Fit::frame(main.as_ref().and_then(View::size), view.size());
|
||||
// An instance that records no auto layout of its own has its main's.
|
||||
let view = match main {
|
||||
Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => {
|
||||
let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };
|
||||
self.auto_layout(&mut o, &layered);
|
||||
self.auto_layout(o, &layered);
|
||||
layered
|
||||
}
|
||||
_ => view.clone(),
|
||||
@@ -252,7 +307,7 @@ impl<'a> Mapper<'a> {
|
||||
content_inst.slot_content = true;
|
||||
}
|
||||
for c in self.children(&content) {
|
||||
kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included));
|
||||
kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(view), None, depth + 1, included));
|
||||
}
|
||||
} else {
|
||||
// Inside an instance a layer's children are its main's: they follow it from the main's
|
||||
@@ -263,21 +318,29 @@ impl<'a> Mapper<'a> {
|
||||
(Some(_), _) => Fit::frame(View::of(base).size(), view.size()),
|
||||
};
|
||||
for c in self.children(id) {
|
||||
kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included));
|
||||
kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included));
|
||||
}
|
||||
}
|
||||
if stand.is_some_and(|s| s.0 == "FRAME") {
|
||||
if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
|
||||
kids.splice(0..0, snapshot);
|
||||
} else {
|
||||
kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
|
||||
kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
|
||||
}
|
||||
kids
|
||||
}
|
||||
|
||||
fn mapped_type<'s>(
|
||||
own: &'s str,
|
||||
view: &View<'a>,
|
||||
connector_snapshot: bool,
|
||||
connector_terminal: bool,
|
||||
stand: Option<(&'static str, &'static str)>,
|
||||
) -> Option<&'s str> {
|
||||
match own {
|
||||
"ROUNDED_RECTANGLE" => Some("RECTANGLE"),
|
||||
"SYMBOL" => Some("COMPONENT"),
|
||||
"FRAME" if view.flag("isStateGroup") == Some(true) => Some("COMPONENT_SET"),
|
||||
"FRAME" if view.flag("resizeToFit") == Some(true) => Some("GROUP"),
|
||||
"VARIABLE" | "VARIABLE_SET" => None,
|
||||
"CONNECTOR" if connector_snapshot && connector_terminal => Some("VECTOR"),
|
||||
"CONNECTOR" if connector_snapshot => Some("FRAME"),
|
||||
t => Some(stand.map_or(t, |s| s.0)),
|
||||
}
|
||||
if !kids.is_empty() {
|
||||
o.insert("children".into(), Value::Array(kids));
|
||||
}
|
||||
Some(Value::Object(o))
|
||||
}
|
||||
|
||||
/// FigJam notes and shapes keep their paints and text on generated template children,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::paint::{bound, color};
|
||||
use super::*;
|
||||
use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_num, guid, json, num};
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
pub(super) fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::layer::cap_name;
|
||||
use super::variables::alias;
|
||||
use super::*;
|
||||
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_num, copy_str, guid, json, matrix, num, rows, vec2};
|
||||
|
||||
fn filter_number(value: &Kv) -> Option<f64> {
|
||||
let number = match value {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::paint::color;
|
||||
use super::variables::variable_data;
|
||||
use super::*;
|
||||
use super::{Kv, MAX_ACTION_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, num, ref_guid};
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
pub(super) fn prototype(&mut self, o: &mut Obj, v: &View<'a>) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::paint::{paint, style_link};
|
||||
use super::*;
|
||||
use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num};
|
||||
|
||||
/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through.
|
||||
fn text_num(v: &View, key: &str) -> Option<f64> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::paint::color;
|
||||
use super::*;
|
||||
use super::{Kv, MAX_EXPR_DEPTH, Mapper, Obj, Value, View, guid, json, num, ref_guid};
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
pub(super) fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) {
|
||||
|
||||
@@ -983,6 +983,11 @@ fn assert_external_namespace(doc: &Document) {
|
||||
let copied_frame = child(doc, copied_body, 0);
|
||||
assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame");
|
||||
assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame));
|
||||
assert_external_slots(doc, frame, copied_frame, main);
|
||||
assert_external_copy_state(doc, frame, copied_frame);
|
||||
}
|
||||
|
||||
fn assert_external_slots(doc: &Document, frame: NodeId, copied_frame: NodeId, main: NodeId) {
|
||||
for index in 0..2 {
|
||||
let slot = child(doc, frame, index);
|
||||
let copied_slot = child(doc, copied_frame, index);
|
||||
@@ -999,6 +1004,9 @@ fn assert_external_namespace(doc: &Document) {
|
||||
assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0)));
|
||||
assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_external_copy_state(doc: &Document, frame: NodeId, copied_frame: NodeId) {
|
||||
let old = doc.node(child(doc, frame, 0)).unwrap();
|
||||
let current = doc.node(child(doc, copied_frame, 0)).unwrap();
|
||||
assert!(!old.visible && old.is_mask && old.opacity < 1.0);
|
||||
|
||||
@@ -630,63 +630,82 @@ fn imports_the_fixture() {
|
||||
assert!(is_fig(&bytes));
|
||||
let (doc, warnings) = import(&bytes, "fallback").unwrap();
|
||||
assert_eq!(doc.name, "Fixture");
|
||||
assert_fixture_layers(&doc);
|
||||
assert_fixture_styles(&doc);
|
||||
assert_fixture_variables(&doc);
|
||||
assert_fixture_components(&doc);
|
||||
assert_fixture_prototype(&doc, &warnings);
|
||||
}
|
||||
|
||||
fn assert_fixture_layers(doc: &Document) {
|
||||
// The internal page is skipped.
|
||||
assert_eq!(doc.pages.len(), 1);
|
||||
let page = doc.node(doc.pages[0]).unwrap();
|
||||
assert_eq!(page.name, "Page A");
|
||||
// Siblings by position: the frame comes first even though it's listed after the component.
|
||||
assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen");
|
||||
let screen = named(&doc, "Screen");
|
||||
let screen = named(doc, "Screen");
|
||||
let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
|
||||
assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]);
|
||||
// Image fill from the archive.
|
||||
let photo = named(&doc, "Photo");
|
||||
let photo = named(doc, "Photo");
|
||||
let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") };
|
||||
assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1)));
|
||||
assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0));
|
||||
// Text with a bold run over "the".
|
||||
let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") };
|
||||
let NodeKind::Text(t) = &named(doc, "Title").kind else { panic!("not text") };
|
||||
assert_eq!(t.characters, "Hi there");
|
||||
assert_eq!(t.style.font_size, 16.0);
|
||||
assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)");
|
||||
assert_eq!(t.runs.len(), 1);
|
||||
assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold"));
|
||||
// The group's children keep their place on the page (group at 100, 100).
|
||||
assert!(matches!(named(&doc, "Group").kind, NodeKind::Group));
|
||||
let tri = named(&doc, "Tri");
|
||||
assert!(matches!(named(doc, "Group").kind, NodeKind::Group));
|
||||
let tri = named(doc, "Tri");
|
||||
assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty()));
|
||||
let abs = doc.world_transform(tri.id).translation();
|
||||
assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}");
|
||||
assert!(matches!(named(&doc, "Star").kind, NodeKind::Star { count: 6, .. }));
|
||||
// The linked style supplies the paint.
|
||||
let styled = named(&doc, "Styled");
|
||||
assert!(matches!(named(doc, "Star").kind, NodeKind::Star { count: 6, .. }));
|
||||
}
|
||||
|
||||
fn assert_fixture_styles(doc: &Document) {
|
||||
let styled = named(doc, "Styled");
|
||||
assert_eq!(styled.styles.fill.as_deref(), Some("1:3"));
|
||||
assert!(doc.styles.iter().any(|s| s.name == "Brand"));
|
||||
let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") };
|
||||
assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0));
|
||||
// Variables, the fill binding and the explicit mode.
|
||||
}
|
||||
|
||||
fn assert_fixture_variables(doc: &Document) {
|
||||
let screen = named(doc, "Screen");
|
||||
assert_eq!(doc.variables.collections.len(), 1);
|
||||
assert_eq!(doc.variables.variables[0].kind, VariableType::Color);
|
||||
assert_eq!(named(&doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));
|
||||
assert_eq!(named(doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));
|
||||
assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91"));
|
||||
// The component keeps its property; the instance is expanded with the value applied.
|
||||
let button = named(&doc, "Button");
|
||||
}
|
||||
|
||||
fn assert_fixture_components(doc: &Document) {
|
||||
let button = named(doc, "Button");
|
||||
assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon")));
|
||||
let inst = named(&doc, "Button copy");
|
||||
let inst = named(doc, "Button copy");
|
||||
assert!(
|
||||
matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false)))
|
||||
);
|
||||
let icon = doc.node(inst.children[0]).unwrap();
|
||||
assert_eq!(icon.name, "Renamed icon");
|
||||
assert!(!icon.visible);
|
||||
// Prototype: the click navigates to the component; the flow starts at the screen.
|
||||
let it = &photo.interactions[0];
|
||||
}
|
||||
|
||||
fn assert_fixture_prototype(doc: &Document, warnings: &[String]) {
|
||||
let button = named(doc, "Button");
|
||||
let screen = named(doc, "Screen");
|
||||
let it = &named(doc, "Photo").interactions[0];
|
||||
assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id));
|
||||
assert_eq!(it.duration_ms, 250);
|
||||
let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") };
|
||||
assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id));
|
||||
// The sticky note comes in as a frame, with a warning.
|
||||
assert!(matches!(named(&doc, "Note").kind, NodeKind::Frame));
|
||||
assert!(matches!(named(doc, "Note").kind, NodeKind::Frame));
|
||||
assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}");
|
||||
}
|
||||
|
||||
@@ -758,6 +777,23 @@ fn a_data_blob_past_the_blobs_stays_a_placeholder() {
|
||||
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A file can point any number of image hashes at one large blob; each used to get its own copy
|
||||
/// of it (#1059).
|
||||
#[test]
|
||||
fn hashes_naming_one_blob_share_one_copy_of_it() {
|
||||
let image = |hash: u8, blob: u64| o(vec![("image", o(vec![("hash", Kv::Bytes(vec![hash; 20])), ("dataBlob", u(blob))]))]);
|
||||
let msg = o(vec![
|
||||
("blobs", Kv::Array(vec![o(vec![("bytes", Kv::Bytes(png()))]), o(vec![("bytes", Kv::Bytes(vec![7; 16]))])])),
|
||||
("nodeChanges", Kv::Array((1..=50).map(|h| image(h, 0)).chain([image(99, 1), image(98, 2)]).collect())),
|
||||
]);
|
||||
let copies = embedded_copies(&msg);
|
||||
assert_eq!(copies.len(), 51, "the hash naming no blob is left out");
|
||||
let first = &copies[&"01".repeat(20)];
|
||||
assert_eq!(**first, *png());
|
||||
assert_eq!(copies.values().filter(|c| Arc::ptr_eq(c, first)).count(), 50);
|
||||
assert_eq!(*copies[&"63".repeat(20)], [7; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_files_are_not_fig() {
|
||||
assert!(try_import(b"{\"document\": {}}", "x").is_none());
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Ruler guides in `.figcraft` files: a reopened page keeps its guides, and files saved with a
|
||||
//! page's guides written twice open again (#1476).
|
||||
|
||||
use figcraft_doc::{Document, Guide, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A guide: vertical (at an x offset) or horizontal (at a y offset).
|
||||
fn guide(vertical: bool, offset: f64) -> Guide {
|
||||
Guide { vertical, offset }
|
||||
}
|
||||
|
||||
/// Page and frame guides survive saving, reopening, saving the reopened file and opening it again.
|
||||
#[test]
|
||||
fn page_and_frame_guides_survive_save_and_reopen() {
|
||||
let mut d = Document::new("ruler");
|
||||
let page = d.first_page().unwrap();
|
||||
let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
d.node_mut(page).unwrap().guides_mut().extend([guide(true, 120.0), guide(false, -30.5)]);
|
||||
d.node_mut(frame).unwrap().guides_mut().push(guide(true, 12.0));
|
||||
let (once, issues) = load_bytes(&save_bytes(&d, None).unwrap()).unwrap();
|
||||
assert!(issues.is_empty(), "{issues:?}");
|
||||
assert_eq!(once.node(page).unwrap().guides(), d.node(page).unwrap().guides());
|
||||
assert_eq!(once.node(frame).unwrap().guides(), d.node(frame).unwrap().guides());
|
||||
// Saving the reopened file and opening it again (where the file stopped opening).
|
||||
let text = serde_json::to_string(&to_json(&once).unwrap()).unwrap();
|
||||
assert_eq!(text.matches("\"guides\"").count(), 2, "one list on the page, one on the frame: {text}");
|
||||
let (twice, _) = load_bytes(&save_bytes(&once, None).unwrap()).unwrap();
|
||||
assert_eq!(twice.node(page).unwrap().guides(), d.node(page).unwrap().guides());
|
||||
}
|
||||
|
||||
/// A `document.json` with a page's `guides` key twice (as saving a reopened page wrote it) opens
|
||||
/// with both lists.
|
||||
#[test]
|
||||
fn a_file_with_a_page_guides_list_written_twice_opens() {
|
||||
let mut d = Document::new("twice");
|
||||
let page = d.first_page().unwrap();
|
||||
d.node_mut(page).unwrap().guides_mut().push(guide(false, 7.0));
|
||||
let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap();
|
||||
// What saving a reopened page wrote: the guides added since, then the ones the file had.
|
||||
let broken = text.replacen("\"guides\":[", "\"guides\":[{\"vertical\":true,\"offset\":5.0}],\"guides\":[", 1);
|
||||
assert_eq!(broken.matches("\"guides\"").count(), 2);
|
||||
let (back, _) = from_json(&broken).unwrap();
|
||||
assert_eq!(back.node(page).unwrap().guides(), &[guide(true, 5.0), guide(false, 7.0)]);
|
||||
}
|
||||
@@ -112,3 +112,51 @@ fn image_members_with_path_like_names_are_dropped_and_not_saved_back() {
|
||||
assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::<Vec<_>>());
|
||||
let _ = hash;
|
||||
}
|
||||
|
||||
/// An unresolved `imageRef` from a hostile REST file never becomes the image's own key: the
|
||||
/// save writer names archive members after keys, so `../../evil` would inject an
|
||||
/// `images/../../evil` member into the next save. Such refs store the shared placeholder
|
||||
/// under its own key instead.
|
||||
#[test]
|
||||
fn a_path_like_rest_image_ref_never_names_an_archive_member() {
|
||||
let rect = json!({"id": "X", "name": "X", "type": "RECTANGLE", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 10, "y": 10},
|
||||
"fills": [{"type": "IMAGE", "imageRef": "../../evil", "scaleMode": "FIT"}]});
|
||||
let file = json!({"name": "Hostile", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT",
|
||||
"children": [{"id": "0:Page", "name": "Page", "type": "CANVAS", "backgroundColor": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}, "children": [rect]}]}});
|
||||
let (doc, _warnings) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
|
||||
assert!(!doc.images.contains_key("../../evil"), "the raw imageRef must not name an image: {:?}", doc.images.keys().collect::<Vec<_>>());
|
||||
assert!(doc.images.contains_key("rest-image-missing"), "the shared placeholder stores it instead: {:?}", doc.images.keys().collect::<Vec<_>>());
|
||||
let bytes = save_bytes(&doc, None).unwrap();
|
||||
let z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::<Vec<_>>());
|
||||
assert!(
|
||||
z.file_names().any(|n| n == "images/rest-image-missing"),
|
||||
"the placeholder is saved under its own name: {:?}",
|
||||
z.file_names().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// A key with `-` or `_` is a plain archive member name too: the REST importer's placeholder
|
||||
/// (`rest-image-missing`) comes back after a save and load instead of being dropped as damaged.
|
||||
#[test]
|
||||
fn an_image_keyed_with_dashes_comes_back_after_a_save() {
|
||||
let mut d = Document::new("t");
|
||||
d.images.insert("rest-image-missing".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(png()), width: 2, height: 2 });
|
||||
let loaded = load_bytes_with_versions(&save_bytes(&d, None).unwrap()).unwrap();
|
||||
assert!(loaded.doc.images.contains_key("rest-image-missing"), "{:?}", loaded.issues);
|
||||
}
|
||||
|
||||
/// An unresolved `imageRef` shaped like a content hash would claim the placeholder's bytes hash
|
||||
/// to it, which they don't: a live session would refuse that image. It shares the placeholder
|
||||
/// instead.
|
||||
#[test]
|
||||
fn a_hash_shaped_rest_image_ref_never_names_its_placeholder() {
|
||||
for image_ref in ["ab".repeat(32), "A".repeat(64)] {
|
||||
let rect = json!({"id": "X", "name": "X", "type": "RECTANGLE", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 10, "y": 10},
|
||||
"fills": [{"type": "IMAGE", "imageRef": image_ref, "scaleMode": "FIT"}]});
|
||||
let file = json!({"name": "F", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT",
|
||||
"children": [{"id": "0:Page", "name": "Page", "type": "CANVAS", "backgroundColor": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}, "children": [rect]}]}});
|
||||
let (doc, _) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
|
||||
assert_eq!(doc.images.keys().collect::<Vec<_>>(), ["rest-image-missing"], "{image_ref}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,9 +720,9 @@ fn read_images<R: Read + Seek>(z: &mut zip::ZipArchive<R>, own: Option<&BTreeSet
|
||||
hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h)));
|
||||
let mut images = BTreeMap::new();
|
||||
let mut unread = Vec::new();
|
||||
// Member names that aren't plain hashes (`../x`, `a/b`) are never read, so never written back.
|
||||
// Member names that can't be real image keys (`../x`, `a/b`) are never read, so never written back.
|
||||
hashes.retain(|h| {
|
||||
let ok = library::is_valid_hash(h);
|
||||
let ok = ImageData::is_valid_key(h);
|
||||
if !ok {
|
||||
unread.push((h.clone(), FormatError::Corrupt("its name isn't a valid image hash".to_string())));
|
||||
}
|
||||
@@ -977,6 +977,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), Form
|
||||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod guides_tests;
|
||||
#[cfg(test)]
|
||||
mod image_budget_tests;
|
||||
#[cfg(test)]
|
||||
mod image_tests;
|
||||
|
||||
@@ -38,18 +38,13 @@ pub fn is_valid_library_id(id: &str) -> bool {
|
||||
(1..=MAX_ID).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
|
||||
/// Image hashes become archive member names: only plain hex-like names are looked up.
|
||||
pub(crate) fn is_valid_hash(h: &str) -> bool {
|
||||
(1..=128).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// Write the published library's entries. `written` are the images already in `images/`.
|
||||
pub(crate) fn write<W: Write + Seek>(z: &mut zip::ZipWriter<W>, lib: &PublishedLibrary, written: &BTreeSet<&String>) -> Result<(), FormatError> {
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
put(z, MANIFEST, json_options(), &serde_json::to_vec(&lib.manifest).map_err(io_err)?)?;
|
||||
put_json(z, SNAPSHOT, &lib.doc)?;
|
||||
for (hash, img) in &lib.doc.images {
|
||||
if !written.contains(hash) && is_valid_hash(hash) {
|
||||
if !written.contains(hash) && ImageData::is_valid_key(hash) {
|
||||
put(z, &format!("{IMAGES}{hash}"), stored, &img.bytes)?;
|
||||
}
|
||||
}
|
||||
@@ -75,7 +70,7 @@ pub(crate) fn read<R: Read + Seek>(
|
||||
let mut budget = budget.saturating_sub(text.len() as u64).min(MAX_IMAGE_BYTES);
|
||||
let (mut doc, _) = from_json(&text)?;
|
||||
for hash in used_images(&doc) {
|
||||
if !is_valid_hash(&hash) {
|
||||
if !ImageData::is_valid_key(&hash) {
|
||||
continue;
|
||||
}
|
||||
if let Some(img) = own.get(&hash) {
|
||||
@@ -90,9 +85,13 @@ pub(crate) fn read<R: Read + Seek>(
|
||||
}
|
||||
// Keep what the snapshot really holds; clip what people see.
|
||||
manifest.components.retain(|a| doc.node(a.id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })));
|
||||
manifest.styles.retain(|s| doc.styles.iter().any(|x| x.id == s.id));
|
||||
manifest.collections.retain(|c| doc.variables.collections.iter().any(|x| x.id == c.id));
|
||||
manifest.variables.retain(|v| doc.variables.variables.iter().any(|x| x.id == v.id && x.collection == v.collection));
|
||||
// Membership by set lookups: a long manifest against a large snapshot stays linear (#1078).
|
||||
let styles: BTreeSet<&str> = doc.styles.iter().map(|x| x.id.as_str()).collect();
|
||||
let collections: BTreeSet<&str> = doc.variables.collections.iter().map(|x| x.id.as_str()).collect();
|
||||
let variables: BTreeSet<(&str, &str)> = doc.variables.variables.iter().map(|x| (x.id.as_str(), x.collection.as_str())).collect();
|
||||
manifest.styles.retain(|s| styles.contains(s.id.as_str()));
|
||||
manifest.collections.retain(|c| collections.contains(c.id.as_str()));
|
||||
manifest.variables.retain(|v| variables.contains(&(v.id.as_str(), v.collection.as_str())));
|
||||
manifest.name = manifest.name.chars().take(MAX_NAME).collect();
|
||||
manifest.description = manifest.description.chars().take(MAX_DESCRIPTION).collect();
|
||||
Ok(Some(PublishedLibrary { manifest, doc: Arc::new(doc) }))
|
||||
|
||||
@@ -947,7 +947,10 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
|
||||
/// The document image key for an `imageRef`: its bytes when the resolver has them, else a
|
||||
/// grey placeholder stored under the `imageRef` itself.
|
||||
/// grey placeholder stored under the `imageRef` itself when that name can legitimately be an
|
||||
/// archive member (`is_valid_key`) and claims no content hash (`claims_content_hash`: the
|
||||
/// placeholder's bytes don't hash to it); any other name shares the `rest-image-missing`
|
||||
/// placeholder.
|
||||
pub(crate) fn image_key(&mut self, image_ref: &str) -> String {
|
||||
if let Some(k) = self.image_keys.get(image_ref) {
|
||||
return k.clone();
|
||||
@@ -957,7 +960,13 @@ impl<'a> Ctx<'a> {
|
||||
Some((hash, _, _)) => hash,
|
||||
None => {
|
||||
self.missing_images += 1;
|
||||
let key = if image_ref.is_empty() { MISSING_IMAGE } else { clip_str(image_ref) }.to_string();
|
||||
// An `imageRef` arrives from an untrusted file, and keys become archive member
|
||||
// names on save: a name that can't legitimately be one must not reach the map,
|
||||
// nor one claiming a content hash the placeholder's bytes don't have.
|
||||
let candidate = if image_ref.is_empty() { MISSING_IMAGE } else { clip_str(image_ref) };
|
||||
let named = ImageData::is_valid_key(candidate) && !ImageData::claims_content_hash(candidate);
|
||||
let key = if named { candidate } else { MISSING_IMAGE };
|
||||
let key = key.to_string();
|
||||
if !self.doc.images.contains_key(&key)
|
||||
&& let Some(png) = placeholder_png()
|
||||
{
|
||||
@@ -1018,7 +1027,7 @@ impl<'a> Ctx<'a> {
|
||||
let n = self.missing_images;
|
||||
let (s, verb) = if n == 1 { ("", "shows") } else { ("s", "show") };
|
||||
self.warn(format!(
|
||||
"{n} image{s} without image data {verb} a grey placeholder named by its imageRef (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
|
||||
"{n} image{s} without image data {verb} a grey placeholder, named by its imageRef when that can legitimately name a file and by the shared name otherwise (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
|
||||
));
|
||||
}
|
||||
let unused = self.style_meta.keys().filter(|k| !self.doc.styles.iter().any(|s| s.id == **k)).count();
|
||||
|
||||
@@ -209,8 +209,14 @@ fn embedded_variables_match_separate_ones() {
|
||||
#[test]
|
||||
fn interactions_triggers_actions_and_transitions() {
|
||||
let (doc, warnings) = import_with(&file_json(), Some(&variables_json()), &no_images).unwrap();
|
||||
let id = |n: &str| named(&doc, n).id;
|
||||
let button = named(&doc, "Button");
|
||||
assert_interactions(&doc, &warnings);
|
||||
assert_legacy_transition(&doc);
|
||||
assert_interactions_without_variables();
|
||||
}
|
||||
|
||||
fn assert_interactions(doc: &Document, warnings: &[String]) {
|
||||
let id = |n: &str| named(doc, n).id;
|
||||
let button = named(doc, "Button");
|
||||
let its = &button.interactions;
|
||||
assert_eq!(its.len(), 8, "{its:#?}");
|
||||
|
||||
@@ -239,18 +245,22 @@ fn interactions_triggers_actions_and_transitions() {
|
||||
assert_eq!(its[7].trigger, Trigger::MouseUp);
|
||||
assert_eq!(its[7].actions, [Action::ScrollTo { dest: id("Home"), offset: [0.0, 0.0] }], "unresolved destinations are dropped");
|
||||
|
||||
// The legacy single transition.
|
||||
let details = named(&doc, "Details");
|
||||
assert!(!has(warnings, "modifier keys"), "{warnings:?}");
|
||||
for w in ["video triggers", "without a destination"] {
|
||||
assert!(has(warnings, w), "{w}: {warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_legacy_transition(doc: &Document) {
|
||||
let id = |n: &str| named(doc, n).id;
|
||||
let details = named(doc, "Details");
|
||||
assert_eq!(details.interactions.len(), 1);
|
||||
let legacy = &details.interactions[0];
|
||||
assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home"), preserve_scroll: false }][..]));
|
||||
assert_eq!((legacy.transition, legacy.duration_ms, legacy.easing), (Transition::Dissolve, 200, Easing::EaseIn));
|
||||
}
|
||||
|
||||
assert!(!has(&warnings, "modifier keys"), "{warnings:?}");
|
||||
for w in ["video triggers", "without a destination"] {
|
||||
assert!(has(&warnings, w), "{w}: {warnings:?}");
|
||||
}
|
||||
// Without variables, set-variable actions have nothing to set.
|
||||
fn assert_interactions_without_variables() {
|
||||
let (doc, warnings) = import(&file_json(), &no_images).unwrap();
|
||||
assert_eq!(named(&doc, "Button").interactions.len(), 6);
|
||||
assert!(has(&warnings, "set-variable actions") && has(&warnings, "set-variable-mode actions"), "{warnings:?}");
|
||||
|
||||
+34
-12
@@ -56,17 +56,22 @@ pub const NEWEST_TESTED_VERSION: u64 = 139;
|
||||
pub const TOO_OLD: &str = "saved by a Sketch older than 48; open it in a newer Sketch and save it again";
|
||||
/// Largest JSON member read (a real page of a few thousand layers takes 33 MB).
|
||||
const MAX_JSON_MEMBER: u64 = 512 * 1024 * 1024;
|
||||
/// Most bytes JSON members may unpack to, per compressed byte (real files pack 5–20 times; a
|
||||
/// zero-filled array a thousand times), with a floor for small members.
|
||||
const MAX_JSON_RATIO: u64 = 100;
|
||||
const MIN_JSON_ALLOWANCE: u64 = 16 * 1024 * 1024;
|
||||
/// Most bytes a member may unpack to, per compressed byte (real JSON packs 5–20 times and images
|
||||
/// barely; a zero-filled member a thousand times), with a floor for small members.
|
||||
const MAX_RATIO: u64 = 100;
|
||||
const MIN_ALLOWANCE: u64 = 16 * 1024 * 1024;
|
||||
/// Most JSON read from one file in all.
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024;
|
||||
/// Largest image member read.
|
||||
const MAX_IMAGE: u64 = 256 * 1024 * 1024;
|
||||
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
|
||||
/// Most image bytes read from one file in all.
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
const MAX_IMAGE_TOTAL: u64 = 1024 * 1024 * 1024;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const MAX_IMAGE_TOTAL: u64 = 256 * 1024 * 1024;
|
||||
/// Most pages read from one file.
|
||||
const MAX_PAGES: usize = 1000;
|
||||
|
||||
@@ -182,6 +187,8 @@ pub(crate) struct Archive<'b> {
|
||||
/// Problems met while reading (pages skipped).
|
||||
pub warnings: Vec<String>,
|
||||
zip: zip::ZipArchive<std::io::Cursor<&'b [u8]>>,
|
||||
/// Image bytes read so far.
|
||||
image_total: std::cell::Cell<u64>,
|
||||
}
|
||||
|
||||
impl<'b> Archive<'b> {
|
||||
@@ -234,7 +241,7 @@ impl<'b> Archive<'b> {
|
||||
warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped"));
|
||||
}
|
||||
let version = meta.get("version").and_then(Value::as_u64).unwrap_or(0);
|
||||
Ok(Archive { document, version, pages, warnings, zip: z })
|
||||
Ok(Archive { document, version, pages, warnings, zip: z, image_total: std::cell::Cell::new(0) })
|
||||
}
|
||||
|
||||
/// The bytes of the image a layer references (`images/<name>`).
|
||||
@@ -245,24 +252,39 @@ impl<'b> Archive<'b> {
|
||||
}
|
||||
// A clone shares the parsed directory and the bytes; the resolver only has shared access.
|
||||
let mut z = self.zip.clone();
|
||||
crate::read_member(&mut z, &format!("images/{name}"), MAX_IMAGE).ok()
|
||||
let mut total = self.image_total.get();
|
||||
let data = capped_member(&mut z, &format!("images/{name}"), MAX_IMAGE, &mut total, MAX_IMAGE_TOTAL).ok();
|
||||
self.image_total.set(total);
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON member `name`, refused before unpacking when it would be larger than `limit`, than
|
||||
/// [`MAX_JSON_RATIO`] times what it takes in the archive (past a floor), or than what is left of
|
||||
/// the file's [`MAX_JSON_TOTAL`] (`total`: read so far).
|
||||
fn json_member(z: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str, limit: u64, total: &mut u64) -> Result<Value, FormatError> {
|
||||
/// Member `name`, refused before unpacking when it would be larger than `limit`, than
|
||||
/// [`MAX_RATIO`] times what it takes in the archive (past a floor), or than what is left of
|
||||
/// `max_total` (`total`: read so far, counted up).
|
||||
fn capped_member(
|
||||
z: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
|
||||
name: &str,
|
||||
limit: u64,
|
||||
total: &mut u64,
|
||||
max_total: u64,
|
||||
) -> Result<Vec<u8>, FormatError> {
|
||||
let (size, packed) = {
|
||||
let f = z.by_name(name).map_err(|e| corrupt(format!("{name}: {e}")))?;
|
||||
(f.size(), f.compressed_size())
|
||||
};
|
||||
let allowed = limit.min(packed.saturating_mul(MAX_JSON_RATIO).max(MIN_JSON_ALLOWANCE)).min(MAX_JSON_TOTAL.saturating_sub(*total));
|
||||
let allowed = limit.min(packed.saturating_mul(MAX_RATIO).max(MIN_ALLOWANCE)).min(max_total.saturating_sub(*total));
|
||||
if size > allowed {
|
||||
return Err(corrupt(format!("{name} is too large")));
|
||||
}
|
||||
let data = crate::read_member(z, name, allowed)?;
|
||||
*total += data.len() as u64;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// JSON member `name`, read by [`capped_member`] within the file's [`MAX_JSON_TOTAL`].
|
||||
fn json_member(z: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str, limit: u64, total: &mut u64) -> Result<Value, FormatError> {
|
||||
let data = capped_member(z, name, limit, total, MAX_JSON_TOTAL)?;
|
||||
serde_json::from_slice(&data).map_err(|e| corrupt(format!("{name}: {e}")))
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,20 @@ fn oval_slice_and_bitmap() {
|
||||
assert_eq!(doc.images.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_bombs_are_refused() {
|
||||
// A valid 2×2 PNG followed by 40 MB of zeros packs to a few dozen kilobytes.
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
png.resize(png.len() + 40 * 1024 * 1024, 0);
|
||||
let mut bitmap = rect("Photo", 0.0, 0.0, 30.0, 20.0);
|
||||
bitmap["_class"] = json!("bitmap");
|
||||
bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": "images/bomb.png"});
|
||||
let (doc, _) = open_with(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![bitmap])], &[("images/bomb.png", &png)]);
|
||||
let total: usize = doc.images.values().map(|i| i.bytes.len()).sum();
|
||||
assert!(total < 1024 * 1024, "{total} image bytes kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_class_is_a_warning() {
|
||||
let mut odd = rect("Odd", 0.0, 0.0, 10.0, 10.0);
|
||||
|
||||
Reference in New Issue
Block a user