Merge main into #2139

This commit is contained in:
bflatastic
2026-11-13 23:23:44 +07:00
13 changed files with 380 additions and 22 deletions
+22 -3
View File
@@ -2,15 +2,21 @@
//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out.
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::{Value, json};
/// The result of one `figcraft-cli run` step (script file `name`); `opt_out` sets the environment
/// variable.
fn run_one(name: &str, step: Value, args: &[&str], opt_out: bool) -> Value {
static NEXT_SCRIPT: AtomicU64 = AtomicU64::new(0);
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let script = d.join(format!("{name}.json"));
// Tests in this binary run concurrently. `sketch_imports_keep_to_no_system_fonts` also calls
// `families`, so a shared `families.json` could be truncated while another child reads it;
// an empty script is valid and makes the CLI exit successfully without printing result JSON.
let sequence = NEXT_SCRIPT.fetch_add(1, Ordering::Relaxed);
let script = d.join(format!("{name}-{sequence}.json"));
std::fs::write(&script, json!([step]).to_string()).unwrap();
let mut cmd = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
cmd.arg("run").arg(&script).args(args);
@@ -20,8 +26,21 @@ fn run_one(name: &str, step: Value, args: &[&str], opt_out: bool) -> Value {
cmd.env_remove("FIGCRAFT_NO_SYSTEM_FONTS");
}
let out = cmd.output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let line: Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(
out.status.success(),
"{name}: CLI status {:?}; stdout={:?}; stderr={}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let line: Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|error| {
panic!(
"{name} {:?}: CLI returned success without JSON: {error}; stdout={:?}; stderr={}",
args,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
});
line["result"].clone()
}
+11 -3
View File
@@ -329,14 +329,22 @@ pub(super) fn paste_over(s: &mut Session, _p: &Value) -> Result<Value> {
let sel = s.selection();
let page = s.page()?;
let doc = s.doc()?;
let (parent, at) = match sel.first() {
let (parent, at, above) = match sel.first() {
Some(i) => {
let parent = doc.node(*i).and_then(|n| n.parent).unwrap_or(page);
(parent, doc.world_bounds(*i).map(|b| (b.x0, b.y0)))
(parent, doc.world_bounds(*i).map(|b| (b.x0, b.y0)), doc.index_in_parent(*i).map(|ix| ix + 1))
}
None => (page, None),
None => (page, None, None),
};
let ids = paste_into(s, parent, at)?;
if let Some(above) = above {
let doc = s.doc_mut()?;
for (k, i) in
ids.iter().filter(|i| doc.node(**i).is_some_and(|n| n.parent == Some(parent))).copied().collect::<Vec<_>>().into_iter().enumerate()
{
doc.reorder(i, above + k)?;
}
}
s.set_selection(ids.clone());
Ok(json!({"ids": ids}))
}
+18
View File
@@ -345,6 +345,24 @@ fn copy_paste_and_duplicate() {
assert_eq!(d["ids"].as_array().unwrap().len(), 1);
}
#[test]
fn paste_over_puts_the_copies_just_above_the_selected_layer() {
let mut s = sess();
let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap());
let layer =
|s: &mut Session, x: f64| id(&s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 10, "height": 10, "parent": frame})).unwrap());
let (a, b, c) = (layer(&mut s, 10.0), layer(&mut s, 30.0), layer(&mut s, 50.0));
let kids = |s: &Session| s.doc().unwrap().node(frame).unwrap().children.clone();
assert_eq!(kids(&s), vec![a, b, c]);
s.execute("edit.copy", &json!({"ids": [b, c]})).unwrap();
s.set_selection(vec![a]);
let pasted: Vec<NodeId> = s.execute("edit.pasteOver", &json!({})).unwrap()["ids"].as_array().unwrap().iter().filter_map(params::id_of).collect();
assert_eq!(pasted.len(), 2);
assert_eq!(kids(&s), vec![a, pasted[0], pasted[1], b, c], "in the selected layer's frame, just above it, in the copied order");
assert_eq!(s.doc().unwrap().world_bounds(pasted[0]).unwrap().x0, 10.0, "at the selected layer's position");
assert_eq!(s.selection(), pasted);
}
#[test]
fn boolean_and_flatten() {
let mut s = sess();
+172 -1
View File
@@ -9,7 +9,7 @@ fn board_schema() -> Schema {
/// [`board_schema`] over another base schema.
fn board_schema_of(base: &str) -> Schema {
Schema::parse(&base.replace(
Schema::parse(&base.replace("enum ShapeWithTextType SQUARE ELLIPSE", "enum ShapeWithTextType SQUARE ROUNDED_RECTANGLE ELLIPSE DIAMOND TRIANGLE_UP TRIANGLE_DOWN PARALLELOGRAM_RIGHT PARALLELOGRAM_LEFT TRAPEZOID PENTAGON HEXAGON OCTAGON STAR PLUS ARROW_LEFT ARROW_RIGHT CHEVRON SPEECH_BUBBLE ENG_DATABASE DOCUMENT_SINGLE PREDEFINED_PROCESS MANUAL_INPUT INTERNAL_STORAGE ENG_QUEUE ENG_FILE ENG_FOLDER SHIELD DOCUMENT_MULTIPLE SUMMING_JUNCTION OR").replace(
"\nmessage Blob ",
", GeneratedData nodeGenerationData, GeneratedData derivedImmutableFrameData, string textAlignHorizontal, string textAlignVertical\nmessage GeneratedData NodeChange[] overrides, uint version\nmessage Blob ",
))
@@ -549,3 +549,174 @@ fn a_fig_sticky_stays_a_design_frame() {
assert!(frame.board.is_none());
assert_eq!(frame.children.len(), 3);
}
/// `board_node("SHAPE_WITH_TEXT")` of one FigJam kind.
fn shape_node(kind: &'static str) -> Kv<'static> {
let mut layer = board_node("SHAPE_WITH_TEXT");
set(&mut layer, "shapeWithTextType", e(kind));
layer
}
/// On a whiteboard a diamond is a native shape: FigCraft draws its outline and its text sits inside.
#[test]
fn a_jam_diamond_is_a_native_shape_drawn_by_figcraft() {
let (doc, warnings) = jam(shape_node("DIAMOND"));
let frame = named(&doc, "Board object");
assert_eq!(frame.board, Some(figcraft_doc::BoardItem::Shape { shape: figcraft_doc::BoardShape::Diamond }));
assert!(frame.fills.is_empty());
assert_eq!((frame.width, frame.height), (240.0, 240.0));
assert_eq!(frame.layout.as_ref().expect("shape auto layout").padding, [0.25 * 240.0; 4]);
assert_eq!(
(frame.child_layout.sizing_h, frame.child_layout.sizing_v),
(figcraft_doc::Sizing::Fixed, figcraft_doc::Sizing::Fixed),
"a fixed box, as a shape made here: it must not hug its text"
);
assert_eq!(frame.children.len(), 2, "the outline and the text: {warnings:?}");
let outline = doc.node(frame.children[0]).unwrap();
let NodeKind::Vector { network } = &outline.kind else { panic!("{:?}", outline.kind) };
assert_eq!(*network, figcraft_doc::VectorNetwork::from_path(&figcraft_doc::BoardShape::Diamond.path(240.0, 240.0)));
assert_eq!(outline.fills[0].solid_color().unwrap().to_rgba8(), [255, 191, 64, 255]);
assert!(outline.child_layout.absolute);
assert_eq!(outline.constraints.horizontal, figcraft_doc::HConstraint::Scale);
assert_eq!(outline.constraints.vertical, figcraft_doc::VConstraint::Scale);
let label = doc.node(frame.children[1]).unwrap().kind.text().unwrap();
assert_eq!(label.characters, "Olá\nIdea");
assert_eq!(label.align_h, figcraft_doc::TextAlignH::Center);
}
/// A shape past the importer's size cap is drawn at the capped size, so its outline stays inside
/// its frame.
#[test]
fn a_jam_shape_past_the_size_cap_draws_its_outline_at_the_cap() {
let mut layer = shape_node("DIAMOND");
set(&mut layer, "size", xy(2_000_000.0, 2_000_000.0));
let (doc, warnings) = jam(layer);
let frame = named(&doc, "Board object");
assert_eq!(frame.board, Some(figcraft_doc::BoardItem::Shape { shape: figcraft_doc::BoardShape::Diamond }), "{warnings:?}");
assert_eq!((frame.width, frame.height), (1e6, 1e6));
let outline = doc.node(frame.children[0]).unwrap();
let NodeKind::Vector { network } = &outline.kind else { panic!("{:?}", outline.kind) };
assert_eq!(*network, figcraft_doc::VectorNetwork::from_path(&figcraft_doc::BoardShape::Diamond.path(1e6, 1e6)));
}
/// 23 of FigJam's 30 shape kinds have a FigCraft outline; the other 7 stay frames and say which.
#[test]
fn every_figjam_shape_kind_maps_or_stays_a_frame() {
let kinds = [
"SQUARE",
"ROUNDED_RECTANGLE",
"ELLIPSE",
"DIAMOND",
"TRIANGLE_UP",
"TRIANGLE_DOWN",
"PARALLELOGRAM_RIGHT",
"PARALLELOGRAM_LEFT",
"TRAPEZOID",
"PENTAGON",
"HEXAGON",
"OCTAGON",
"STAR",
"PLUS",
"ARROW_LEFT",
"ARROW_RIGHT",
"CHEVRON",
"SPEECH_BUBBLE",
"ENG_DATABASE",
"DOCUMENT_SINGLE",
"PREDEFINED_PROCESS",
"MANUAL_INPUT",
"INTERNAL_STORAGE",
"ENG_QUEUE",
"ENG_FILE",
"ENG_FOLDER",
"SHIELD",
"DOCUMENT_MULTIPLE",
"SUMMING_JUNCTION",
"OR",
];
assert_eq!(kinds.len(), 30);
let mut mapped = 0;
for kind in kinds {
let table = super::map::board_shape(kind);
let (doc, warnings) = jam(shape_node(kind));
let frame = named(&doc, "Board object");
if let Some(shape) = table {
mapped += 1;
assert_eq!(frame.board, Some(figcraft_doc::BoardItem::Shape { shape }), "{kind}");
} else {
assert!(frame.board.is_none(), "{kind}");
assert!(warnings.iter().any(|w| w.contains(kind) && w.contains("no FigCraft outline")), "{kind}: {warnings:?}");
}
}
assert_eq!(mapped, 23);
}
/// A square keeps the paints and weight the file gave it, as read.
#[test]
fn a_square_keeps_its_paints_and_weight() {
let mut layer = shape_node("SQUARE");
let background =
record(0, vec![("fillPaints", arr(vec![solid(1.0, 1.0, 1.0)])), ("strokePaints", arr(vec![solid(0.5, 0.5, 0.5)])), ("strokeWeight", f(4.0))]);
set(&mut layer, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text("Olá", 16.0))]))]));
let (doc, _) = jam(layer);
let frame = named(&doc, "Board object");
assert_eq!(frame.board, Some(figcraft_doc::BoardItem::Shape { shape: figcraft_doc::BoardShape::Rectangle }));
let outline = doc.node(frame.children[0]).unwrap();
assert_eq!(outline.fills[0].solid_color().unwrap().to_rgba8(), [255, 255, 255, 255]);
assert_eq!(outline.strokes[0].solid_color().unwrap().to_rgba8(), [128, 128, 128, 255]);
assert_eq!(outline.stroke.weight, 4.0);
}
/// A shape record that isn't complete keeps today's generated layers and no board item.
#[test]
fn a_damaged_shape_record_keeps_the_generated_layers() {
let mut cases = Vec::new();
let mut a = shape_node("DIAMOND");
set(&mut a, "derivedImmutableFrameData", o(vec![("version", u(2))]));
cases.push(a);
let mut b = shape_node("DIAMOND");
set(&mut b, "nodeGenerationData", o(vec![("overrides", arr(vec![record(1, text("Olá", 16.0))]))]));
cases.push(b);
let mut c = shape_node("DIAMOND");
let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 1.0, 1.0)]))]);
set(&mut c, "nodeGenerationData", o(vec![("overrides", arr(vec![background]))]));
cases.push(c);
for (i, layer) in cases.into_iter().enumerate() {
let (as_fig, fig_warnings) = imported(layer.clone());
let (doc, warnings) = jam(layer);
let frame = named(&doc, "Board object");
assert!(frame.board.is_none(), "case {i}");
assert_eq!(frame.children.len(), named(&as_fig, "Board object").children.len(), "case {i}: the generated layers, as in a .fig");
let sorted = |mut w: Vec<String>| {
w.sort();
w
};
assert_eq!(sorted(warnings), sorted(fig_warnings), "case {i}: today's warnings");
}
}
/// A shape with no text yet is still a native shape (an empty one).
#[test]
fn a_jam_shape_without_text_is_still_a_native_shape() {
let mut layer = shape_node("SQUARE");
let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 1.0, 1.0)]))]);
set(&mut layer, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text("", 16.0))]))]));
let (doc, _) = jam(layer);
let frame = named(&doc, "Board object");
assert_eq!(frame.board, Some(figcraft_doc::BoardItem::Shape { shape: figcraft_doc::BoardShape::Rectangle }));
assert_eq!(doc.node(frame.children[1]).unwrap().kind.text().unwrap().characters, "");
}
/// Blank generated text must not hide text the outer node still carries (#1397): on a whiteboard
/// too, such a shape keeps the generated layers.
#[test]
fn a_jam_shape_whose_text_only_the_outer_node_carries_keeps_it() {
let mut layer = shape_node("SQUARE");
set(&mut layer, "textData", o(vec![("characters", s("Outer saved text"))]));
let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 1.0, 1.0)]))]);
set(&mut layer, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text(" ", 24.0))]))]));
let (doc, _) = jam(layer);
let frame = named(&doc, "Board object");
assert!(frame.board.is_none(), "the generated layers keep the outer text");
assert!(frame.children.iter().filter_map(|id| doc.node(*id)?.kind.text()).any(|t| t.characters == "Outer saved text"));
}
+3
View File
@@ -28,6 +28,9 @@ mod proto;
mod text;
mod variables;
#[cfg(test)]
pub(super) use board::board_shape;
use component::OverrideScope;
#[cfg(test)]
use paint::paint;
+128
View File
@@ -5,6 +5,7 @@
use super::layer::{own_text, valid_size};
use super::paint::paint;
use super::{Affine, Kv, MAX_DEPTH, Mapper, Obj, Value, View, generated_record, json, num};
use figcraft_doc::BoardShape;
impl<'a> Mapper<'a> {
/// A sticky as a native board sticky: its frame (size, place, opacity and shadows as saved,
@@ -86,3 +87,130 @@ impl<'a> Mapper<'a> {
Some(kids)
}
}
impl<'a> Mapper<'a> {
/// A sticky or shape with text as a native board item, or `None` with the note that it is
/// a frame with its generated layers.
pub(super) fn native_board_item(&mut self, own: &str, o: &mut Obj, v: &View<'a>, rest_id: &str, depth: usize) -> Option<Vec<Value>> {
let native = if own == "STICKY" { self.native_sticky(o, v, rest_id, depth) } else { self.native_shape(o, v, rest_id, depth) };
if native.is_none() && own == "STICKY" {
self.warn("sticky layers import as frames with their text");
} else if native.is_none() {
self.warn("shape with text layers import as frames with their text");
}
native
}
}
impl<'a> Mapper<'a> {
/// A shape with text as a native board shape: an empty frame holding FigCraft's own outline
/// (the file's paints and weight) and the text, centred. `None` (nothing changed) when the kind
/// has no FigCraft outline or a generated record is damaged: then the caller keeps the
/// generated layers.
pub(super) fn native_shape(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, depth: usize) -> Option<Vec<Value>> {
let kind = v.str("shapeWithTextType").unwrap_or("SQUARE");
let Some(shape) = board_shape(kind) else {
self.warn(format!("shape with text kind {} has no FigCraft outline; kept as a frame", crate::rest::props::clip_str(kind)));
return None;
};
if v.get("nodeGenerationData").is_none() || v.get("derivedImmutableFrameData").and_then(|d| num(d, "version")) != Some(1.0) {
return None;
}
let record = |id: u32| {
let (style, bounds) = (generated_record(v, "nodeGenerationData", id)?, generated_record(v, "derivedImmutableFrameData", id)?);
let view = View::of(style).with([(bounds, 1.0)]);
(valid_size(view.get("size")).is_some() && view.transform().is_some()).then_some(view)
};
let background = record(0)?;
let body = record(1)?;
// Blank generated text must not hide text the outer node still carries (#1397): the
// generated layers keep it.
if own_text(&body).is_none() && own_text(v).is_some() {
return None;
}
if depth >= MAX_DEPTH || self.count.saturating_add(2) > self.budget() {
return None;
}
let (w, h) = v.size().or_else(|| o.get("size").and_then(|s| Some((s.get("x")?.as_f64()?, s.get("y")?.as_f64()?))))?;
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return None;
}
// The REST reader caps every size; the outline is drawn at the capped size too, so it
// stays inside its frame.
let (w, h) = (w.min(crate::rest::props::MAX_SIZE), h.min(crate::rest::props::MAX_SIZE));
// Everything is valid: build the outline from the record's paints, then the frame.
let mut outline = Obj::new();
outline.insert("id".into(), json!(format!("{rest_id};generated0")));
outline.insert("type".into(), json!("VECTOR"));
outline.insert("name".into(), json!("Outline"));
self.common(&mut outline, &background, "VECTOR", Affine::IDENTITY, None);
outline.insert("size".into(), json!({"x": w, "y": h}));
outline.insert("relativeTransform".into(), json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]));
outline.insert("vectorNetwork".into(), serde_json::to_value(figcraft_doc::VectorNetwork::from_path(&shape.path(w, h))).ok()?);
outline.insert("layoutPositioning".into(), json!("ABSOLUTE"));
outline.insert("constraints".into(), json!({"horizontal": "SCALE", "vertical": "SCALE"}));
outline.remove("cornerRadius");
let mut label = Obj::new();
label.insert("id".into(), json!(format!("{rest_id};generated1")));
label.insert("type".into(), json!("TEXT"));
label.insert("name".into(), json!(own_text(&body).map_or_else(|| "Text".to_string(), |c| c.chars().take(40).collect())));
self.common(&mut label, &body, "TEXT", Affine::IDENTITY, None);
self.text(&mut label, &body);
if let Some(Value::Object(style)) = label.get_mut("style") {
style.insert("textAutoResize".into(), json!("HEIGHT"));
style.insert("textAlignHorizontal".into(), json!("CENTER"));
}
label.insert("layoutSizingHorizontal".into(), json!("FILL"));
o.insert("fills".into(), json!([]));
o.insert("strokes".into(), json!([]));
o.insert("clipsContent".into(), json!(false));
o.remove("cornerRadius");
let [top, right, bottom, left] = shape.text_insets();
for (k, val) in [
("layoutMode", json!("VERTICAL")),
("primaryAxisAlignItems", json!("CENTER")),
("counterAxisAlignItems", json!("CENTER")),
("primaryAxisSizingMode", json!("FIXED")),
("counterAxisSizingMode", json!("FIXED")),
("paddingTop", json!(top * h)),
("paddingRight", json!(right * w)),
("paddingBottom", json!(bottom * h)),
("paddingLeft", json!(left * w)),
] {
o.insert(k.into(), val);
}
self.count += 2;
o.insert("board".into(), json!({"type": "shape", "shape": shape.id()}));
Some(vec![Value::Object(outline), Value::Object(label)])
}
}
/// FigJam's `shapeWithTextType` as the outline FigCraft draws for it; `None` for the kinds it has none for.
pub(in crate::fig) fn board_shape(kind: &str) -> Option<BoardShape> {
Some(match kind {
"SQUARE" => BoardShape::Rectangle,
"ROUNDED_RECTANGLE" => BoardShape::RoundedRectangle,
"ELLIPSE" => BoardShape::Ellipse,
"DIAMOND" => BoardShape::Diamond,
"TRIANGLE_UP" => BoardShape::Triangle,
"TRIANGLE_DOWN" => BoardShape::TriangleDown,
"PARALLELOGRAM_RIGHT" => BoardShape::ParallelogramRight,
"PARALLELOGRAM_LEFT" => BoardShape::ParallelogramLeft,
"TRAPEZOID" => BoardShape::Trapezoid,
"PENTAGON" => BoardShape::Pentagon,
"HEXAGON" => BoardShape::Hexagon,
"OCTAGON" => BoardShape::Octagon,
"STAR" => BoardShape::Star,
"PLUS" => BoardShape::Plus,
"ARROW_LEFT" => BoardShape::ArrowLeft,
"ARROW_RIGHT" => BoardShape::ArrowRight,
"CHEVRON" => BoardShape::Chevron,
"SPEECH_BUBBLE" => BoardShape::SpeechBubble,
"ENG_DATABASE" => BoardShape::Database,
"DOCUMENT_SINGLE" => BoardShape::Document,
"PREDEFINED_PROCESS" => BoardShape::PredefinedProcess,
"MANUAL_INPUT" => BoardShape::ManualInput,
"INTERNAL_STORAGE" => BoardShape::InternalStorage,
_ => return None,
})
}
+4 -7
View File
@@ -174,9 +174,9 @@ impl<'a> Mapper<'a> {
// A label without a snapshot: the connector becomes a frame holding its line and its label.
let connector_label = own == "CONNECTOR" && !connector_snapshot && self.connector_label_fits(&view, depth);
let ty = if connector_label { "FRAME" } else { Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)? };
// A whiteboard's sticky says it imports as a frame only when it can't be a native one.
let native_sticky = self.board && own == "STICKY";
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH" && !native_sticky) {
// A whiteboard's sticky or shape with text says it imports as a frame only when it can't be a native one.
let native_board = self.board && matches!(own, "STICKY" | "SHAPE_WITH_TEXT");
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH" && !native_board) {
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
}
if connector_snapshot {
@@ -264,7 +264,7 @@ impl<'a> Mapper<'a> {
});
kids.splice(0..0, connector_kids);
if stand.is_some_and(|s| s.0 == "FRAME") {
let native = if native_sticky { self.native_sticky(&mut o, &view, &rest_id, depth) } else { None };
let native = if native_board { self.native_board_item(own, &mut o, &view, &rest_id, depth) } else { None };
if let Some(native) = native {
kids.splice(0..0, native);
} else if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
@@ -273,9 +273,6 @@ impl<'a> Mapper<'a> {
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 native_sticky && !o.contains_key("board") {
self.warn("sticky layers import as frames with their text");
}
}
if !kids.is_empty() {
o.insert("children".into(), Value::Array(kids));
+1 -1
View File
@@ -13,7 +13,7 @@ use serde_json::Value;
use super::Ctx;
/// Largest width/height/stroke/radius/spacing value taken from the file.
pub(super) const MAX_SIZE: f64 = 1e6;
pub(crate) const MAX_SIZE: f64 = 1e6;
/// Largest coordinate taken from the file.
pub(super) const MAX_COORD: f64 = 1e7;
/// Most entries read from any one list (paints, effects, dashes, grids…).
+12 -2
View File
@@ -376,9 +376,19 @@ fn font_bytes_load_into_the_db() {
let families = load_font_bytes(f.bytes.to_vec());
assert_eq!(families.len(), 1);
assert!(families.iter().all(|f| has_family(f)));
let faces = db().read().unwrap().face_count();
// The global face count can grow here when another test finishes the concurrent system
// font scan. Count explicitly loaded faces from this family so the idempotency assertion
// stays isolated from unrelated installed fonts.
let loaded_faces = || {
let db = db().read().unwrap();
families
.iter()
.map(|family| db.faces.iter().filter(|face| face.loaded > 0 && face.family.eq_ignore_ascii_case(family)).count())
.collect::<Vec<_>>()
};
let faces = loaded_faces();
assert_eq!(load_font_bytes(f.bytes.to_vec()), families);
assert_eq!(db().read().unwrap().face_count(), faces);
assert_eq!(loaded_faces(), faces);
}
}
+1
View File
@@ -1889,6 +1889,7 @@ name = "English"
"set-variable actions without a value were skipped" = "set-variable actions without a value were skipped"
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"
"shape generated outline is missing or damaged; using its rectangle" = "shape generated outline is missing or damaged; using its rectangle"
"shape with text layers import as frames with their text" = "shape with text layers import as frames with their text"
"slot content assignment target is missing or is not slot content" = "slot content assignment target is missing or is not slot content"
"slot content assignment uses the empty GUID sentinel and was skipped" = "slot content assignment uses the empty GUID sentinel and was skipped"
"slot layers without a matching slot property were imported as frames" = "slot layers without a matching slot property were imported as frames"
+3 -2
View File
@@ -1,6 +1,6 @@
# Boards parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-12 · **Change:** minor (a sticky has a text-size menu in the contextual bar, Small 16, Medium 24, Large 40, Extra large 64 and Huge 96, with Small the default (an earlier reading of 18 did not reproduce); `board.setStyle {fontSize}` sizes stickies and the sticky tool remembers the last size for new ones; the note grows taller at Huge and shrinks back, as measured in FigJam's web app, 2026-11-12; resizing a board table by height changes its rows, not blank space below them: a bottom or top edge grows the last row, a corner shares the change over every row, and a table keeps at least 40 per cell, measured in FigJam's web app, 2026-11-09 (#2172); a `.jam` file's stickies come in as native board stickies, with their colour, text and author kept and the size as saved (#1808); a sticky's text stays 18 and the note grows taller instead of the text shrinking, as measured in FigJam's web app (#2157); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-12 · **Change:** minor (a `.jam` file's shapes with text come in as native board shapes for the 23 kinds FigCraft draws, the other 7 as frames (#1808); a sticky has a text-size menu in the contextual bar, Small 16, Medium 24, Large 40, Extra large 64 and Huge 96, with Small the default (an earlier reading of 18 did not reproduce); `board.setStyle {fontSize}` sizes stickies and the sticky tool remembers the last size for new ones; the note grows taller at Huge and shrinks back, as measured in FigJam's web app, 2026-11-12; resizing a board table by height changes its rows, not blank space below them: a bottom or top edge grows the last row, a corner shares the change over every row, and a table keeps at least 40 per cell, measured in FigJam's web app, 2026-11-09 (#2172); a `.jam` file's stickies come in as native board stickies, with their colour, text and author kept and the size as saved (#1808); a sticky's text stays 18 and the note grows taller instead of the text shrinking, as measured in FigJam's web app (#2157); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
**Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind
(`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that
@@ -66,7 +66,7 @@ all end with the same geometry.
| Audio and huddles | missing | |
| AI features (summaries, sorting stickies, generating boards) | missing | |
| Grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it. Dots by default, or lines, graph paper (a strong line every 2–12 cells over fainter ones, like Japanese section paper) or none; spacing (4–512), mark size and contrast are settings kept with the preferences (`board.grid`; Preferences ▸ Board grid). Zoomed out it thins out (dots and lines by halves, graph paper down to its strong lines) until the marks are at least 14 points apart (10 for lines), the marks the next step drops fading out first; dots stay about a point and a half across on screen (1 to 3 points), scaled by the size setting. Dark marks on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per mark, a crossing of two lines blended once; never in exports. FigCraft's own settings: the incumbent's board grid has none |
| `.jam` import and export | **partial**: a `.jam` opens as a board, best effort (#1342): its stickies come in as native stickies (their colour, text and author kept; size as saved), its shapes with text as frames with their generated layers and its connectors as vectors, not yet as native board objects (an elbowed connector keeps the path it was saved with, #1910); no export | see [`file-format-parity.md`](file-format-parity.md) |
| `.jam` import and export | **partial**: a `.jam` opens as a board, best effort (#1342): its stickies come in as native stickies (their colour, text and author kept; size as saved), its shapes with text as native shapes for the 23 kinds FigCraft draws (the file's paints and weight kept, the outline FigCraft's own; the 7 others stay frames with their generated layers) and its connectors as vectors, not yet as native board objects (an elbowed connector keeps the path it was saved with, #1910); no export | see [`file-format-parity.md`](file-format-parity.md) |
## Revision history
@@ -77,6 +77,7 @@ all end with the same geometry.
| 2026-11-09 | patch | M12, #2157: a sticky's text is 18 and stays 18 (measured in FigJam's web app: ten lines, then a long paragraph, never changed it; the note kept its width and grew taller). The normalize pass no longer resets a sticky's text to a size by its length (24 down to 11 before), so stickies in saved files keep their size. Size presets (Small to Huge) are still missing. Tests: `a_new_sticky_starts_at_18`, `a_sticky_keeps_its_text_size_as_text_is_added`, `a_sticky_saved_with_another_text_size_keeps_it` |
| 2026-11-10 | minor | M12, #2226 (step 1 of 4): mind maps in the document and engine. A mind map node is a board object of its own (`BoardItem::MindNode`: text with no box) and a branch is a connector marked `branch`; the map's shape is read from its branches. `board.mindMap` places a first node with three children and `board.mindMapAdd` adds a child or a sibling, each one undo step. The normalize pass lays a touched map out again from its first node (`board::settle_mind_maps`, `figcraft_doc::mind_layout`): 88 between columns, 48 between siblings, each stack centred on its parent, numbers from a FigJam file saved and decoded. Tests: `board_mindmap_tests` in doc (five) and engine (eight). Numbers unchanged (no toolbar entry yet). |
| 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer, and joined by a connector leaving by the handle's side and arriving by the side facing it (`board.addConnected` gains `at: [x, y]`, a point of the page placed in the parent's coordinates); let go on another sticky or shape, inside a section or frame too (`board::sticky_or_shape_at`), a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it; let go off the canvas or over the toolbar, nothing, and the bar above the selection stays away meanwhile, as for any drag. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (the drag ones), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. |
| 2026-11-12 | minor | M9, #1808: `.jam` import: a FigJam shape with text comes in as a native board shape (`BoardItem::Shape`) for the 23 kinds FigCraft draws (square, rounded rectangle, ellipse, diamond, triangles, parallelograms, trapezoid, pentagon, hexagon, octagon, star, plus, arrows, chevron, speech bubble, database, document, predefined process, manual input, internal storage). The frame is empty and a fixed size, as a shape made here, with centred auto layout and the kind's text insets; it holds an `Outline` vector drawn by FigCraft (`BoardShape::path`, so FigJam's 3 pt corner on a square is lost, and `board.setShape` and resizing treat it as FigCraft's own) with the file's paints, weight and dashes as read, and the text, centred. The 7 kinds FigCraft has no outline for (ENG_QUEUE, ENG_FILE, ENG_FOLDER, SHIELD, DOCUMENT_MULTIPLE, SUMMING_JUNCTION, OR) stay frames with their generated layers and a warning naming the kind; so does a shape whose generated records are damaged, or whose generated text is blank while the outer layer still carries text (#1397), and the same shape in a `.fig`; a shape with no text at all is still native, with an empty text layer. Tests: `board_import_tests.rs` (six). Numbers unchanged. |
| 2026-11-10 | minor | M9, #1808: a `.jam` sticky imports as a native board sticky (`BoardItem::Sticky`): its frame keeps the saved size (240 pt, not FigCraft's 200), place, opacity and shadows, the saved colour is kept as it is (not snapped to the board colours), the note and the author line are its text layers, and it lays out like one made here (vertical, space between, hugging the height down to the saved size, the saved inset as padding). The importer reads a `board` field on the REST JSON (a FigCraft extension) for this; a sticky whose generated records are damaged, or the same sticky in a `.fig`, keeps the generated layers as before. Shapes, connectors, stamps and tables are unchanged; the note's text keeps the file's size until it is edited. Tests: `board_import_tests.rs` (native sticky, no author, damaged records, `.fig` unchanged), `import_regression_tests.rs` (the `board` field and junk values). Numbers unchanged. |
| 2026-11-04 | minor | M12, #1916: a connector end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), as FigJam's do (the issue's screenshots: the line starts a few pixels outside, with a round cap), so the stroke never lies on the fill; elbows leaving one side share their first run along its normal, curves leave tangent to it and straight ones fan out from the same point; the `.jam` importer's routes, re-attached ends, path handles and labels follow the route's points; an end placed by hand with Cmd/Ctrl held keeps its exact point. Tests: `board_route_tests` (three). Numbers unchanged. |
| 2026-11-04 | patch | M6, #1910: a `.jam` elbowed connector saved with its path (FigJam's `connectorControlPoints`) keeps it on import: the points become the `ConnectorPath::Runs` a connector reshaped by hand has and the line is walked along them, so a connector drawn around a layer stays around it; one whose points don't make a path from its ends' sides is routed as before. Still a vector, not a native board object. Details in [`file-format-parity.md`](file-format-parity.md). |
File diff suppressed because one or more lines are too long
+2 -1
View File
File diff suppressed because one or more lines are too long