# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
aiongg
2026-10-25 02:21:44 +00:00
5 changed files with 94 additions and 15 deletions
+30 -7
View File
@@ -941,14 +941,37 @@ pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> O
.reduce(|a, b| a.union(b))
}
/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off,
/// and always for slices) every top-level layer of their page, so whatever shows in the area is in.
fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec<NodeId> {
let slice = ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)));
match ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) {
Some(page) if slice || !setting.contents_only => page.children.clone(),
_ => super::edit::z_sorted(doc, ids),
/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off)
/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing
/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a
/// slice anywhere else draws every top-level layer of its page.
pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec<NodeId> {
let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else {
return super::edit::z_sorted(doc, ids);
};
if !setting.contents_only {
return page.children.clone();
}
let mut own = Vec::with_capacity(ids.len());
for &i in ids {
if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) {
own.push(i);
continue;
}
match slice_container(doc, i) {
Some(c) => own.push(c),
None => return page.children.clone(),
}
}
own.sort_unstable();
own.dedup();
super::edit::z_sorted(doc, &super::edit::roots(doc, &own))
}
/// The frame or group a slice sits in: None on the page or in a section.
fn slice_container(doc: &Document, slice: NodeId) -> Option<NodeId> {
let parent = doc.node(slice)?.parent?;
doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent)
}
/// [`export_bytes`] with an export setting's format and options (its size is `scale`: see
@@ -256,6 +256,43 @@ fn masks_and_slices_export_what_shows() {
let _ = under;
}
/// Ignore overlapping layers (contents only) on a slice: inside a frame or group the slice exports
/// only what is in that container; off, or on the page, everything in its area.
#[test]
fn a_slice_in_a_frame_or_group_exports_only_that_container() {
let mut s = Session::new();
let slice_in = |s: &mut Session, parent: Option<NodeId>, x: f64| {
let mut p = json!({"type": "slice", "x": x, "y": 25, "width": 50, "height": 50});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id(&ex(s, "node.create", p))
};
let at = |s: &mut Session, p: Value| pixels(&export(s, p)).get_pixel(40, 40).0;
// A frame holding a red square and a slice, with a blue square on the page above it.
let f = frame(&mut s, 0.0, 0.0, 100.0, 100.0, false);
rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#FF0000", Some(f));
let in_frame = slice_in(&mut s, Some(f), 25.0);
rect(&mut s, 50.0, 50.0, 100.0, 100.0, "#0000FF", None);
assert_eq!(png_size(&export(&mut s, json!({"ids": [in_frame]}))), (50, 50), "the slice still exports its own area");
assert_eq!(at(&mut s, json!({"ids": [in_frame]})), [255, 0, 0, 255], "on by default: only the frame's content, the blue square left out");
assert_eq!(at(&mut s, json!({"ids": [in_frame], "contentsOnly": false})), [0, 0, 255, 255], "off: everything in the slice's area");
// The same in a group.
let red = rect(&mut s, 300.0, 0.0, 100.0, 100.0, "#FF0000", None);
let grouped = slice_in(&mut s, None, 325.0);
let g = id(&ex(&mut s, "object.group", json!({"ids": [red, grouped]})));
assert_eq!(s.doc().unwrap().node(grouped).unwrap().parent, Some(g));
rect(&mut s, 350.0, 50.0, 100.0, 100.0, "#0000FF", None);
assert_eq!(at(&mut s, json!({"ids": [grouped]})), [255, 0, 0, 255], "in a group: only the group's content");
assert_eq!(at(&mut s, json!({"ids": [grouped], "contentsOnly": false})), [0, 0, 255, 255], "off: everything in its area");
// On the page the option has no effect.
rect(&mut s, 600.0, 0.0, 100.0, 100.0, "#FF0000", None);
let top = slice_in(&mut s, None, 625.0);
rect(&mut s, 650.0, 50.0, 100.0, 100.0, "#0000FF", None);
assert_eq!(at(&mut s, json!({"ids": [top]})), [0, 0, 255, 255], "on the page: everything in its area, with the option on");
assert_eq!(at(&mut s, json!({"ids": [top], "contentsOnly": false})), [0, 0, 255, 255], "on the page: everything in its area, with it off");
}
#[test]
fn several_layers_and_frames_to_pdf() {
let mut s = Session::new();
+8 -7
View File
@@ -195,19 +195,20 @@ pub(crate) fn thumbnail(ctx: &egui::Context, doc: &Document, id: NodeId, px: f32
Some(ctx.load_texture(format!("asset-thumb-{}", id.0), img, egui::TextureOptions::LINEAR))
}
/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the page content
/// under it, exactly as exporting one does (`figcraft_engine::cmd::file::drawn_layers`): without
/// this a slice's preview is a blank checkerboard next to a correct export.
/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the content under it
/// that exporting it with its first export setting writes (`figcraft_engine::cmd::file::drawn_layers`):
/// without this a slice's preview is a blank checkerboard next to a correct export.
pub(crate) fn thumbnail_pixmap(doc: &Document, id: NodeId, px: f32) -> Option<figcraft_render::Pixmap> {
let slice = doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Slice));
let b = if slice { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? };
let slice = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Slice));
let b = if slice.is_some() { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? };
let side = b.width().max(b.height());
if !(side.is_finite() && side > 0.0) {
return None;
}
let scale = (f64::from(px) / side).min(2.0);
if slice {
let layers = doc.page_of(id).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default();
if let Some(n) = slice {
let setting = n.exports.first().cloned().unwrap_or_default();
let layers = figcraft_engine::cmd::file::drawn_layers(doc, &[id], &setting);
return figcraft_render::render_area(doc, &layers, b, scale);
}
figcraft_render::render_node(doc, id, scale)
@@ -33,6 +33,23 @@ fn a_slice_previews_the_content_under_it() {
assert!(paints(&pix), "the rectangle under the slice shows");
}
/// The preview follows the slice's first export setting: with Ignore overlapping layers (contents
/// only) on, a slice in a frame shows only the frame's content, as its export does.
#[test]
fn a_slice_in_a_frame_previews_only_the_frame() {
let mut s = Session::new();
let new = |s: &mut Session, p: Value| NodeId(s.execute("node.create", &p).unwrap()["id"].as_u64().unwrap());
let f = new(&mut s, json!({"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100, "props": {"fills": []}}));
let slice = new(&mut s, json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100, "parent": f}));
new(&mut s, json!({"type": "rectangle", "x": 0, "y": 0, "width": 200, "height": 100}));
let pix = thumbnail_pixmap(s.doc().unwrap(), slice, 200.0).expect("the slice previews");
assert_eq!([pix.width(), pix.height()], [200, 100]);
assert!(!paints(&pix), "the rectangle on the page above the empty frame is left out");
s.execute("export.add", &json!({"ids": [slice], "setting": {"size": "1x", "contentsOnly": false}})).unwrap();
let pix = thumbnail_pixmap(s.doc().unwrap(), slice, 200.0).expect("the slice previews");
assert!(paints(&pix), "with the option off, the rectangle shows");
}
#[test]
fn a_slice_with_nothing_under_it_is_blank() {
let (doc, ids) = doc_after(&[("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100}))]);
+2 -1
View File
@@ -49,7 +49,7 @@ export limit, not a native document nesting limit.
| Format | Figma | FigCraft | Notes | Hours |
|---|---|---|---|---|
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them; sides round up to whole pixels | — |
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with contents only on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels | — |
| JPG | yes | yes | white where transparent; fixed quality (90) | — |
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 |
@@ -166,6 +166,7 @@ each.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged |
| 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before |
| 2026-10-24 | minor | Boards (M12): `.figcraft` saves the file kind (`kind: "board"`, absent for design files) and each board object's `board` metadata; the `.jam` row points at boards |
| 2026-10-15 | minor | `.fig` import (#279, #281; #266, #267): resized instances lay out their layers by their constraints, and explicit variable modes whose collection is named by its asset key come across (before, they were dropped and bound layers resolved in the default mode); fixture-tested, real files still owed. `.figcraft`: atomic saves and exports through unique staging files (#284), optional auto-save (#305). SVG export keeps a nested frame's fill and stroke inside its parent's clip (#327). Numbers unchanged |
| 2026-10-20 | minor | `.fig` export writes grid track sizes (`gridColumnsSizing` / `gridRowsSizing`): fixed px, fractional fill and content-sized tracks keep their sizes and come back through our own importer (#465). Tracks whose size the format can't hold (a negative or out-of-range value) keep their id and read back equal, with a warning. Numbers unchanged |