Merge branch 'main' into feat/guide-snapping

This commit is contained in:
Ahmed Mahmoud authored and GitHub committed 2026-10-29 10:08:31 +03:00
commit f3017259ff
170 files changed
+6560 -944

No files matched your search

Generated
+2
View File
@@ -1653,6 +1653,8 @@ dependencies = [
"fmv-macos-events",
"image",
"log",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"rfd",
"serde_json",
"wat",
+1 -1
View File
@@ -208,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
</td>
<td width="50%" valign="top">
<h3>Prototype and hand off</h3>
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose, ArkUI, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
</td>
</tr>
</table>
+4 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -352,11 +352,13 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged |
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
| 2026-10-27 | minor | M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. |
| 2026-10-27 | minor | Placed images keep their own size (M7, #1360): dropping, Place image, paste and `image.place` put an image at its pixel size and fit only a side past 4096 px (they fitted it into 1000 px); a PNG whose `pHYs` records a whole 2x–4x multiple of 72 dpi (macOS screenshots record 144 dpi) is placed at its point size, so an 800 × 600 px capture comes in at 400 × 300; other densities, such as 300 dpi scans, keep their pixel size; the image data stays at full resolution and an explicit width or height still wins. On macOS, paste reads the clipboard's PNG as is (its density and colour profile kept) and falls back to the decoded image. Numbers unchanged |
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
@@ -433,6 +435,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
| 2026-10-16 | minor | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List<Widget>?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. Numbers unchanged |
| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
+4
View File
@@ -366,6 +366,7 @@ pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
.enumerate()
.map(|(i, s)| {
let cmd = s.get("command").and_then(Value::as_str).ok_or_else(|| format!("step {i}: `command` (a string) is required"))?;
figcraft_engine::batch::refuse_unknown_step_keys(i, &s).map_err(|e| e.to_string())?;
let params = match s.get("params") {
None | Some(Value::Null) => json!({}),
Some(p @ Value::Object(_)) => p.clone(),
@@ -443,6 +444,9 @@ mod tests {
assert!(parse_script("[{\"command\":\"a\",\"params\":3}]").is_err());
assert!(parse_script("not json\n").is_err());
assert!(parse_script("42").is_err());
// `args` for `params` would run the step without them (#1413).
let e = parse_script(r#"[{"command":"a"},{"command":"b","args":{"x":1}}]"#).unwrap_err().to_string();
assert!(e.contains("step 1: unknown key `args`"), "{e}");
}
#[test]
+3
View File
@@ -48,6 +48,9 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "macos")'.dependencies]
fmv-macos-events = "=0.1.0"
# Paste reads the clipboard's public.png as is, keeping the screen density a screenshot records.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSString"] }
# A macOS app menu whose Quit (⌘Q) asks the app first (sends the root window a close request, the
# same guarded path as the close button); winit's default Quit ends the process through
# `terminate:`, losing unsaved changes without a word (issue #857).
+9
View File
@@ -327,6 +327,9 @@ fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> Dial
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
}
#[cfg(target_os = "macos")]
const MACOS_PASTEBOARD_PNG: &str = "public.png";
/// How long a clipboard read may wait on the app that copied (Linux and the BSDs).
#[cfg(all(unix, not(target_os = "macos")))]
const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
@@ -438,6 +441,12 @@ impl Services for Desktop {
}
Some((w, h, img.bytes.into_owned()))
}
#[cfg(target_os = "macos")]
fn clipboard_get_png(&mut self) -> Option<Vec<u8>> {
let board = objc2_app_kit::NSPasteboard::generalPasteboard();
let png = board.dataForType(&objc2_foundation::NSString::from_str(MACOS_PASTEBOARD_PNG))?;
(png.len() <= figcraft_ui_egui::MAX_CLIPBOARD_PNG_BYTES).then(|| png.to_vec())
}
fn open_url(&mut self, url: &str) {
let _ = webbrowser::open(url);
}
+3
View File
@@ -92,3 +92,6 @@ sync_github_name = true
[people.culiulemon]
sync_github_name = true
[people.StrajnarFilip]
display_name = "Filip Strajnar"
+56 -1
View File
@@ -2,7 +2,7 @@
//! picks the group, and a double click goes one level deeper than the selection.
use crate::geometry::{enter_target, select_target};
use crate::{BoolOp, Document, NodeId, NodeKind};
use crate::{BoolOp, Document, NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
use figcraft_geom::Point;
fn page(d: &Document) -> NodeId {
@@ -40,6 +40,61 @@ fn hits_reach_operands_but_clicks_pick_the_group() {
assert_eq!(listed, vec![f, g, b]);
}
#[test]
fn ordinary_hit_uses_region_paints_without_layer_fill() {
let mut d = Document::new("region hit");
let p = page(&d);
let network = VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(10.0, 0.0), Vertex::new(10.0, 10.0), Vertex::new(0.0, 10.0)],
segments: (0..4).map(|i| Segment::line(i, (i + 1) % 4)).collect(),
regions: vec![
Region {
loops: vec![vec![0, 1, 2, 3]],
winding: WindingRule::NonZero,
fills: Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]),
..Default::default()
},
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
],
};
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
d.get_mut(id).unwrap().fills.clear();
assert!(d.node(id).unwrap().fills.is_empty());
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id));
assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
}
#[test]
fn inherited_paint_frames_use_per_region_hit_testing() {
let mut d = Document::new("inherited region hit");
let p = page(&d);
let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true);
network.regions[0].winding = WindingRule::EvenOdd;
network.regions.push(network.regions[0].clone());
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), None, "legacy even-odd loops cancel");
let NodeKind::Vector { network } = &mut d.get_mut(id).unwrap().kind else { panic!("expected vector") };
network.regions[0].paint_space = Some(crate::RegionPaintSpace { width: 10.0, height: 10.0, transform: figcraft_geom::Affine::IDENTITY });
assert!(network.regions.iter().all(|region| region.fills.is_none()));
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id), "paint-framed regions inherit the node fill independently");
assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
}
#[test]
fn explicitly_unfilled_regions_keep_the_stroke_only_selection_band() {
let mut d = Document::new("stroke selection");
let p = page(&d);
let mut network =
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
network.regions[0].fills = Some(Vec::new());
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
let n = d.get_mut(id).unwrap();
n.fills.clear();
n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
n.stroke.weight = 1.0;
assert_eq!(d.hit_test(p, Point::new(50.0, -4.0), 2.0, None), Some(id));
}
#[test]
fn double_clicks_go_one_level_deeper() {
let (d, f, g, b, r1, _) = setup();
+3
View File
@@ -60,6 +60,9 @@ impl Node {
f(&mut prototype.background);
}
NodeKind::Text(t) => t.for_each_color_mut(f),
NodeKind::Vector { network } => {
network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten().for_each(|p| p.for_each_color_mut(f))
}
_ => {}
}
for it in &mut self.interactions {
+17
View File
@@ -74,6 +74,23 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
n.local_rect().inflate(tol_l, tol_l).contains(lp)
}
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
if !outline {
for (i, region) in net.regions.iter().enumerate().rev() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
if paints.iter().any(|p| p.visible) && net.region_contains(i, lp) {
return true;
}
}
}
let path = net.stroke_path();
let has_fill = net.regions.iter().any(|region| region.fills.as_deref().unwrap_or(&n.fills).iter().any(|paint| paint.visible));
let stroke_only = n.has_visible_stroke() && !has_fill;
let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol_l * STROKE_REACH } else { tol_l });
path.bounding_box().inflate(reach, reach).contains(lp) && near_path(&path, lp, reach)
}
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))),
}
}
+9
View File
@@ -915,6 +915,15 @@ impl Document {
pt.source = m.0;
}
}
if let NodeKind::Vector { network } = &mut c.kind {
for p in network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten() {
if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind
&& let Some(m) = map.get(&NodeId(pt.source))
{
pt.source = m.0;
}
}
}
self.reserve_ids(*new_id);
self.nodes.insert(*new_id, Arc::new(c));
self.dirty.touch(*new_id);
+5
View File
@@ -300,6 +300,11 @@ pub fn paint_images<'a>(paints: impl IntoIterator<Item = &'a Paint>, out: &mut B
/// Image hashes a node paints (fills, strokes, text and text run fills).
pub fn node_images(n: &Node, out: &mut BTreeSet<String>) {
paint_images(n.fills.iter().chain(&n.strokes), out);
if let NodeKind::Vector { network } = &n.kind {
for r in &network.regions {
paint_images(r.fills.iter().flatten(), out);
}
}
if let NodeKind::Text(t) = &n.kind {
paint_images(t.style.fills.iter().flatten(), out);
for r in &t.runs {
+1 -1
View File
@@ -222,7 +222,7 @@ pub struct Annotation {
/// (`figcraft_engine::code_connect`).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeLink {
/// `react`, `swiftui` or `compose`.
/// `react`, `leptos`, `vue`, `swiftui`, `compose`, `arkui` or `flutter`.
pub language: String,
/// What the snippet imports from (`@acme/ui`, a Swift module, a Kotlin name).
#[serde(default, skip_serializing_if = "String::is_empty")]
+31 -3
View File
@@ -125,6 +125,34 @@ fn hit_test_respects_z_and_clip() {
assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0, None), Some(f));
}
#[test]
fn region_paints_participate_in_colors_images_and_pattern_remapping() {
let mut d = Document::new("region resources");
let p = page(&d);
let group = d.create(p, NodeKind::Group, "Group", 0.0, 0.0, 40.0, 20.0).unwrap();
let source = d.create(group, NodeKind::Rectangle, "Source", 0.0, 0.0, 4.0, 4.0).unwrap();
let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)], true);
network.regions[0].fills = Some(vec![
figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FF0000").unwrap()),
figcraft_color::Paint::image("region-image", figcraft_color::ImageScaleMode::Fill),
figcraft_color::Paint::pattern(source.0),
]);
let vector = d.create(group, NodeKind::Vector { network }, "Vector", 0.0, 0.0, 20.0, 20.0).unwrap();
assert!(used_images(&d).contains("region-image"));
let dup = d.duplicate_into(group, p, None).unwrap();
let copied_children = d.node(dup).unwrap().children.clone();
let copied_source = copied_children[0];
let copied_vector = d.node(copied_children[1]).unwrap();
let NodeKind::Vector { network } = &copied_vector.kind else { panic!() };
let figcraft_color::PaintKind::Pattern(pattern) = &network.regions[0].fills.as_ref().unwrap()[2].kind else { panic!() };
assert_eq!(pattern.source, copied_source.0);
let red = figcraft_color::Color::from_hex("FF0000").unwrap();
d.map_colors(|c| if c == red { figcraft_color::Color::BLACK } else { c });
let NodeKind::Vector { network } = &d.node(vector).unwrap().kind else { panic!() };
assert_eq!(network.regions[0].fills.as_ref().unwrap()[0].solid_color(), Some(figcraft_color::Color::BLACK));
}
#[test]
fn click_target_rules() {
let mut d = Document::new("x");
@@ -212,7 +240,7 @@ fn vector_network_paths() {
let mut bad = VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0)],
segments: vec![Segment::line(0, 5)],
regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd }],
regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd, fills: None, ..Default::default() }],
};
bad.sanitize();
assert!(bad.segments.is_empty());
@@ -534,7 +562,7 @@ fn hit_test_even_odd_vector_hole_misses() {
VectorNetwork {
vertices: v1.into_iter().chain(v2).collect(),
segments: s1.into_iter().chain(s2).collect(),
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }],
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule, ..Default::default() }],
}
};
let mut d = Document::new("x");
@@ -637,7 +665,7 @@ fn two_segment_closed_loop_rounded_corners() {
let net = VectorNetwork {
vertices: vec![v0, v1],
segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }],
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }],
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() }],
};
let r = net.rounded().unwrap();
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
+48 -3
View File
@@ -1,8 +1,9 @@
//! Vector networks: vertices joined by curved segments, any number of segments per vertex, plus
//! filled regions made of closed loops.
use figcraft_color::Paint;
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv};
use figcraft_geom::{BezPath, Point, Vec2};
use figcraft_geom::{Affine, BezPath, Point, Vec2};
use serde::{Deserialize, Serialize};
use crate::{StrokeCap, StrokeJoin};
@@ -88,12 +89,44 @@ pub enum WindingRule {
EvenOdd,
}
/// The paint coordinate frame retained when a region moves or its vector is refit.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct RegionPaintSpace {
/// Width of the box that defines paint coordinates.
pub width: f64,
/// Height of the box that defines paint coordinates.
pub height: f64,
/// Maps paint-local coordinates into this network's local coordinates.
pub transform: Affine,
}
/// A filled region: loops of segment indices.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct Region {
pub loops: Vec<Vec<usize>>,
#[serde(default)]
pub winding: WindingRule,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fills: Option<Vec<Paint>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paint_space: Option<RegionPaintSpace>,
}
impl Region {
/// Paint box and transform, falling back to the current node box for old or invalid metadata.
pub fn paint_frame(&self, width: f64, height: f64) -> (f64, f64, Affine) {
let Some(frame) = self.paint_space else { return (width, height, Affine::IDENTITY) };
let coefficients = frame.transform.as_coeffs();
let valid = frame.width.is_finite()
&& frame.height.is_finite()
&& frame.width > 0.0
&& frame.height > 0.0
&& coefficients.iter().all(|value| value.is_finite())
&& frame.transform.determinant().is_finite()
&& frame.transform.determinant() != 0.0
&& frame.transform.inverse().as_coeffs().iter().all(|value| value.is_finite());
if valid { (frame.width, frame.height, frame.transform) } else { (width, height, Affine::IDENTITY) }
}
}
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again
@@ -153,7 +186,7 @@ impl VectorNetwork {
let mut regions = Vec::new();
if closed && n > 2 {
segments.push(Segment::line(n - 1, 0));
regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero });
regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero, fills: None, paint_space: None });
}
Self { vertices, segments, regions }
}
@@ -242,7 +275,7 @@ impl VectorNetwork {
}
close_open(&mut net, &mut loop_segs, &mut all_loops, first_v, cur_v);
if !all_loops.is_empty() {
net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero });
net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero, fills: None, paint_space: None });
}
net
}
@@ -637,6 +670,12 @@ impl VectorNetwork {
s.ta = (s.ta.0 * sx, s.ta.1 * sy);
s.tb = (s.tb.0 * sx, s.tb.1 * sy);
}
let scale = Affine::scale_non_uniform(sx, sy);
for region in &mut self.regions {
if let Some(frame) = &mut region.paint_space {
frame.transform = scale * frame.transform;
}
}
}
pub fn translate(&mut self, dx: f64, dy: f64) {
@@ -644,6 +683,12 @@ impl VectorNetwork {
v.x += dx;
v.y += dy;
}
let translate = Affine::translate((dx, dy));
for region in &mut self.regions {
if let Some(frame) = &mut region.paint_space {
frame.transform = translate * frame.transform;
}
}
}
/// Bounds of the curves.
+53 -8
View File
@@ -30,7 +30,12 @@ impl Face {
/// The fill region for this face.
pub fn region(&self) -> Region {
Region { loops: self.loops.clone(), winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero } }
Region {
loops: self.loops.clone(),
winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero },
fills: None,
paint_space: None,
}
}
/// Some point strictly inside the face (not in a hole).
@@ -115,7 +120,13 @@ impl VectorNetwork {
/// Like [`Self::paint`], but with `want` the face ends up filled (`Some(true)`) or unfilled
/// (`Some(false)`) whatever it was: a paint bucket drag keeps doing what its first click did.
pub fn paint_to(&mut self, p: Point, want: Option<bool>) -> Option<bool> {
self.paint_to_with(p, want, None)
}
/// Like [`Self::paint_to`], with the layer fill stack used for inherited regions.
pub fn paint_to_with(&mut self, p: Point, want: Option<bool>, inherited: Option<&[figcraft_color::Paint]>) -> Option<bool> {
let face = self.face_at(p)?;
let legacy = self.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
let key = |loops: &[Vec<usize>]| -> Vec<Vec<usize>> {
let mut k: Vec<Vec<usize>> = loops
.iter()
@@ -131,18 +142,52 @@ impl VectorNetwork {
let faces = self.faces();
let face_keys: Vec<Vec<Vec<usize>>> = faces.iter().map(|f| key(&f.loops)).collect();
if !self.regions.iter().all(|r| face_keys.contains(&key(&r.loops))) {
let filled: Vec<Region> = faces.iter().filter(|f| f.interior_point().is_some_and(|q| self.fill_contains(q))).map(Face::region).collect();
self.regions = filled;
if legacy {
// Legacy vectors use one combined fill rule across all regions.
self.regions =
faces.iter().filter(|face| face.interior_point().is_some_and(|point| self.fill_contains(point))).map(Face::region).collect();
} else {
// Split each styled source region independently to retain its stack and frame.
let source_regions = self.regions.clone();
let mut filled = Vec::new();
for (source_index, source) in source_regions.into_iter().enumerate() {
let source_path = self.region_path(source_index);
for face in &faces {
if face.interior_point().is_some_and(|q| {
source_path.as_ref().is_some_and(|path| match source.winding {
WindingRule::NonZero => path.winding(q) != 0,
WindingRule::EvenOdd => path.winding(q) % 2 != 0,
})
}) {
let mut region = face.region();
region.fills = source.fills.clone();
region.paint_space = source.paint_space;
filled.push(region);
}
}
}
self.regions = filled;
}
}
let k = key(&face.loops);
match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
(Some(_), None | Some(false)) => {
let filled = if legacy {
self.fill_contains(p) && inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible))
} else {
self.regions.iter().filter(|r| key(&r.loops) == k).any(|region| match &region.fills {
Some(fills) => fills.iter().any(|paint| paint.visible),
None => inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible)),
})
};
let want = want.unwrap_or(!filled);
match (filled, want) {
(true, false) => {
self.regions.retain(|r| key(&r.loops) != k);
Some(false)
}
(Some(_), Some(true)) => Some(true),
(None, Some(false)) => Some(false),
(None, _) => {
(true, true) => Some(true),
(false, false) => Some(false),
(false, true) => {
self.regions.retain(|r| key(&r.loops) != k);
self.regions.push(face.region());
Some(true)
}
+26 -1
View File
@@ -30,7 +30,32 @@ fn nested() -> VectorNetwork {
}
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
Region { loops, winding }
Region { loops, winding, fills: None, ..Default::default() }
}
#[test]
fn region_fills_default_to_inheritance_and_keep_explicit_empty() {
let legacy: Region = serde_json::from_value(serde_json::json!({"loops": []})).unwrap();
assert_eq!(legacy.fills, None);
let empty = Region { fills: Some(Vec::new()), ..legacy };
let value = serde_json::to_value(empty).unwrap();
assert!(value["fills"].as_array().is_some_and(Vec::is_empty));
let inherited = serde_json::to_value(region(Vec::new(), WindingRule::NonZero)).unwrap();
assert!(inherited.get("fills").is_none());
}
#[test]
fn region_paint_space_roundtrips_and_rejects_nonfinite_inverse() {
use crate::RegionPaintSpace;
use figcraft_geom::Affine;
let mut r = region(Vec::new(), WindingRule::EvenOdd);
assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
r.paint_space = Some(RegionPaintSpace { width: 30.0, height: 20.0, transform: Affine::translate((4.0, 8.0)) });
let decoded: Region = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
assert_eq!(decoded, r);
assert_eq!(decoded.paint_frame(100.0, 40.0), (30.0, 20.0, Affine::translate((4.0, 8.0))));
r.paint_space.as_mut().unwrap().transform = Affine::scale_non_uniform(1e-320, 1.0);
assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
}
fn p(x: f64, y: f64) -> Point {
+44 -1
View File
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{CellAlign, MainAlign, NodeId, Sizing};
use figcraft_doc::{CellAlign, CrossAlign, LayoutMode, MainAlign, NodeId, Sizing};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
@@ -233,3 +233,46 @@ fn a_size_given_with_a_new_path_fixes_that_axis() {
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
}
/// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or
/// an object that replaces the whole auto layout) applies first and the other keys tune it.
#[test]
fn auto_layout_keys_in_one_call_tune_the_mode_given_beside_them() {
for (key, mode) in ["layout", "layoutMode"].into_iter().flat_map(|k| [(k, json!("vertical")), (k, json!({"mode": "vertical"}))]) {
let mut props = json!({"gap": 8, "padding": 4, "crossAlign": "center"});
props[key] = mode;
let mut s = Session::new();
let created = id(s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap());
let set_on = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
set(&mut s, set_on, props.clone());
for f in [created, set_on] {
let l = s.doc().unwrap().node(f).unwrap().layout.clone().unwrap();
assert_eq!((l.mode, l.gap, l.padding, l.cross_align), (LayoutMode::Vertical, 8.0, [4.0; 4], CrossAlign::Center), "{props}");
}
}
}
/// Auto layout keys beside a `layout` that turns auto layout off would turn it back on: the call
/// is refused and the layer stays as it was.
#[test]
fn auto_layout_keys_beside_no_auto_layout_are_refused() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
for props in
[json!({"layout": null, "padding": 4}), json!({"layout": "none", "gap": 8, "padding": 4}), json!({"layoutMode": "NONE", "wrap": true})]
{
let e = s.execute("node.set", &json!({"id": f, "props": props})).unwrap_err().to_string();
assert!(e.contains("turns auto layout off"), "{props}: {e}");
assert!(s.doc().unwrap().node(f).unwrap().layout.is_none(), "{props}");
let e = s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap_err().to_string();
assert!(e.contains("turns auto layout off"), "{props}: {e}");
}
let e = s.execute("node.set", &json!({"id": f, "props": {"layout": null, "gap": 8, "padding": 4}})).unwrap_err().to_string();
assert!(e.contains("`gap`, `padding` are auto layout properties, and `layout` in the same call"), "{e}");
let e = s.execute("node.set", &json!({"id": f, "props": {"layoutMode": "NONE", "wrap": true}})).unwrap_err().to_string();
assert!(e.contains("`wrap` is an auto layout property, and `layoutMode` in the same call"), "{e}");
// Without other auto layout keys, `layout: null` still removes it.
set(&mut s, f, json!({"layout": "vertical", "gap": 8}));
set(&mut s, f, json!({"layout": null, "name": "Plain"}));
assert!(s.doc().unwrap().node(f).unwrap().layout.is_none());
}
+2 -2
View File
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"component.playground",
"Component playground",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui|flutter (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
false,
|s, p| {
let cmd = "component.playground";
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
};
let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui"))?,
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui|flutter"))?,
None => CodeLang::React,
};
let (copy, inst) = build(s.doc()?, target, &values)?;
+12
View File
@@ -39,6 +39,18 @@ pub fn step_name(step: usize, json: &Value) -> Result<Option<String>> {
}
}
/// Refuse a step key other than `command`, `params` and `as` (`step` is its index, for the
/// error): `args` in place of `params` would run the command without them.
pub fn refuse_unknown_step_keys(step: usize, json: &Value) -> Result<()> {
let Some(o) = json.as_object() else { return Ok(()) };
let unknown: Vec<String> = o.keys().filter(|k| !matches!(k.as_str(), "command" | "params" | "as")).map(|k| format!("`{k}`")).collect();
if unknown.is_empty() {
return Ok(());
}
let s = if unknown.len() == 1 { "" } else { "s" };
Err(bad("command.batch", format!("step {step}: unknown key{s} {} (a step takes command, params and as)", unknown.join(", "))))
}
/// The steps of one batch run so far: their results, and which step each `as` name belongs to.
/// A nested `command.batch` step gets its own scope, so its references are to its own steps.
#[derive(Clone, Debug, Default)]
+140 -20
View File
@@ -2,9 +2,10 @@
//!
//! The selected shapes' outlines split the plane into **regions** (the faces of their arrangement):
//! connected areas covered by the same shapes. Merging regions makes one new vector from them,
//! styled like the top-most shape covering the first region; removing regions deletes them. Either
//! way every shape covering a picked region loses that area (it becomes a vector, or is deleted
//! when nothing is left), so the rest of the drawing stays exactly where it was.
//! styled like the top-most fill or stroke covering the first selected region (using that vector
//! region's paints when applicable); removing regions deletes them. Either way every shape covering
//! a picked region loses that area (it becomes a vector, or is deleted when nothing is left), so
//! the rest of the drawing stays exactly where it was.
//!
//! **Strokes and open paths.** A visible stroke takes part as its outlined area (a [`Part`] of its
//! own, above the shape's fill), so stroked lines and open paths split the regions they cross and
@@ -37,6 +38,9 @@ use crate::{EngineError, Result, Session, bad, spec};
pub struct Part {
pub id: NodeId,
pub stroke: bool,
/// The source vector region for a fill (`None` for ordinary fills and strokes).
#[serde(skip)]
pub region: Option<usize>,
}
/// One region of the arrangement, in page coordinates.
@@ -126,7 +130,16 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
if !(figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_))) {
return None;
}
figcraft_render::node_outline(doc, n)?
match (&n.kind, part.region) {
(NodeKind::Vector { network }, Some(i)) => {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let region = net.regions.get(i)?;
let path = net.region_path(i)?;
(path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
}
_ => figcraft_render::node_outline(doc, n)?,
}
};
p.apply_affine(doc.world_transform(part.id));
(!p.elements().is_empty() && figcraft_pathops::finite(&p)).then_some((p, rule == figcraft_render::FillRule::EvenOdd))
@@ -155,10 +168,17 @@ fn compute(doc: Arc<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement>
let mut inputs = Vec::new();
for id in eligible(&doc, ids) {
let before = parts.len();
for part in [Part { id, stroke: false }, Part { id, stroke: true }] {
if inputs.len() < figcraft_pathops::MAX_ARRANGEMENT_INPUTS
&& let Some(o) = world_area(&doc, part)
{
let fill_parts: Vec<Part> = match &doc.get(id)?.kind {
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
network.regions.iter().enumerate().map(|(region, _)| Part { id, stroke: false, region: Some(region) }).collect()
}
_ => vec![Part { id, stroke: false, region: None }],
};
for part in fill_parts.into_iter().chain([Part { id, stroke: true, region: None }]) {
if let Some(o) = world_area(&doc, part) {
if inputs.len() >= figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
return Err(EngineError::Other("shape builder: too many fill regions and strokes to process safely".into()));
}
parts.push(part);
inputs.push(o);
}
@@ -253,6 +273,68 @@ fn network_from(path: &BezPath, to_local: Affine) -> Option<VectorNetwork> {
Some(net)
}
fn append_network(into: &mut VectorNetwork, mut part: VectorNetwork) {
let (vertices, segments) = (into.vertices.len(), into.segments.len());
for segment in &mut part.segments {
segment.a += vertices;
segment.b += vertices;
}
for region in &mut part.regions {
for loop_segments in &mut region.loops {
for segment in loop_segments {
*segment += segments;
}
}
}
into.vertices.extend(part.vertices);
into.segments.extend(part.segments);
into.regions.extend(part.regions);
}
fn paint_frame(region: Option<&figcraft_doc::Region>, node: &Node) -> (f64, f64, Affine) {
region.map_or((node.width, node.height, Affine::IDENTITY), |r| r.paint_frame(node.width, node.height))
}
/// Rebuild `id` from each remaining source region, retaining region order, paint stacks and frames.
fn reshape_vector_regions(doc: &mut Document, id: NodeId, areas: &[(usize, BezPath)]) -> Result<()> {
let old = doc.get(id)?.clone();
let (world, network) = match &old.kind {
NodeKind::Vector { network } => (doc.world_transform(id), network),
_ => return Err(EngineError::Other("expected a vector with regions".into())),
};
if !(world.determinant().is_finite() && world.determinant().abs() > 1e-12) {
return Err(EngineError::Other("can't reshape a layer scaled to nothing".into()));
}
let mut rebuilt = VectorNetwork::default();
for (index, area) in areas {
let Some(source) = network.regions.get(*index) else { continue };
let Some(mut part) = network_from(area, world.inverse()) else { return Err(EngineError::Other("invalid geometry".into())) };
if part.regions.is_empty() {
continue;
}
let (width, height, frame) = paint_frame(Some(source), &old);
for region in &mut part.regions {
region.fills = source.fills.clone();
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width, height, transform: frame });
}
append_network(&mut rebuilt, part);
}
let Some(bounds) = rebuilt.bounds() else {
let n = doc.get_mut(id)?;
n.kind = NodeKind::Vector { network: rebuilt };
return Ok(());
};
rebuilt.translate(-bounds.x0, -bounds.y0);
let n = doc.get_mut(id)?;
n.kind = NodeKind::Vector { network: rebuilt };
n.children.clear();
n.corners = Corners::default();
n.transform *= Affine::translate((bounds.x0, bounds.y0));
n.width = bounds.width();
n.height = bounds.height();
Ok(())
}
/// Replace shape `id`'s geometry with the page-space area `area`, keeping its transform and style.
fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
let w = doc.world_transform(id);
@@ -273,10 +355,16 @@ fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
crate::vectoredit::refit(doc, id)
}
/// A new vector for the page-space area `area`, styled like part `style` (a stroke part: filled
/// with the stroke's paints), just above its shape.
/// A new vector for page-space `area`, styled like `style` (a stroke part: filled with stroke
/// paints), just above its shape. Vector region paints and their original frame stay attached.
fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<NodeId> {
let mut src = doc.get(style.id)?.clone();
let source_region = match (&src.kind, style.region) {
(NodeKind::Vector { network }, Some(i)) => network.regions.get(i),
_ => None,
};
let (paint_width, paint_height, source_paint_frame) = paint_frame(source_region, &src);
let region_fills = source_region.and_then(|r| r.fills.clone());
if style.stroke {
src.fills = std::mem::take(&mut src.strokes);
src.stroke = StrokeProps::default();
@@ -291,6 +379,12 @@ fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<Node
let mut net = network_from(area, pw.inverse()).ok_or_else(|| EngineError::Other("invalid geometry".into()))?;
let bb = net.bounds().ok_or_else(|| EngineError::Other("empty region".into()))?;
net.translate(-bb.x0, -bb.y0);
let to_world = pw * Affine::translate((bb.x0, bb.y0));
let paint_transform = to_world.inverse() * doc.world_transform(style) * source_paint_frame;
for region in &mut net.regions {
region.fills = region_fills.clone();
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
}
let id = doc.alloc_id();
let mut v = Node::new(id, NodeKind::Vector { network: net }, src.name.clone(), bb.width(), bb.height());
v.transform = Affine::translate((bb.x0, bb.y0));
@@ -321,7 +415,9 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
// Merging every region of a single shape would only replace it with a copy of itself.
let whole_shape =
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
if merge && whole_shape {
// A vector's distinct region paints are separate fill parts that merging does unify.
let fill_parts = touched_parts.iter().filter(|part| !part.stroke).count();
if merge && whole_shape && fill_parts <= 1 {
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
}
let doc = s.doc_mut()?;
@@ -347,7 +443,8 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
let mut changed = Vec::new();
let mut outlined: Vec<(NodeId, NodeId)> = Vec::new();
for src in a.sources.iter().copied().filter(|i| touched.contains(i)) {
let (fill, stroke) = (Part { id: src, stroke: false }, Part { id: src, stroke: true });
let stroke = Part { id: src, stroke: true, region: None };
let fill_parts: Vec<Part> = a.parts.iter().copied().filter(|part| part.id == src && !part.stroke).collect();
// The stroke becomes an outline of its own (what's left of it), the shape loses it.
if a.parts.contains(&stroke) {
if let Some(area) = kept(stroke)? {
@@ -357,16 +454,35 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
n.strokes.clear();
n.styles.stroke = None;
}
let left = if !a.parts.contains(&fill) {
None
} else if touched_parts.contains(&fill) {
kept(fill)?
} else {
if fill_parts.is_empty() {
doc.remove(src)?;
removed.push(src);
continue;
}
let touched_fills: Vec<Part> = fill_parts.iter().copied().filter(|part| touched_parts.contains(part)).collect();
if touched_fills.is_empty() {
// The fill wasn't picked: the shape stays as it is.
changed.push(src);
continue;
};
match left {
}
if fill_parts.iter().any(|part| part.region.is_some()) {
let mut areas = Vec::new();
for part in &fill_parts {
let Some(region) = part.region else { continue };
if let Some(area) = kept(*part)? {
areas.push((region, area));
}
}
if areas.is_empty() {
doc.remove(src)?;
removed.push(src);
} else {
reshape_vector_regions(doc, src, &areas)?;
changed.push(src);
}
continue;
}
match kept(fill_parts[0])? {
Some(area) => {
reshape(doc, src, &area)?;
changed.push(src);
@@ -427,7 +543,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"shapeBuilder.merge",
"Merge regions",
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first; the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first selected region (using its vector-region paints); the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
true,
|s, p| apply(s, "shapeBuilder.merge", p, true)
),
@@ -464,6 +580,10 @@ pub fn key(s: &mut Session, name: &str) -> Result<bool> {
Ok(true)
}
#[cfg(test)]
#[path = "shapebuilder_region_paints_tests.rs"]
mod region_paints_tests;
impl Session {
/// The Shape builder tool: hover highlights a region, click/drag collects regions, release
/// merges (or removes, with Alt or in Remove mode) them as one undo step. Clicking outside
@@ -0,0 +1,307 @@
use figcraft_color::{Color, Gradient, GradientKind, Paint};
use figcraft_doc::{NodeId, NodeKind, Region, VectorNetwork, WindingRule};
use figcraft_geom::{Affine, Point, Shape};
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::shapebuilder::arrangement;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn rect(s: &mut Session, x: f64, y: f64, width: f64, height: f64, fill: &str) -> NodeId {
id(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": width, "height": height, "props": {"fill": fill}})))
}
fn append(into: &mut VectorNetwork, mut part: VectorNetwork) {
let (vertices, segments) = (into.vertices.len(), into.segments.len());
for segment in &mut part.segments {
segment.a += vertices;
segment.b += vertices;
}
for region in &mut part.regions {
for lp in &mut region.loops {
for segment in lp {
*segment += segments;
}
}
}
into.vertices.extend(part.vertices);
into.segments.extend(part.segments);
into.regions.extend(part.regions);
}
fn box_path(x0: f64, y0: f64, x1: f64, y1: f64) -> VectorNetwork {
VectorNetwork::from_points(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
}
fn split_vector(s: &mut Session) -> NodeId {
let id = rect(s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
let mut network = VectorNetwork::default();
append(&mut network, box_path(0.0, 0.0, 50.0, 100.0));
append(&mut network, box_path(50.0, 0.0, 100.0, 100.0));
network.regions[0].fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]);
network.regions[1].fills = Some(vec![Paint::solid(Color::from_hex("#0000ff").unwrap())]);
s.doc_mut().unwrap().get_mut(id).unwrap().kind = NodeKind::Vector { network };
id
}
fn network(s: &Session, id: NodeId) -> &VectorNetwork {
let NodeKind::Vector { network } = &s.doc().unwrap().node(id).unwrap().kind else { panic!("expected vector") };
// This helper's borrow is tied to the session document.
network
}
fn world_region_area(s: &Session, id: NodeId, index: usize) -> f64 {
let doc = s.doc().unwrap();
let mut path = network(s, id).region_path(index).unwrap();
path.apply_affine(doc.world_transform(id));
path.area().abs()
}
fn normalized_paint_world(s: &Session, id: NodeId, index: usize) -> Affine {
let doc = s.doc().unwrap();
let node = doc.node(id).unwrap();
let (width, height, transform) = network(s, id).regions[index].paint_frame(node.width, node.height);
doc.world_transform(id) * transform * Affine::scale_non_uniform(width, height)
}
fn assert_same_transform(a: Affine, b: Affine) {
for (x, y) in a.as_coeffs().into_iter().zip(b.as_coeffs()) {
assert!((x - y).abs() < 1e-8, "paint frame changed: {a:?} vs {b:?}");
}
}
#[test]
fn arrangement_keeps_internal_paint_boundaries_when_another_shape_crosses_them() {
let mut s = Session::new();
let vector = split_vector(&mut s);
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
s.set_selection(vec![vector, crossing]);
let a = arrangement(&mut s, &[vector, crossing]).unwrap();
let left = a.region_at(Point::new(35.0, 50.0)).unwrap();
let right = a.region_at(Point::new(65.0, 50.0)).unwrap();
assert_ne!(left, right, "the old paint boundary must split the crossing shape's overlap");
assert!(a.regions[left].parts.iter().any(|p| p.id == vector && p.region == Some(0)));
assert!(a.regions[right].parts.iter().any(|p| p.id == vector && p.region == Some(1)));
}
#[test]
fn legacy_unstyled_nested_evenodd_regions_keep_the_hole_unpainted() {
let mut s = Session::new();
let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
let mut net = box_path(0.0, 0.0, 100.0, 100.0);
let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
append(&mut net, inner);
for region in &mut net.regions {
region.winding = WindingRule::EvenOdd;
region.fills = None;
region.paint_space = None;
}
s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net };
let crossing = rect(&mut s, -5.0, 45.0, 15.0, 10.0, "#ffffff");
s.set_selection(vec![vector, crossing]);
let a = arrangement(&mut s, &[vector, crossing]).unwrap();
assert!(a.region_at(Point::new(50.0, 50.0)).is_none(), "the legacy combined even-odd fill leaves its nested center empty");
ex(&mut s, "shapeBuilder.remove", json!({"points": [[5, 50]]}));
let NodeKind::Vector { network } = &s.doc().unwrap().node(vector).unwrap().kind else { panic!("expected vector") };
assert!(!network.fill_contains(Point::new(50.0, 50.0)), "the nested hole survives removal");
assert!(network.fill_contains(Point::new(85.0, 50.0)), "unselected outer area remains filled");
}
#[test]
fn merge_all_painted_regions_uses_the_first_picked_region_style() {
let mut s = Session::new();
let vector = split_vector(&mut s);
s.set_selection(vec![vector]);
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
let merged = id(&result);
assert!(!s.doc().unwrap().contains(vector));
assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
assert!((world_region_area(&s, merged, 0) - 10_000.0).abs() < 0.1);
s.undo().unwrap();
assert_eq!(network(&s, vector).regions.len(), 2);
}
#[test]
fn merge_all_inherited_gradient_regions_with_distinct_frames_is_not_a_noop() {
let mut s = Session::new();
let vector = split_vector(&mut s);
{
let doc = s.doc_mut().unwrap();
let node = doc.get_mut(vector).unwrap();
node.fills = vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))];
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
for (region, offset) in network.regions.iter_mut().zip([0.0, 12.0]) {
region.fills = None;
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::translate((offset, 0.0)) });
}
}
let before_a = normalized_paint_world(&s, vector, 0);
let before_b = normalized_paint_world(&s, vector, 1);
assert_ne!(before_a, before_b, "source regions use distinct inherited-gradient frames");
s.set_selection(vec![vector]);
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
let merged = id(&result);
assert!(!s.doc().unwrap().contains(vector), "the selected regions are merged into a new vector");
assert_eq!(network(&s, merged).regions.len(), 1);
assert_eq!(network(&s, merged).regions[0].fills, None, "node gradient remains inherited");
assert!(matches!(s.doc().unwrap().node(merged).unwrap().fills[0].kind, figcraft_color::PaintKind::Gradient(_)));
assert_same_transform(normalized_paint_world(&s, merged, 0), before_a);
}
#[test]
fn exceeding_arrangement_capacity_never_drops_unprocessed_regions() {
let mut s = Session::new();
let vector = split_vector(&mut s);
let mut net = VectorNetwork::default();
for i in 0..=figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
let mut part = box_path(i as f64 * 12.0, 0.0, i as f64 * 12.0 + 10.0, 10.0);
part.regions[0].fills = Some(vec![Paint::solid(Color::BLACK)]);
append(&mut net, part);
}
s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net.clone() };
s.set_selection(vec![vector]);
let error = s.execute("shapeBuilder.remove", &json!({"points": [[5, 5]]})).unwrap_err();
assert!(error.to_string().contains("too many fill regions"));
assert_eq!(network(&s, vector), &net);
}
#[test]
fn remove_changes_only_the_picked_painted_face() {
let mut s = Session::new();
let vector = split_vector(&mut s);
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
s.set_selection(vec![vector, crossing]);
ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
assert!((world_region_area(&s, vector, 0) - 2500.0).abs() < 0.1);
assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "the untouched blue region keeps its area");
let net = network(&s, vector);
assert_eq!(net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
assert_eq!(net.regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
}
#[test]
fn reshape_keeps_inherited_and_explicitly_empty_region_fills_distinct() {
let mut s = Session::new();
let vector = split_vector(&mut s);
{
let doc = s.doc_mut().unwrap();
let NodeKind::Vector { network } = &mut doc.get_mut(vector).unwrap().kind else { panic!("expected vector") };
network.regions[0].fills = Some(Vec::new());
network.regions[1].fills = None;
}
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
s.set_selection(vec![vector, crossing]);
ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
assert_eq!(network(&s, vector).regions[0].fills, Some(Vec::new()));
assert_eq!(network(&s, vector).regions[1].fills, None);
assert_eq!(s.doc().unwrap().node(vector).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
}
#[test]
fn merge_uses_selected_region_paints_and_retains_other_region_stack() {
let mut s = Session::new();
// Create crossing first, then vector above it so vector region supplies merge appearance.
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
let vector = split_vector(&mut s);
s.set_selection(vec![crossing, vector]);
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[35, 50]]}));
let merged = id(&result);
let merged_net = network(&s, merged);
assert_eq!(merged_net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "unselected blue region survives");
assert_eq!(network(&s, vector).regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
assert!((world_region_area(&s, merged, 0) - 2500.0).abs() < 0.1);
assert!(s.undo().unwrap());
assert_eq!(network(&s, vector).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
assert!(s.redo().unwrap());
assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
}
#[test]
fn merged_region_keeps_a_transformed_source_gradient_frame() {
let mut s = Session::new();
let crossing = rect(&mut s, 0.0, 0.0, 300.0, 200.0, "#ffffff");
let vector = split_vector(&mut s);
let mut transform = Affine::translate((100.0, 20.0)) * Affine::rotate(0.2);
transform *= Affine::scale_non_uniform(1.2, 0.8);
{
let doc = s.doc_mut().unwrap();
let node = doc.get_mut(vector).unwrap();
node.transform = transform;
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
network.regions[0].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]);
}
let before = normalized_paint_world(&s, vector, 0);
let point = s.doc().unwrap().world_transform(vector) * Point::new(35.0, 50.0);
s.set_selection(vec![crossing, vector]);
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[point.x, point.y]]}));
let merged = id(&result);
assert!(matches!(&network(&s, merged).regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
assert_same_transform(before, normalized_paint_world(&s, merged, 0));
}
#[test]
fn removal_preserves_holes_and_transformed_gradient_frames() {
let mut s = Session::new();
let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
let mut outer = box_path(0.0, 0.0, 100.0, 100.0);
let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
let outer_loop = outer.regions[0].loops[0].clone();
let inner_loop: Vec<usize> = inner.regions[0].loops[0].iter().map(|i| i + outer.segments.len()).collect();
append(&mut outer, inner);
outer.regions = vec![Region {
loops: vec![outer_loop, inner_loop],
winding: WindingRule::EvenOdd,
fills: Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]),
paint_space: None,
}];
let transform = Affine::translate((120.0, 20.0)) * Affine::rotate(0.2);
{
let doc = s.doc_mut().unwrap();
let node = doc.get_mut(vector).unwrap();
node.kind = NodeKind::Vector { network: outer };
node.transform = transform;
}
let before = normalized_paint_world(&s, vector, 0);
let world = s.doc().unwrap().world_transform(vector) * Point::new(10.0, 50.0);
let overlay = rect(&mut s, world.x - 5.0, world.y - 5.0, 10.0, 10.0, "#ffffff");
s.set_selection(vec![overlay, vector]);
ex(&mut s, "shapeBuilder.remove", json!({"points": [[world.x, world.y]]}));
let net = network(&s, vector);
assert_eq!(net.regions.len(), 1);
assert_eq!(net.regions[0].loops.len(), 3, "the original hole and removed face stay separate loops");
assert!(!net.region_contains(0, Point::new(50.0, 50.0)), "the hole remains empty");
assert!(!net.region_contains(0, Point::new(10.0, 50.0)), "the removed face remains empty");
assert!(net.region_contains(0, Point::new(90.0, 50.0)), "the unselected ring remains filled");
assert_same_transform(before, normalized_paint_world(&s, vector, 0));
}
#[test]
fn removing_the_left_region_keeps_the_right_region_paint_frame() {
let mut s = Session::new();
let vector = split_vector(&mut s);
{
let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(vector).unwrap().kind else { panic!("expected vector") };
network.regions[1].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#0000ff").unwrap()))]);
}
let before = normalized_paint_world(&s, vector, 1);
s.set_selection(vec![vector]);
ex(&mut s, "shapeBuilder.remove", json!({"points": [[25, 50]]}));
let node = s.doc().unwrap().node(vector).unwrap();
assert!((node.width - 50.0).abs() < 1e-6, "the layer is reboxed to the surviving region: {}", node.width);
let net = network(&s, vector);
assert_eq!(net.regions.len(), 1);
assert!(matches!(&net.regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
assert!((world_region_area(&s, vector, 0) - 5000.0).abs() < 1e-6);
assert_same_transform(before, normalized_paint_world(&s, vector, 0));
}
+16 -2
View File
@@ -1219,17 +1219,31 @@ pub(crate) fn cut_across(s: &mut Session, a: Point, b: Point) -> Result<Vec<Node
/// under it, the others get the same (points outside closed areas are skipped). A vector without
/// fills gets the default fill so the painted area shows.
pub fn paint_all(doc: &mut Document, id: NodeId, pts: &[Point]) -> Result<bool> {
let inherited = doc.get(id)?.fills.clone();
let filled = edit(doc, id, |net| {
let mut want = None;
for p in pts {
want = net.paint_to(*p, want).or(want);
want = net.paint_to_with(*p, want, Some(&inherited)).or(want);
}
want
})?
.ok_or_else(|| bad("vector.edit.paint", "click inside a closed area"))?;
let n = doc.get_mut(id)?;
if filled && n.fills.iter().all(|f| !f.visible) {
n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)];
let default = figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE);
let targets: std::collections::BTreeSet<usize> = match &n.kind {
NodeKind::Vector { network } => pts.iter().filter_map(|point| network.region_at(*point)).collect(),
_ => std::collections::BTreeSet::new(),
};
if let NodeKind::Vector { network } = &mut n.kind {
for (index, region) in network.regions.iter_mut().enumerate() {
if region.fills.is_none() && !targets.contains(&index) {
// Freeze an untouched inherited region before adding a node-wide default.
region.fills = Some(Vec::new());
}
}
}
n.fills = vec![default];
}
Ok(filled)
}
+29 -8
View File
@@ -19,15 +19,21 @@ pub fn move_vertices(net: &mut VectorNetwork, idx: &[usize], d: Vec2) {
if !finite(d) {
return;
}
let mut seen = std::collections::BTreeSet::new();
for i in idx {
if seen.insert(*i)
&& let Some(v) = net.vertices.get_mut(*i)
{
let seen: std::collections::BTreeSet<usize> = idx.iter().copied().filter(|i| *i < net.vertices.len()).collect();
for i in &seen {
if let Some(v) = net.vertices.get_mut(*i) {
v.x += d.x;
v.y += d.y;
}
}
if seen.len() == net.vertices.len() {
let translate = Affine::translate(d);
for region in &mut net.regions {
if let Some(frame) = &mut region.paint_space {
frame.transform = translate * frame.transform;
}
}
}
}
/// Map vertices `idx` through `m` (scale, rotate, flip…); the handles at them follow its linear
@@ -65,6 +71,13 @@ pub fn transform_vertices(net: &mut VectorNetwork, idx: &[usize], m: Affine) {
}
}
}
if set.len() == net.vertices.len() {
for region in &mut net.regions {
if let Some(frame) = &mut region.paint_space {
frame.transform = m * frame.transform;
}
}
}
}
/// Set a handle to `rel` (relative to its vertex). The vertex's other handle follows its mirroring
@@ -586,7 +599,7 @@ pub fn part(net: &VectorNetwork, verts: &[usize]) -> VectorNetwork {
let loops: Vec<Vec<usize>> =
r.loops.iter().map(|l| l.iter().filter_map(|i| smap.get(*i).copied().flatten()).collect::<Vec<_>>()).filter(|l| !l.is_empty()).collect();
if !loops.is_empty() {
out.regions.push(Region { loops, winding: r.winding });
out.regions.push(Region { loops, ..r.clone() });
}
}
reopen_loops(&mut out);
@@ -648,7 +661,7 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
if unfilled {
lp.insert(0, si);
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero });
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
}
Some(si)
@@ -862,6 +875,14 @@ mod tests {
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 0.0)], false)
}
#[test]
fn split_part_keeps_region_paint_metadata() {
let mut n = square();
n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
let part = part(&n, &[0, 1, 2, 3]);
assert_eq!(part.regions[0].fills, n.regions[0].fills);
}
#[test]
fn handles_follow_mirroring() {
let mut n = open_line();
@@ -899,7 +920,7 @@ mod tests {
// Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
n.segments.push(Segment::line(0, 2));
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero });
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let p = Point::new(25.0, 10.0);
assert!(n.fill_contains(p));
for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] {
@@ -52,9 +52,8 @@ pub fn hatch_spacing(zoom: f64) -> Option<f64> {
(s.is_finite() && s > 0.0).then_some(s)
}
/// Whether vector `n` shows a fill (only filled areas are regions).
fn filled(n: &figcraft_doc::Node) -> bool {
n.fills.iter().any(|f| f.visible)
fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool {
net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible))
}
/// The network as its fill draws it: rounded points drawn in (region indexes are kept).
@@ -73,7 +72,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
return None;
}
let net = network(doc, ve.id)?;
if net.regions.is_empty() || !doc.node(ve.id).is_some_and(filled) {
let node = doc.node(ve.id)?;
if net.regions.is_empty() || !net.regions.iter().enumerate().any(|(i, _)| region_filled(node, net, i)) {
return None;
}
let w = doc.world_transform(ve.id);
@@ -90,7 +90,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) {
return None;
}
drawn(net).region_at(lp)
let drawn = drawn(net);
(0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp))
}
/// The regions to hatch: the selected ones and the hovered one (not while a drag is on), each once.
@@ -31,7 +31,7 @@ fn nested(regions: Vec<Region>) -> VectorNetwork {
}
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
Region { loops, winding }
Region { loops, winding, fills: None, ..Default::default() }
}
/// The 7.1 region: both squares as one region.
@@ -118,6 +118,17 @@ fn only_filled_areas_are_regions() {
assert!(ve(&s).regions.is_empty());
}
#[test]
fn styled_region_hit_ignores_empty_override_and_needs_no_layer_fill() {
let mut n = overlapping();
n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
n.regions[1].fills = Some(Vec::new());
let mut s = Session::new();
let id = editing_vector(&mut s, n);
s.doc_mut().unwrap().get_mut(id).unwrap().fills.clear();
assert_eq!(hover(&mut s, 100.0, 100.0, 1.0), Some(0));
}
#[test]
fn hover_and_press_select_a_region_with_all_its_points() {
// 20.
+1 -1
View File
@@ -244,7 +244,7 @@ pub(crate) fn specs() -> Vec<CommandSpec> {
vec![spec!(
"vector.edit.widthPoint",
"Width point",
"{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)",
"{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight), side? (left|right)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)",
true,
width_point
)]
+111 -1
View File
@@ -63,6 +63,8 @@ macro_rules! spec {
struct Registry {
list: Vec<CommandSpec>,
index: HashMap<&'static str, usize>,
/// The parameter names each command's doc lists (`list` order; see [`doc_param_names`]).
names: Vec<Option<Vec<&'static str>>>,
}
fn registry() -> &'static Registry {
@@ -123,7 +125,8 @@ fn registry() -> &'static Registry {
log::warn!("duplicate command id {}", s.id);
}
}
Registry { list, index }
let names = list.iter().map(|s| doc_param_names(s.params)).collect();
Registry { list, index, names }
})
}
@@ -136,6 +139,113 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
r.index.get(id).and_then(|i| r.list.get(*i))
}
/// The parameter names command `id` takes, from its doc; `None` when the doc isn't a `{…}` list
/// (`node.set` documents its props, and refuses the ones it doesn't know itself).
pub fn param_names(id: &str) -> Option<&'static [&'static str]> {
let r = registry();
r.index.get(id).and_then(|i| r.names.get(*i)).and_then(|n| n.as_deref())
}
/// Refuse params naming something the command doesn't take: a misspelt name would otherwise be
/// ignored and the command answer as if it hadn't been given. `ids` and `id` are always taken:
/// the layers a command acts on (`params::targets` reads either).
pub fn refuse_unknown_params(id: &str, params: &Value) -> Result<()> {
let (Some(names), Some(given)) = (param_names(id), params.as_object()) else { return Ok(()) };
let taken = |k: &str| matches!(k, "ids" | "id") || names.contains(&k);
let unknown: Vec<String> = given.keys().filter(|k| !taken(k)).map(|k| format!("`{k}`")).collect();
if unknown.is_empty() {
return Ok(());
}
let s = if unknown.len() == 1 { "" } else { "s" };
let takes = if names.is_empty() { "none".to_string() } else { names.join(", ") };
Err(crate::bad(id, format!("unknown parameter{s} {} (it takes {takes})", unknown.join(", "))))
}
/// The top-level items of a `{…}` params doc (`{ids?, direction? (horizontal|vertical)}` →
/// `ids?`, ` direction? (horizontal|vertical)`), with those of the alternatives after it
/// (`{x, y} | {points}`); `None` when the doc doesn't start with one.
pub(crate) fn doc_items(doc: &'static str) -> Option<Vec<&'static str>> {
let mut body = doc.trim_start().strip_prefix('{')?;
let (mut items, mut depth, mut start) = (Vec::new(), 0usize, 0);
loop {
let mut rest = None;
for (i, c) in body.char_indices() {
match c {
'{' | '[' | '(' => depth += 1,
'}' | ']' | ')' if depth > 0 => depth -= 1,
'}' | ',' if depth == 0 => {
items.push(body.get(start..i).unwrap_or_default());
start = i + c.len_utf8();
if c == '}' {
rest = body.get(start..);
break;
}
}
_ => {}
}
}
let rest = rest?;
match rest.trim_start().strip_prefix('|').and_then(|r| r.trim_start().strip_prefix('{')) {
Some(next) => (body, start) = (next, 0),
None => return Some(items),
}
}
}
/// The names one doc item gives: its first word and the words joined to it by `|`, `/` or `+`
/// (`id?/ids?`, `path | data (base64) + name`, `value? + newValue?`), without the `?` of optional
/// ones. Bracketed text describes a name, and what follows a `:`, `=` or `→` its value.
pub(crate) fn item_names(item: &'static str) -> Vec<&'static str> {
let mut outside = Vec::new();
let (mut depth, mut start) = (0usize, None);
for (i, c) in item.char_indices() {
let value_follows = depth == 0 && matches!(c, ':' | '=' | '→');
let in_word = depth == 0 && !value_follows && !c.is_whitespace() && !matches!(c, '(' | '[' | '{' | ')' | ']' | '}');
match c {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth = depth.saturating_sub(1),
_ => {}
}
match (in_word, start) {
(true, None) => start = Some(i),
(false, Some(s)) => {
outside.push(item.get(s..i).unwrap_or_default());
start = None;
}
_ => {}
}
if value_follows {
break;
}
}
if let Some(s) = start {
outside.push(item.get(s..).unwrap_or_default());
}
let is_name =
|n: &&str| n.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
let mut words = outside.into_iter();
let (mut names, mut word) = (Vec::new(), words.next());
while let Some(w) = word {
names.extend(w.split(['/', '|']).map(|n| n.trim_end_matches('?')).filter(is_name));
word = match words.next() {
Some("|" | "/" | "+") => words.next(),
_ => None,
};
}
names
}
/// Every name a `{…}` params doc lists, once each (see [`item_names`]).
fn doc_param_names(doc: &'static str) -> Option<Vec<&'static str>> {
let mut names = Vec::new();
for n in doc_items(doc)?.into_iter().flat_map(item_names) {
if !names.contains(&n) {
names.push(n);
}
}
Some(names)
}
/// `{"ok": true}` shorthand.
pub fn ok() -> Result<Value> {
Ok(serde_json::json!({"ok": true}))
+29 -23
View File
@@ -576,7 +576,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"id": id, "link": link(id)}))
}
),
spec!("comment.reply", "Reply to comment", "{id, text, author?}", true, |s, p| {
spec!("comment.reply", "Reply to comment", "{id | link, text, author?}", true, |s, p| {
let id = req_comment("comment.reply", s, p)?;
let who = author(s, p);
let r = Message::new(who.clone(), text("comment.reply", p)?, now_secs());
@@ -586,7 +586,7 @@ pub fn specs() -> Vec<CommandSpec> {
c.touched_by(&who);
Ok(json!({"reply": c.replies.len() - 1}))
}),
spec!("comment.edit", "Edit comment", "{id, text, reply? (index; omit for the first message), author?}", true, |s, p| {
spec!("comment.edit", "Edit comment", "{id | link, text, reply? (index; omit for the first message), author?}", true, |s, p| {
let id = req_comment("comment.edit", s, p)?;
let t = text("comment.edit", p)?;
let who = author(s, p);
@@ -607,7 +607,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"comment.react",
"React to comment",
"{id, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}",
"{id | link, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}",
true,
|s, p| {
let id = req_comment("comment.react", s, p)?;
@@ -629,7 +629,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"on": on}))
}
),
spec!("comment.resolve", "Resolve comment", "{id, resolved? (default true)}", true, |s, p| {
spec!("comment.resolve", "Resolve comment", "{id | link, resolved? (default true)}", true, |s, p| {
let id = req_comment("comment.resolve", s, p)?;
let v = boolean(p, "resolved").unwrap_or(true);
if let Some(c) = s.doc_mut()?.comment_mut(id) {
@@ -640,7 +640,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"comment.move",
"Move comment",
"{id, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}",
"{id | link, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}",
true,
|s, p| {
let id = req_comment("comment.move", s, p)?;
@@ -655,24 +655,30 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}
),
spec!("comment.delete", "Delete comment", "{id, reply? (index: delete one reply; omit to delete the thread), author?}", true, |s, p| {
let id = req_comment("comment.delete", s, p)?;
let who = author(s, p);
let doc = s.doc_mut()?;
let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?;
match message_index("comment.delete", c, p)? {
0 => {
own("comment.delete", &c.first, &who, "delete this thread")?;
doc.comments.retain(|c| c.id != id);
}
i => {
let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?;
own("comment.delete", m, &who, "delete this reply")?;
c.replies.remove(i - 1);
spec!(
"comment.delete",
"Delete comment",
"{id | link, reply? (index: delete one reply; omit to delete the thread), author?}",
true,
|s, p| {
let id = req_comment("comment.delete", s, p)?;
let who = author(s, p);
let doc = s.doc_mut()?;
let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?;
match message_index("comment.delete", c, p)? {
0 => {
own("comment.delete", &c.first, &who, "delete this thread")?;
doc.comments.retain(|c| c.id != id);
}
i => {
let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?;
own("comment.delete", m, &who, "delete this reply")?;
c.replies.remove(i - 1);
}
}
ok()
}
ok()
}),
),
spec!(
"comment.markRead",
"Mark comment as read",
@@ -711,7 +717,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"comment.next",
"Next comment",
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query? (the list, as comment.list), author?}",
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query?, frame?, resolved? (the list, as comment.list), author?}",
false,
|s, p| {
let dir = num(p, "dir").unwrap_or(1.0);
@@ -748,7 +754,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(json!({"zoom": v.zoom}))
}),
spec!("comment.link", "Copy link to comment", "{id}", false, |s, p| {
spec!("comment.link", "Copy link to comment", "{id | link}", false, |s, p| {
let id = req_comment("comment.link", s, p)?;
Ok(json!({"link": link(id)}))
}),
+42 -17
View File
@@ -9,6 +9,8 @@ use super::{CommandSpec, ok};
use crate::params::{boolean, f64_of, get, id, num, num_or, opt_id, req_targets, string};
use crate::{Result, Session, bad, spec};
pub const PLACED_IMAGE_MAX_SIDE: f64 = 4096.0;
/// Frame presets shown by the Frame tool (category, name, width, height).
pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[
("Phone", "iPhone 16", 393.0, 852.0),
@@ -53,37 +55,61 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"node.create",
"Create layer",
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? (for text), path? (SVG path data for vector), props? {node.set keys}, select? (default true)}",
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]), closed? (vector), props? {node.set keys}, select? (default true)}",
true,
create
),
spec!(
"frame.create",
"Create frame",
"{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, name?, props?} (a preset without x/y lands to the right of the page's frames)",
"{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, index?, name?, props?, select? (default true)} (a preset without x/y lands to the right of the page's frames)",
true,
frame_create
),
spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "rectangle")),
spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "ellipse")),
spec!("shape.polygon", "Polygon", "{x, y, width, height, parent?, props? {pointCount}}", true, |s, p| create_kind(s, p, "polygon")),
spec!("shape.star", "Star", "{x, y, width, height, parent?, props? {pointCount, innerRadius}}", true, |s, p| create_kind(s, p, "star")),
spec!("shape.line", "Line", "{x, y, width (length), parent?, props? {rotation}}", true, |s, p| create_kind(s, p, "line")),
spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, props?}", true, |s, p| create_kind(s, p, "arrow")),
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| {
create_kind(s, p, "text")
spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, index?, name?, props?, select? (default true)}", true, |s, p| {
create_kind(s, p, "rectangle")
}),
spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(
s, p, "ellipse"
)),
spec!(
"shape.polygon",
"Polygon",
"{x, y, width, height, parent?, index?, name?, props? {pointCount}, select? (default true)}",
true,
|s, p| create_kind(s, p, "polygon")
),
spec!(
"shape.star",
"Star",
"{x, y, width, height, parent?, index?, name?, props? {pointCount, innerRadius}, select? (default true)}",
true,
|s, p| create_kind(s, p, "star")
),
spec!("shape.line", "Line", "{x, y, width (length), parent?, index?, name?, props? {rotation}, select? (default true)}", true, |s, p| {
create_kind(s, p, "line")
}),
spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(
s, p, "arrow"
)),
spec!(
"text.create",
"Text",
"{text | characters, x, y, parent?, index?, name? (default: the first line), width? (fixed width wraps), props? (fontSize, fontFamily, …), select? (default true)}",
true,
|s, p| { create_kind(s, p, "text") }
),
spec!(
"vector.create",
"Vector",
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}",
true,
|s, p| create_kind(s, p, "vector")
),
spec!(
"image.place",
"Place image",
"{data (base64) | path, x?, y?, width?, height?, parent?, target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
"{data (base64) | path, x?, y?, width?, height?, parent?, name? (default Image), target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
true,
place_image
),
@@ -443,13 +469,12 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let ds = s.ds_mut()?;
let doc = ds.doc_mut();
let density = figcraft_format::pixel_density(&bytes);
let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash));
let (iw, ih) = (f64::from(iw), f64::from(ih));
// Fit large images into 1000 px like Figma does when placing.
let k = (1000.0 / iw.max(ih)).min(1.0);
let w = num(p, "width").unwrap_or(iw * k);
let h = num(p, "height").unwrap_or(if num(p, "width").is_some() { w * ih / iw.max(1.0) } else { ih * k });
let (placed_w, placed_h) = figcraft_format::placed_size(iw, ih, density, PLACED_IMAGE_MAX_SIDE);
let w = num(p, "width").unwrap_or(placed_w);
let h = num(p, "height").unwrap_or(if num(p, "width").is_some() { w * f64::from(ih) / f64::from(iw).max(1.0) } else { placed_h });
let nid = doc.alloc_id();
let name = string(p, "name").unwrap_or_else(|| "Image".into());
let mut node = Node::new(nid, NodeKind::Rectangle, name, w, h);
+88 -6
View File
@@ -8,8 +8,8 @@ use serde_json::{Map, Value, json};
use super::{CommandSpec, ok};
use crate::params::{
blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets,
string, targets,
blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_id, opt_index, paint_of, parse, req_id, req_index, req_num, req_string,
req_targets, string, targets,
};
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
@@ -29,6 +29,80 @@ use select::{
select_parent, select_same, select_sibling, toggle,
};
/// The layer types `node.find` takes, as `NodeKind::type_name` spells them.
const LAYER_TYPES: [&str; 16] = [
"PAGE",
"FRAME",
"GROUP",
"SECTION",
"RECTANGLE",
"ELLIPSE",
"POLYGON",
"STAR",
"LINE",
"VECTOR",
"TEXT",
"BOOLEAN_OPERATION",
"COMPONENT",
"COMPONENT_SET",
"INSTANCE",
"SLICE",
];
/// Lowercase for `node.find`'s substring filters. Greek final sigma becomes σ on both sides, so a
/// query lowercased on its own (`Σ` to `σ`) still matches a name ending in it (`ΟΣ` to `ος`).
fn fold(s: &str) -> String {
s.to_lowercase().replace('ς', "σ")
}
fn node_find(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "node.find";
let filter = |key| -> Result<Option<String>> {
get(p, key).map(|v| v.as_str().map(fold).ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose()
};
let name = filter("name")?;
let text = filter("text")?;
let component = filter("component")?;
let kind = filter("type")?;
if let Some(kind) = &kind
&& !LAYER_TYPES.iter().any(|t| t.eq_ignore_ascii_case(kind))
{
return Err(bad(cmd, format!("unknown type; use one of {}", LAYER_TYPES.join(", "))));
}
let limit = match get(p, "limit") {
None => 100,
Some(v) => v.as_u64().filter(|n| *n <= 10000).ok_or_else(|| bad(cmd, "`limit` must be an integer from 0 to 10000"))? as usize,
};
let root = opt_id(cmd, p, "root")?.map_or_else(|| s.page(), Ok)?;
let doc = s.doc()?;
// Keep one child iterator per ancestor, not a materialised subtree or all pending siblings.
let mut stack = vec![doc.get(root)?.children.iter()];
let mut ids = Vec::new();
let mut total = 0usize;
while let Some(children) = stack.last_mut() {
let Some(&id) = children.next() else {
stack.pop();
continue;
};
let n = doc.get(id)?;
stack.push(n.children.iter());
if name.as_ref().is_some_and(|q| !fold(&n.name).contains(q))
|| kind.as_ref().is_some_and(|q| !n.kind.type_name().eq_ignore_ascii_case(q))
|| text.as_ref().is_some_and(|q| !matches!(&n.kind, NodeKind::Text(t) if fold(&t.characters).contains(q)))
|| component.as_ref().is_some_and(
|q| !matches!(&n.kind, NodeKind::Instance { component, .. } if doc.node(*component).is_some_and(|c| fold(&c.name).contains(q))),
)
{
continue;
}
total += 1;
if ids.len() < limit {
ids.push(id);
}
}
Ok(json!({"ids": ids, "total": total}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))),
@@ -235,6 +309,13 @@ pub fn specs() -> Vec<CommandSpec> {
let i = req_id("node.get", p, "id")?;
Ok(crate::inspect::node_json(s.doc()?, i, 1))
}),
spec!(
"node.find",
"Find layers",
"{root? (strict descendants; default current page), name?, type? (canonical layer type), text?, component? (main component name), limit? (0-10000; default 100)}: every filter must match; name, text and component are case-insensitive substrings, type is exact (any case); returns {ids, total} and does not select",
false,
node_find
),
spec!(
"node.reparent",
"Move layer into",
@@ -248,8 +329,8 @@ pub fn specs() -> Vec<CommandSpec> {
s.doc_mut()?.reorder(i, idx)?;
ok()
}),
spec!("fill.add", "Add fill", "{ids?, paint? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
spec!("stroke.add", "Add stroke", "{ids?, paint? (default black)}", true, |s, p| paint_list(s, p, false, true)),
spec!("fill.add", "Add fill", "{ids?, paint? | color? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
spec!("stroke.add", "Add stroke", "{ids?, paint? | color? (default black)}", true, |s, p| paint_list(s, p, false, true)),
spec!("fill.remove", "Remove fill", "{ids?, index}", true, |s, p| paint_list(s, p, true, false)),
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
@@ -273,7 +354,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"fill.pattern",
"Pattern fill",
"{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?} the source layer tiled; it updates as the source changes",
"{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?, visible?, blend?} the source layer tiled; it updates as the source changes",
true,
pattern_fill
),
@@ -354,7 +435,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"export.update",
"Edit export setting",
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}",
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
true,
|s, p| {
let cmd = "export.update";
@@ -387,6 +468,7 @@ pub fn specs() -> Vec<CommandSpec> {
for (i, c) in a.iter().enumerate() {
let cmd =
c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
crate::batch::refuse_unknown_step_keys(i, c)?;
let params = c.get("params").cloned().unwrap_or(json!({}));
out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) });
}
+23 -25
View File
@@ -36,7 +36,7 @@ pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write};
pub use open::MAX_RECENT;
#[cfg(test)]
use open::append_pages;
use open::{import, open, open_recent};
use open::{import, open, open_recent, revert};
pub(crate) use save::save_ends_preview;
pub use save::{FileSnapshot, file_bytes, thumbnail};
use save::{color_profile, mark_saved, save, save_as};
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.open",
"Open",
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file), reload? (a file already open: read it again into its tab, which must have no unsaved changes)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open returns its `index`, with `stale: true` when the file changed on disk since the tab read or saved it, or `reloaded: true` after `reload`)",
false,
open
),
@@ -110,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.export",
"Export",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
false,
export
),
@@ -121,13 +121,19 @@ pub fn specs() -> Vec<CommandSpec> {
false,
export_layers
),
spec!("file.exportSvg", "Export selection as SVG", "{ids?, path?}", false, |s, p| {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("format".into(), json!("svg"));
spec!(
"file.exportSvg",
"Export selection as SVG",
"{ids?, path?, scale? | size?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke? (file.export's SVG options)}",
false,
|s, p| {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("format".into(), json!("svg"));
}
export(s, &q)
}
export(s, &q)
}),
),
spec!(
"file.exportFramesPdf",
"Export frames to PDF",
@@ -142,7 +148,13 @@ pub fn specs() -> Vec<CommandSpec> {
true,
import
),
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
spec!(
"file.placeImage",
"Place image",
"{path | data, x?, y?, width?, height?, parent?, name?, target?, mode? (as image.place)}",
true,
super::create::place_image
),
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
let from = req_index("file.moveTab", p, "from")?;
@@ -191,21 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"index": s.active}))
}),
spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| {
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
let (loaded, _) = load_path(&path, None)?;
let i = s.active;
let uid = s.ds()?.uid;
if let Some(slot) = s.docs.get_mut(i) {
*slot = DocState::new(loaded.doc, Some(path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, loaded.images_past_budget);
slot.on_disk = on_disk;
}
// Edits on its branch tabs are reverted too.
crate::branches::close_tabs(s, uid, None);
revert(s)?;
ok()
}),
spec!(
+16
View File
@@ -165,7 +165,23 @@ fn reflow_overridden_text(doc: &mut Document) {
}
for id in changed {
if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) {
// Sketch grows auto-width text from its aligned edge: a centred one stays centred, a
// right-aligned one keeps its right edge.
let shift = doc.node(id).map_or(0.0, |n| match &n.kind {
NodeKind::Text(t) if t.auto_resize == figcraft_doc::TextAutoResize::WidthAndHeight => match t.align_h {
figcraft_doc::TextAlignH::Center => (n.width - w) / 2.0,
figcraft_doc::TextAlignH::Right => n.width - w,
_ => 0.0,
},
_ => 0.0,
});
figcraft_layout::set_size(doc, id, w, h);
if shift.is_finite()
&& shift != 0.0
&& let Some(n) = doc.node_mut(id)
{
n.transform *= figcraft_doc::geom::Affine::translate((shift, 0.0));
}
}
figcraft_layout::layout_up(doc, id, &m);
}
+37 -2
View File
@@ -62,11 +62,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
return open_data(s, &d, p, variables.as_ref());
}
let path = req_string("file.open", p, "path")?;
// Already open: switch to it.
// Already open: switch to it, and say when the file changed on disk since this tab read or
// saved it (`stale`), or read it again (`reload`).
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
s.active = i;
remember(s, &path);
return Ok(json!({"index": i}));
let stale = s.ds()?.on_disk.as_ref().is_some_and(|read| crate::library::stat(&path).ok().as_ref() != Some(read));
if !boolean(p, "reload").unwrap_or(false) {
return Ok(if stale { json!({"index": i, "stale": true}) } else { json!({"index": i}) });
}
if s.ds()?.is_dirty() {
return Err(EngineError::Disabled(
"file.open".into(),
"the open copy has unsaved changes: save them, or Revert to saved to drop them".into(),
));
}
if s.live().doc_uid.is_some() {
return Err(EngineError::Disabled("file.open".into(), "leave the live session before reading the file again".into()));
}
revert(s)?;
return Ok(json!({"index": i, "reloaded": true}));
}
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
@@ -95,6 +110,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// Replace the active tab's document with its file as saved (`file.revert`, `file.open {reload}`).
pub(super) fn revert(s: &mut Session) -> Result<()> {
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
let (loaded, _) = load_path(&path, None)?;
let i = s.active;
let uid = s.ds()?.uid;
if let Some(slot) = s.docs.get_mut(i) {
*slot = DocState::new(loaded.doc, Some(path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, loaded.images_past_budget);
slot.on_disk = on_disk;
}
// Edits on its branch tabs are reverted too.
crate::branches::close_tabs(s, uid, None);
Ok(())
}
/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells
/// the format. It opens as a new document with no path, so the first save asks where to put it.
fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result<Value> {
+23 -8
View File
@@ -387,20 +387,34 @@ fn paint_none(v: &Value) -> bool {
v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))
}
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style
/// links after the values so a link wins over values its style sets, variable bindings last so
/// paints set in the same call don't detach them.
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, the
/// auto layout mode before the keys that tune it (an object `layout` replaces the whole auto
/// layout), style links after the values so a link wins over values its style sets, variable
/// bindings last so paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
match k {
"path" | "width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
"boundVariables" => 5,
_ => 1,
"layout" | "layoutMode" => 1,
"rotation" => 3,
"x" | "y" => 4,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 5,
"boundVariables" => 6,
_ => 2,
}
}
/// Auto layout keys given beside a `layout` (or `layoutMode`) that turns auto layout off: they
/// would turn it back on (`layout_mut`), so the call is refused rather than one of them losing.
fn layout_keys_beside_none(cmd: &str, props: &Map<String, Value>) -> Result<()> {
let off = ["layout", "layoutMode"]
.into_iter()
.find(|k| props.get(*k).is_some_and(|v| v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))));
let tuning: Vec<String> = props.keys().filter(|k| LAYOUT_KEYS.contains(&k.as_str())).map(|k| format!("`{k}`")).collect();
let Some(off) = off.filter(|_| !tuning.is_empty()) else { return Ok(()) };
let (what, are) = if tuning.len() == 1 { ("an auto layout property", "is") } else { ("auto layout properties", "are") };
Err(bad(cmd, format!("{} {are} {what}, and `{off}` in the same call turns auto layout off", tuning.join(", "))))
}
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
/// into W or H.
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
@@ -995,6 +1009,7 @@ fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
layout_keys_beside_none(cmd, props)?;
let mut unknown = Vec::new();
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| apply_order(k));
+2 -2
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.interaction.add",
"Add interaction",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], dest? (without action: a click-navigate to it), transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs? | duration_ms?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}, delayMs? | delay_ms? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
true,
add
),
@@ -51,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.interaction.set",
"Edit interaction",
"{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?, delayMs?}",
"{id?, index, interaction? {..} (replaces it whole), trigger?, action? (first action), actions?, transition?, durationMs? | duration_ms?, easing?, delayMs? | delay_ms?} patch fields on top of the current interaction (or of interaction)",
true,
set
),
+275 -65
View File
@@ -2,8 +2,8 @@
use figcraft_color::{Color, Paint};
use figcraft_doc::{
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, Segment, Sizing, StrokeAlign, StrokeJoin, StrokeProps,
VectorNetwork, WindingRule, proto,
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, RegionPaintSpace, Segment, Sizing, StrokeAlign, StrokeJoin,
StrokeProps, VectorNetwork, WindingRule, proto,
};
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use serde_json::{Value, json};
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add),
spec!("layout.suggest", "Suggest auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add),
spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page),
spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?;
@@ -385,56 +385,35 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
let index = doc.index_in_parent(top).map(|i| i + 1);
let pw = doc.world_transform(parent);
let mut net = VectorNetwork::default();
for i in &ids {
let Some(n) = doc.node(*i) else { continue };
// Vectors keep their own network (open paths and unfilled loops have no fill outline);
// booleans flatten to their result; strokes don't get outlined.
let mut part = match &n.kind {
NodeKind::Vector { network } => network.clone(),
_ => {
let Some((op, rule)) = (match &n.kind {
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
figcraft_render::subtree_outline(doc, n, 0)
}
_ => figcraft_render::node_outline(doc, n),
}) else {
continue;
};
let mut part = VectorNetwork::from_path(&op);
if rule == figcraft_render::FillRule::EvenOdd {
for r in &mut part.regions {
r.winding = WindingRule::EvenOdd;
}
}
part
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
let fallback_fills = first
.as_ref()
.filter(|node| matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty())
.map(|_| vec![Paint::solid(Color::DEFAULT_SHAPE)]);
let output_fills = first
.as_ref()
.map(|node| {
if matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty() {
fallback_fills.clone().unwrap_or_default()
} else {
node.fills.clone()
}
};
// Into the common parent's space.
let all: Vec<usize> = (0..part.vertices.len()).collect();
crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i));
let (nv, ns) = (net.vertices.len(), net.segments.len());
net.vertices.extend(part.vertices);
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
net.regions.extend(
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
);
})
.unwrap_or_default();
let mut net = VectorNetwork::default();
for id in &ids {
collect_flatten_parts(doc, *id, pw, fallback_fills.as_deref(), &output_fills, &mut net, 0)?;
}
let Some(bb) = net.bounds() else {
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
};
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
net.translate(-bb.x0, -bb.y0);
let vid = doc.alloc_id();
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
v.transform = Affine::translate((bb.x0, bb.y0));
if let Some(f) = first {
v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() {
vec![Paint::solid(Color::DEFAULT_SHAPE)]
} else {
f.fills.clone()
};
v.fills = output_fills;
v.strokes = f.strokes.clone();
v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() };
v.effects = f.effects.clone();
@@ -449,6 +428,99 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": vid}))
}
fn collect_flatten_parts(
doc: &Document,
id: NodeId,
parent_world: Affine,
fallback_fills: Option<&[Paint]>,
output_fills: &[Paint],
net: &mut VectorNetwork,
depth: usize,
) -> Result<()> {
if depth > 64 {
return Err(bad("vector.flatten", "group nesting exceeds 64"));
}
let Some(node) = doc.node(id) else { return Ok(()) };
if matches!(node.kind, NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) && !node.children.is_empty() {
// Match subtree_outline: a nonempty frame contributes its own visible fill, then
// visible descendants in stacking order. Child clipping remains the container's outline rule.
if node.kind.is_frame_like()
&& node.fills.iter().any(|paint| paint.visible)
&& let Some(path) = figcraft_doc::geometry::shape_path(node)
{
let frame = VectorNetwork::from_path(&path);
let into_parent = parent_world.inverse() * doc.world_transform(id);
append_flatten_part(node, fallback_fills, output_fills, into_parent, frame, net);
}
for child in &node.children {
if !doc.node(*child).is_some_and(|child| child.visible) {
continue;
}
collect_flatten_parts(doc, *child, parent_world, fallback_fills, output_fills, net, depth + 1)?;
}
return Ok(());
}
let part = match &node.kind {
NodeKind::Vector { network } => {
let mut part = network.clone();
if !part.regions.is_empty() && part.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none()) {
let winding = if part.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
WindingRule::EvenOdd
} else {
WindingRule::NonZero
};
let loops = std::mem::take(&mut part.regions).into_iter().flat_map(|region| region.loops).collect();
part.regions = vec![Region { loops, winding, ..Default::default() }];
}
part
}
_ => {
let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { return Ok(()) };
let mut part = VectorNetwork::from_path(&path);
for region in &mut part.regions {
if rule == figcraft_render::FillRule::EvenOdd {
region.winding = WindingRule::EvenOdd;
}
}
part
}
};
let into_parent = parent_world.inverse() * doc.world_transform(id);
append_flatten_part(node, fallback_fills, output_fills, into_parent, part, net);
Ok(())
}
fn append_flatten_part(
node: &Node,
fallback_fills: Option<&[Paint]>,
output_fills: &[Paint],
into_parent: Affine,
mut part: VectorNetwork,
net: &mut VectorNetwork,
) {
for region in &mut part.regions {
let (width, height, paint_transform) = region.paint_frame(node.width, node.height);
if region.fills.is_none() {
let effective = if node.fills.is_empty() { fallback_fills.unwrap_or(&node.fills) } else { &node.fills };
if effective != output_fills {
region.fills = Some(effective.to_vec());
}
}
region.paint_space = Some(RegionPaintSpace { width, height, transform: paint_transform });
}
let vertices: Vec<usize> = (0..part.vertices.len()).collect();
crate::vectoredit::ops::transform_vertices(&mut part, &vertices, into_parent);
let (nv, ns) = (net.vertices.len(), net.segments.len());
net.vertices.extend(part.vertices);
net.segments.extend(part.segments.into_iter().map(|segment| Segment { a: segment.a + nv, b: segment.b + nv, ..segment }));
net.regions.extend(
part.regions.into_iter().map(|region| Region {
loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + ns).collect()).collect(),
..region
}),
);
}
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.outlineStroke", s, p)?;
let mut out = Vec::new();
@@ -489,7 +561,8 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
v.fills = n.strokes.clone();
v.strokes.clear();
v.styles.fill = n.styles.stroke;
if n.fills.iter().any(|f| f.visible) && closed {
let region_fill = matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.as_ref().is_some_and(|fills| fills.iter().any(|f| f.visible))));
if (n.fills.iter().any(|f| f.visible) || region_fill) && closed {
// Keep the fill as its own layer under the outline, grouped.
doc.insert(v, parent, idx.map(|x| x + 1))?;
let fill_id = i;
@@ -634,12 +707,53 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc_mut()?;
let n = doc.get_mut(i)?;
if let NodeKind::Vector { network } = &mut n.kind {
let fill = network.fill_path();
let src = if fill.elements().is_empty() { network.stroke_path() } else { fill };
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default());
let regions = !network.regions.is_empty();
let mut net = VectorNetwork::from_path(&simplified);
if !regions {
let source = network.clone();
let mut net = VectorNetwork::default();
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
if legacy && !source.regions.is_empty() {
let winding = if source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
WindingRule::EvenOdd
} else {
WindingRule::NonZero
};
let path = source.fill_path();
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
Ok(path) => (path, WindingRule::EvenOdd),
Err(_) => (path, winding),
};
let combined = Region { winding: output_winding, ..Default::default() };
append_region_path(&mut net, &path, &combined, output_winding);
} else {
for (index, region) in source.regions.iter().enumerate() {
let Some(path) = source.region_path(index) else { continue };
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
append_region_path(&mut net, &path, region, region.winding);
}
}
if !source.regions.is_empty() {
let mut used = vec![false; source.segments.len()];
for segment in source.regions.iter().flat_map(|region| region.loops.iter().flatten()) {
if let Some(used) = used.get_mut(*segment) {
*used = true;
}
}
let mut open = source.clone();
open.segments = source.segments.iter().enumerate().filter_map(|(index, segment)| (!used[index]).then_some(*segment)).collect();
open.regions.clear();
if !open.segments.is_empty() {
let path = open.stroke_path();
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
let mut loose = VectorNetwork::from_path(&simplified);
loose.regions.clear();
append_network(&mut net, loose);
}
}
if source.regions.is_empty() {
let path = source.stroke_path();
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
net = VectorNetwork::from_path(&simplified);
net.regions.clear();
}
*network = net;
@@ -657,22 +771,99 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
if !n.children.is_empty() {
continue;
}
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let res = if d >= 0.0 {
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
} else {
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
};
let Ok(mut res) = res else { continue };
let bb = res.bounding_box();
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let nm = doc.get_mut(i)?;
let mut net = VectorNetwork::from_path(&res);
for r in &mut net.regions {
r.winding = WindingRule::EvenOdd;
if d == 0.0 {
continue;
}
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
let NodeKind::Vector { network: source } = &n.kind else {
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let result = if d >= 0.0 {
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
} else {
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
}
.map_err(|error| bad("vector.offset", error.to_string()))?;
let mut result = result;
let bounds = result.bounding_box();
result.apply_affine(Affine::translate((-bounds.x0, -bounds.y0)));
let node = doc.get_mut(i)?;
node.kind = NodeKind::Vector { network: VectorNetwork::from_path(&result) };
node.transform *= Affine::translate((bounds.x0, bounds.y0));
node.width = bounds.width();
node.height = bounds.height();
continue;
};
let mut net = VectorNetwork::default();
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
if source.regions.is_empty() {
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let mut res = if d >= 0.0 {
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
} else {
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
}
.map_err(|error| bad("vector.offset", error.to_string()))?;
let bb = res.bounding_box();
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let nm = doc.get_mut(i)?;
nm.kind = NodeKind::Vector { network: VectorNetwork::from_path(&res) };
nm.transform *= Affine::translate((bb.x0, bb.y0));
nm.width = bb.width();
nm.height = bb.height();
continue;
}
if legacy {
let path = source.fill_path();
let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd);
let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?;
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let result = if d >= 0.0 {
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
} else {
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
}
.map_err(|error| bad("vector.offset", error.to_string()))?;
let mut part = VectorNetwork::from_path(&result);
let (paint_width, paint_height, paint_transform) = source.regions[0].paint_frame(n.width, n.height);
for region in &mut part.regions {
region.winding = WindingRule::EvenOdd;
region.fills = source.regions[0].fills.clone();
region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
}
append_network(&mut net, part);
} else {
for (index, region) in source.regions.iter().enumerate() {
let Some(path) = source.region_path(index) else { continue };
let mut paint_region = region.clone();
let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height);
paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd)
.map_err(|error| bad("vector.offset", error.to_string()))?;
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let res = if d >= 0.0 {
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
} else {
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
}
.map_err(|error| bad("vector.offset", error.to_string()))?;
// A collapsed negative offset removes this region, while other source regions stay.
if res.elements().is_empty() {
continue;
}
append_region_path(&mut net, &res, &paint_region, WindingRule::EvenOdd);
}
}
let Some(bb) = net.bounds() else {
let nm = doc.get_mut(i)?;
nm.kind = NodeKind::Vector { network: net };
nm.width = 0.0;
nm.height = 0.0;
continue;
};
net.translate(-bb.x0, -bb.y0);
let nm = doc.get_mut(i)?;
nm.kind = NodeKind::Vector { network: net };
nm.transform *= Affine::translate((bb.x0, bb.y0));
nm.width = bb.width();
@@ -681,6 +872,25 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn append_region_path(net: &mut VectorNetwork, path: &BezPath, source: &Region, winding: WindingRule) {
let mut part = VectorNetwork::from_path(path);
for region in &mut part.regions {
region.winding = winding;
region.fills = source.fills.clone();
region.paint_space = source.paint_space;
}
append_network(net, part);
}
fn append_network(net: &mut VectorNetwork, part: VectorNetwork) {
let (nv, ns) = (net.vertices.len(), net.segments.len());
net.vertices.extend(part.vertices);
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
net.regions.extend(
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
);
}
/// Join open vectors whose end points touch into one vector.
fn join(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.join", s, p)?;
@@ -695,7 +905,7 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
let last = network.vertices.len() - 1;
network.segments.push(Segment::line(last, 0));
let all: Vec<usize> = (0..network.segments.len()).collect();
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero });
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero, fills: None, paint_space: None });
}
}
return ok();
+1 -1
View File
@@ -164,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.create",
"Create variable",
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}}",
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?}",
true,
var_create
),
+15 -9
View File
@@ -279,13 +279,19 @@ pub fn specs() -> Vec<CommandSpec> {
.ok_or_else(|| bad("text.decoration", "`decoration`: none|underline|strikethrough"))?;
style(s, p, |st| st.decoration = d)
}),
spec!("text.listStyle", "List style", "{style: none|bulleted|numbered, ids?} while editing: the selected paragraphs", true, |s, p| {
let l: ListStyle = get(p, "style")
.or_else(|| get(p, "list"))
.and_then(super::props::enum_of)
.ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
set_list(s, p, l, false)
}),
spec!(
"text.listStyle",
"List style",
"{style | list: none|bulleted|numbered, ids?} while editing: the selected paragraphs",
true,
|s, p| {
let l: ListStyle = get(p, "style")
.or_else(|| get(p, "list"))
.and_then(super::props::enum_of)
.ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
set_list(s, p, l, false)
}
),
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
para(s, p, |t| t.paragraph_indent = v)
@@ -374,7 +380,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("text.endPreview", "End text preview", "{} puts the document back as it was before text.preview → {ended}", false, |s, _| {
Ok(json!({"ended": crate::textpreview::end(s)}))
}),
spec!("text.set", "Set text", "{id?, characters}", true, |s, p| {
spec!("text.set", "Set text", "{id?, characters | text}", true, |s, p| {
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
let ids = req_targets("text.set", s, p)?;
let doc = s.doc_mut()?;
@@ -390,7 +396,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"text.style",
"Style text range",
"{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textCase?, hyperlink?, textFills?}",
"{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontVariations? | variationSettings?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textDecorationStyle?, textDecorationThickness?, textDecorationOffset?, textDecorationSkipInk?, textDecorationColor?, textCase?, hyperlink?, textFills?}",
true,
|s, p| {
let ids = req_targets("text.style", s, p)?;
+1 -1
View File
@@ -373,7 +373,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"pref.uiScale",
"Interface size",
"{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
"{scale? (a size from 0.75 to 3 relative to the system's display scale: 1 is the system's own size, so 1.25 is 125 %, not 125; outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
false,
|s, p| {
match get(p, "scale") {
@@ -106,6 +106,17 @@ fn components_take_their_properties_as_parameters() {
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
let dart = code(&mut s, c, "flutter");
for want in [
"import 'package:flutter/material.dart';\n\nclass Card extends StatelessWidget {\n",
" const Card({\n super.key,\n this.showDot = true,\n this.title = \"Hello\",\n });\n",
" final bool showDot;\n final String title;\n",
" Widget build(BuildContext context) {\n return Container(",
"if (showDot)\n",
"Text(\n title,",
] {
assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}");
}
// The command describes the properties.
let v = component_code(&mut s, c, "react");
assert_eq!(v["name"], "Card");
@@ -140,6 +151,7 @@ fn instances_are_calls_passing_the_values_they_set() {
assert!(react.contains("export function Card({"));
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title=\"Buy\" />\n");
// Text that needs escaping is an expression in JSX and escaped per language.
@@ -147,6 +159,7 @@ fn instances_are_calls_passing_the_values_they_set() {
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Card showDot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" \\$5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
// A quoted title becomes a binding whose attribute value is escaped: the expression itself
@@ -175,6 +188,8 @@ fn nested_instances_in_a_layer_tree_are_calls() {
assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}");
assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")"));
assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")"));
let dart = code(&mut s, screen, "flutter");
assert!(dart.contains("Card(title: \"Inbox\")") && !dart.contains("Hello"), "{dart}");
let leptos = code(&mut s, screen, "leptos");
assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains("<Card title=\"Inbox\" />"), "{leptos}");
assert!(!leptos.contains("Hello"), "{leptos}");
@@ -232,6 +247,19 @@ fn variant_sets_share_one_definition() {
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Button state=ButtonState::Hover />\n");
}
#[test]
fn flutter_variant_sets_share_one_enum() {
let mut s = Session::new();
let (set, _, _) = button_set(&mut s);
let dart = code(&mut s, set, "flutter");
assert!(dart.contains("enum ButtonState { defaultValue, hover }\n"), "{dart}");
assert!(dart.contains(" this.state = ButtonState.defaultValue,\n"), "{dart}");
assert!(dart.contains(" final ButtonState state;\n"), "{dart}");
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Button(state: ButtonState.hover)\n");
}
#[test]
fn instance_swap_properties_take_views() {
let mut s = Session::new();
@@ -252,6 +280,10 @@ fn instance_swap_properties_take_views() {
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
let kt = code(&mut s, chip, "compose");
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
let dart = code(&mut s, chip, "flutter");
assert!(dart.contains(" final Widget icon;\n") && dart.contains(" this.icon = const HeartIcon(),\n"), "{dart}");
assert!(dart.contains("child: icon\n"), "the nested instance is the parameter: {dart}");
assert!(!dart.contains("child: Container("), "the swap's widget tree isn't spelled out: {dart}");
let ark = code(&mut s, chip, "arkui");
for want in [
" @BuilderParam icon: () => void = this.defaultIconBuilder",
@@ -272,6 +304,7 @@ fn instance_swap_properties_take_views() {
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Chip icon={<StarIcon />} />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Chip(icon: StarIcon())\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Chip({ icon: () => { StarIcon() } })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Chip :icon=\"StarIcon\" />\n");
@@ -289,6 +322,9 @@ fn slots_take_optional_content() {
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
let dart = code(&mut s, panel, "flutter");
assert!(dart.contains(" final List<Widget>? content;\n") && dart.contains(" this.content,\n"), "{dart}");
assert!(dart.contains("...?content,\n") && dart.contains("if (content == null) ...[\n"), "{dart}");
let ark = code(&mut s, panel, "arkui");
for want in [
" @BuilderParam content: () => void = this.defaultContentBuilder",
@@ -305,11 +341,14 @@ fn slots_take_optional_content() {
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 400, "y": 0})));
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Panel />\n", "the main's content isn't passed");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Panel()\n", "the main's content isn't passed");
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Content", "component": badge, "replace": true}));
let usage = component_code(&mut s, i, "react")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("<Panel content={(\n") && usage.contains("<Badge />"), "{usage}");
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("<Panel content={|| view! {\n") && usage.contains("<Badge />") && usage.ends_with("}} />\n"), "{usage}");
let usage = component_code(&mut s, i, "flutter")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("Panel(content: [\n") && usage.contains("Badge()"), "{usage}");
let usage = component_code(&mut s, i, "arkui")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("Panel({ content: () => {\n") && usage.contains("Badge()") && usage.ends_with("} })\n"), "{usage}");
}
@@ -359,10 +398,22 @@ fn identifiers_are_escaped_and_unique() {
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
// Dart: lowerCamel members, keywords suffixed, never a type the widget tree names.
let dart = code_for(doc, c, CodeLang::Flutter);
for want in [
"class TextComponent extends StatelessWidget {",
"enum TextComponentColorVariant { red }\n",
" this.defaultValue = false,",
" this.p2Columns = false,",
" final String type;",
] {
assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}");
}
// Errors.
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");
assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "html"})).is_err());
assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "flutter"})).is_ok());
assert!(s.execute("inspect.componentCode", &json!({"id": 999_999})).is_err());
}
+2 -2
View File
@@ -247,7 +247,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"annotation.add",
"Add annotation",
"{id? (default the first selected layer), label? (the note), properties?: [width|height|top|left|rotation|opacity|cornerRadius|direction|gap|padding|alignment|fontFamily|fontStyle|fontSize|lineHeight|letterSpacing|textAlign|fill|stroke|strokeWeight|effects], category? (id or name)} → {id, index} (a note, pinned properties or both)",
"{id? (default the first selected layer), label? | text? (the note), properties?: [width|height|top|left|rotation|opacity|cornerRadius|direction|gap|padding|alignment|fontFamily|fontStyle|fontSize|lineHeight|letterSpacing|textAlign|fill|stroke|strokeWeight|effects], category? (id or name)} → {id, index} (a note, pinned properties or both)",
true,
|s, p| {
let cmd = "annotation.add";
@@ -270,7 +270,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"annotation.edit",
"Edit annotation",
"{id?, index, label?, properties?: [..] (replaces the pinned properties), category? (id or name; null: none)}",
"{id?, index, label? | text?, properties?: [..] (replaces the pinned properties), category? (id or name; null: none)}",
true,
|s, p| {
let cmd = "annotation.edit";
+16 -6
View File
@@ -37,8 +37,9 @@ const MAX_WARNINGS: usize = 50;
/// The languages a component can be connected in.
pub fn language(s: &str) -> Option<CodeLang> {
CodeLang::from_id(s)
.filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))
CodeLang::from_id(s).filter(|l| {
matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi | CodeLang::Flutter)
})
}
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
@@ -69,6 +70,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
// A single-file component is a default export.
CodeLang::Vue => format!("import {} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
CodeLang::Flutter => format!("import '{}';", path.replace(['\'', '\\'], "")),
_ => format!("import {path}"),
}
}
@@ -273,6 +275,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
(Some(l), CodeLang::Vue) => l.name.clone(),
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
(Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name),
(Some(l), CodeLang::Flutter) => format!("{}()", l.name),
(Some(l), _) => format!("{{ {}() }}", l.name),
(None, _) => value_expr(doc, c, p, v, lang),
}
@@ -456,7 +459,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::<String>())
}
CodeLang::Vue => format!("<{name}{} />", params.iter().map(|p| format!(" :{p}={{{p}}}")).collect::<String>()),
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
CodeLang::SwiftUi | CodeLang::Flutter => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
}
@@ -478,6 +481,7 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
CodeLang::React | CodeLang::ArkUi => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
CodeLang::Vue => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./@-".contains(ch)),
CodeLang::Flutter => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./:-".contains(ch)),
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
};
if path.chars().count() > MAX_IMPORT || !path_ok {
@@ -486,6 +490,9 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
CodeLang::ArkUi => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"),
CodeLang::Vue => format!("importPath: a module path (letters, digits, _ . / @ and -), up to {MAX_IMPORT} characters"),
CodeLang::Flutter => {
format!("importPath: a Dart import (package:acme/card.dart; letters, digits, _ . / : and -), up to {MAX_IMPORT} characters")
}
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
});
}
@@ -521,7 +528,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
/// The `language` parameter (default React).
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
match text(cmd, p, "language")? {
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui")),
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui|flutter")),
None => Ok(CodeLang::React),
}
}
@@ -551,7 +558,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"codeConnect.set",
"Connect to code",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui|flutter (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
true,
|s, p| {
let cmd = "codeConnect.set";
@@ -666,7 +673,10 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let (Some(component), Some(lang), Some(name), Some(template)) =
(field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str))
else {
warnings.push(format!("link {}: needs component, language (react|leptos|vue|swiftui|compose|arkui), name and template strings", i + 1));
warnings.push(format!(
"link {}: needs component, language (react|leptos|vue|swiftui|compose|arkui|flutter), name and template strings",
i + 1
));
continue;
};
let link = CodeLink {
+129 -7
View File
@@ -1,7 +1,7 @@
//! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the
//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS;
//! stacks for auto layout, absolute offsets otherwise). Components become components whose
//! properties are parameters and instances become calls passing their values (see
//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose, ArkUI (HarmonyOS) or
//! Flutter (stacks for auto layout, absolute offsets otherwise). Components become components
//! whose properties are parameters and instances become calls passing their values (see
//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The
//! output is a starting point, not a pixel-exact translation.
@@ -20,6 +20,7 @@ use crate::{bad, spec};
mod arkui;
mod compose;
mod css;
mod flutter;
mod html;
mod swift;
@@ -30,6 +31,7 @@ pub(crate) use css::Tokens;
pub(crate) use css::css_decls;
use css::{css, tok};
pub use css::{css_var_name, css_var_of};
pub(crate) use flutter::{FLUTTER_IMPORT, Place, flutter, flutter_indent};
use html::html;
pub(crate) use html::{Markup, class_name};
pub(crate) use swift::swift;
@@ -38,13 +40,15 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.code",
"Code for layer",
"{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
"{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui|flutter (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
false,
|s, p| {
let cmd = "inspect.code";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui"))?,
Some(l) => {
CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui|flutter"))?
}
None => CodeLang::Css,
};
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
@@ -96,10 +100,12 @@ pub enum CodeLang {
Compose,
/// HarmonyOS ArkUI: an ArkTS struct (`@Component`, `build`).
ArkUi,
/// A Flutter `StatelessWidget` (Dart) with its widget tree.
Flutter,
}
impl CodeLang {
pub const ALL: [CodeLang; 9] = [
pub const ALL: [CodeLang; 10] = [
CodeLang::Css,
CodeLang::Html,
CodeLang::React,
@@ -109,6 +115,7 @@ impl CodeLang {
CodeLang::SwiftUi,
CodeLang::Compose,
CodeLang::ArkUi,
CodeLang::Flutter,
];
pub fn id(self) -> &'static str {
match self {
@@ -121,6 +128,7 @@ impl CodeLang {
CodeLang::SwiftUi => "swiftui",
CodeLang::Compose => "compose",
CodeLang::ArkUi => "arkui",
CodeLang::Flutter => "flutter",
}
}
pub fn label(self) -> &'static str {
@@ -134,6 +142,7 @@ impl CodeLang {
CodeLang::SwiftUi => "SwiftUI",
CodeLang::Compose => "Compose",
CodeLang::ArkUi => "ArkUI",
CodeLang::Flutter => "Flutter",
}
}
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
@@ -141,6 +150,11 @@ impl CodeLang {
pub fn is_web(self) -> bool {
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Vue)
}
/// Languages whose strings interpolate `$` (Kotlin's `$name`, Dart's `$name`), where a literal
/// `$` has to be escaped.
pub fn escapes_dollar(self) -> bool {
matches!(self, CodeLang::Compose | CodeLang::Flutter)
}
pub fn from_id(s: &str) -> Option<CodeLang> {
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
return Some(CodeLang::React);
@@ -151,6 +165,9 @@ impl CodeLang {
if s.eq_ignore_ascii_case("arkts") {
return Some(CodeLang::ArkUi);
}
if s.eq_ignore_ascii_case("dart") {
return Some(CodeLang::Flutter);
}
CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s))
}
}
@@ -262,7 +279,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
CodeLang::Css => css(doc, id),
CodeLang::Html => html(doc, id),
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => {
CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi | CodeLang::Flutter => {
crate::component_codegen::code(doc, id, lang)
}
}
@@ -549,6 +566,111 @@ mod tests {
assert!(ark.contains(&format!(".position({{ x: {}, y: {} }})", num(x), num(y))), "the absolute child keeps its place: {ark}");
}
#[test]
fn flutter_builds_a_widget_tree() {
let mut s = Session::new();
let f = card(&mut s);
let doc = s.doc().unwrap();
let dart = code_for(doc, f, CodeLang::Flutter);
// The card hugs its content, so its width is the layout's, not the 200 it was created with.
let w = doc.node(f).map(|n| n.width).unwrap_or_default();
assert!(dart.starts_with(&format!("Container(\n width: {},\n height: 100,\n", num(w))), "{dart}");
assert!(dart.contains("borderRadius: BorderRadius.circular(12)"), "{dart}");
assert!(dart.contains("child: Row(\n"), "{dart}");
assert!(dart.contains(" Text(\n \"Say \\\"hi\\\" \\$5\\nnow\",\n"), "escaped: {dart}");
assert!(dart.contains("fontWeight: FontWeight.w400,"), "{dart}");
assert!(dart.contains("shape: BoxShape.circle"), "{dart}");
assert!(dart.ends_with(')'), "a bare expression, like the other native targets: {dart}");
// Through the command: `dart` names the same language, and rem is for the web only.
let v = s.execute("inspect.code", &json!({"id": f, "language": "flutter"})).unwrap();
assert_eq!(v["code"].as_str().unwrap(), dart);
assert_eq!(v["language"], "flutter");
assert_eq!(s.execute("inspect.code", &json!({"id": f, "language": "dart"})).unwrap()["language"], "flutter");
let rem = s.execute("inspect.code", &json!({"id": f, "language": "flutter", "unit": "rem"})).unwrap();
assert_eq!(rem["code"].as_str().unwrap(), dart, "rem doesn't apply to Dart");
}
#[test]
fn flutter_positions_free_children_and_bounds_the_tree() {
let mut s = Session::new();
let root = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 5, "height": 5, "parent": root})).unwrap();
let mut parent = root;
for _ in 0..20 {
parent = id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent})).unwrap()["id"]).unwrap();
}
let dart = code_for(s.doc().unwrap(), root, CodeLang::Flutter);
assert!(dart.contains("Stack(\n alignment: Alignment.topLeft,\n"), "{dart}");
assert!(dart.contains("Positioned(\n left: 7,\n top: 9,\n"), "{dart}");
// A wrapper's closing parenthesis sits on its own line, not glued to the widget's.
assert!(dart.contains(" )\n ),\n"), "{dart}");
assert!(dart.contains("…"), "the depth is capped: {dart}");
assert!(dart.contains("const SizedBox()"), "a capped layer is still a widget in the list: {dart}");
}
#[test]
fn flutter_keeps_a_capped_layers_name_on_its_comment_line() {
let mut s = Session::new();
let root = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
let mut parent = root;
for _ in 0..20 {
let p = json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": "deep\nvoid main() {}"});
parent = id_of(&s.execute("frame.create", &p).unwrap()["id"]).unwrap();
}
let dart = code_for(s.doc().unwrap(), root, CodeLang::Flutter);
assert!(dart.contains("…"), "the depth is capped: {dart}");
assert!(!dart.lines().any(|l| l.trim_start().starts_with("void main()")), "a name never leaves its comment: {dart}");
}
#[test]
fn flutter_expands_and_stretches_fill_children() {
let mut s = Session::new();
let row = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 40})).unwrap()["id"]).unwrap();
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": row})).unwrap()["id"]).unwrap();
s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 50, "height": 20, "parent": row})).unwrap();
s.execute("layout.add", &json!({"ids": [row], "direction": "horizontal"})).unwrap();
s.execute("node.set", &json!({"id": a, "props": {"sizingH": "fill", "sizingV": "fill"}})).unwrap();
let dart = code_for(s.doc().unwrap(), row, CodeLang::Flutter);
assert!(dart.contains("child: Row(\n"), "{dart}");
assert!(dart.contains("Expanded(\n"), "a child filling the main axis takes the room left: {dart}");
assert!(dart.contains("crossAxisAlignment: CrossAxisAlignment.stretch,"), "a child filling the cross axis stretches the line: {dart}");
}
#[test]
fn flutter_lifts_a_child_that_ignores_its_auto_layout_parent_out_of_the_line() {
let mut s = Session::new();
let row = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 40})).unwrap()["id"]).unwrap();
let flow = id_of(&s.execute("shape.rectangle", &json!({"x": 4, "y": 4, "width": 50, "height": 20, "parent": row})).unwrap()["id"]).unwrap();
let over = id_of(&s.execute("shape.ellipse", &json!({"x": 150, "y": 6, "width": 30, "height": 30, "parent": row})).unwrap()["id"]).unwrap();
s.execute("layout.add", &json!({"ids": [row], "direction": "horizontal"})).unwrap();
s.execute("node.set", &json!({"id": row, "props": {"padding": [4, 4, 4, 4]}})).unwrap();
// It asks for both axes, which the line would read as "stretch me and take the room left".
s.execute("node.set", &json!({"id": over, "props": {"absolute": true, "sizingH": "fill", "sizingV": "fill"}})).unwrap();
let doc = s.doc().unwrap();
let dart = code_for(doc, row, CodeLang::Flutter);
let at = |needle: &str| dart.find(needle).unwrap_or_else(|| panic!("no {needle:?}: {dart}"));
// Over the line, in a Stack at the parent's own box, at its own offsets.
let [_, _, _, _, x, y] = doc.node(over).unwrap().transform.as_coeffs();
assert!(
dart.contains(&format!("Positioned(\n left: {},\n top: {},\n", num(x), num(y))),
"the absolute child keeps its place: {dart}"
);
let stack = at("child: Stack(\n");
let fill = at("Positioned.fill(\n");
let padding = at("padding: EdgeInsets.all(4),");
let line = at("child: Row(\n");
assert!(stack < fill && fill < padding && padding < line, "the line sits in the Stack, padded: {dart}");
assert!(!dart[..padding].contains("padding: "), "the parent's padding would offset the absolute child: {dart}");
// Only the flow child is in the line, so neither `Expanded` nor a stretch comes from it.
let over_at = at("\n Positioned(\n");
let line_part = &dart[line..over_at];
assert!(line_part.contains("width: 50,"), "the flow child is in the line: {dart}");
assert!(!line_part.contains("width: 30,"), "the absolute child left the line: {dart}");
assert!(!dart.contains("Expanded(\n"), "it takes no room in the line either: {dart}");
assert!(dart.contains("crossAxisAlignment: CrossAxisAlignment.start,"), "{dart}");
assert!(doc.node(flow).is_some());
}
#[test]
fn arkui_wraps_a_positioned_instance_swap_so_the_position_lands_on_a_container() {
let mut s = Session::new();
+364
View File
@@ -0,0 +1,364 @@
use figcraft_doc::AutoLayout;
use super::swift::one_line;
use super::{
CodeLang, CrossAlign, Ctx, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Param, Sizing, TextAlignH, TextDecoration,
drop_shadow, first_fill, kotlin_color, literal, num, radius, stroke_color,
};
// ------------------------------------------------------------------ Flutter (Dart)
/// The import a generated Flutter unit starts with (a widget tree on its own needs it too).
pub(crate) const FLUTTER_IMPORT: &str = "import 'package:flutter/material.dart';\n\n";
/// Dart indents by two spaces.
pub(crate) fn flutter_indent(depth: usize) -> String {
" ".repeat(depth)
}
/// Where a child sits in its parent: in a Stack at its position in the frame, taking the room left
/// in a Row or Column, or wherever the parent puts it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Place {
Flow,
At(f64, f64),
Fill,
}
/// A child and where its parent puts it.
type Placed = (NodeId, Place);
/// Dart's `FontWeight.w600` (a weight is hundreds).
fn flutter_weight(w: u16) -> String {
format!("FontWeight.w{}", (w.clamp(100, 900) / 100) * 100)
}
/// A Row or Column's main-axis alignment (`None` for the default, the start).
fn flutter_main_align(a: MainAlign) -> Option<&'static str> {
match a {
MainAlign::Min => None,
MainAlign::Center => Some("MainAxisAlignment.center"),
MainAlign::Max => Some("MainAxisAlignment.end"),
MainAlign::SpaceBetween => Some("MainAxisAlignment.spaceBetween"),
MainAlign::SpaceAround => Some("MainAxisAlignment.spaceAround"),
MainAlign::SpaceEvenly => Some("MainAxisAlignment.spaceEvenly"),
}
}
/// A Row or Column's cross-axis alignment; a flow child that fills it stretches the line instead.
fn flutter_cross_align(a: CrossAlign, stretch: bool) -> &'static str {
if stretch {
return "CrossAxisAlignment.stretch";
}
match a {
CrossAlign::Min => "CrossAxisAlignment.start",
CrossAlign::Center => "CrossAxisAlignment.center",
CrossAlign::Max => "CrossAxisAlignment.end",
CrossAlign::Baseline => "CrossAxisAlignment.baseline",
}
}
/// A frame's auto layout padding as `EdgeInsets` (`None` when it has none).
fn flutter_padding(n: &Node) -> Option<String> {
let l = n.layout.as_ref()?;
let [t, r, b, left] = l.padding;
if ![t, r, b, left].iter().any(|v| *v > 0.0) {
return None;
}
Some(if t == r && r == b && b == left {
format!("EdgeInsets.all({})", num(t))
} else if t == b && left == r {
format!("EdgeInsets.symmetric(vertical: {}, horizontal: {})", num(t), num(left))
} else {
format!("EdgeInsets.fromLTRB({}, {}, {}, {})", num(left), num(t), num(r), num(b))
})
}
/// A layer's fill, shape, corner radius, stroke and shadow as a `BoxDecoration`; `None` when it has
/// none of them. `pad` is the indentation of the constructor itself.
fn box_decoration(n: &Node, pad: &str) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(c) = first_fill(n) {
parts.push(format!("color: {}", kotlin_color(c)));
}
if matches!(n.kind, NodeKind::Ellipse { .. }) {
parts.push("shape: BoxShape.circle".into());
} else if radius(n) > 0.0 {
parts.push(format!("borderRadius: BorderRadius.circular({})", num(radius(n))));
}
if let Some(c) = stroke_color(n) {
parts.push(format!("border: Border.all(color: {}, width: {})", kotlin_color(c), num(n.stroke.weight)));
}
if let Some(s) = drop_shadow(n) {
parts.push(format!(
"boxShadow: [BoxShadow(color: {}, blurRadius: {}, offset: Offset({}, {}))]",
kotlin_color(s.color),
num(s.radius / 2.0),
num(s.offset.0),
num(s.offset.1)
));
}
if parts.is_empty() {
return None;
}
let inner = format!("{pad} ");
Some(format!("BoxDecoration(\n{}{pad})", parts.iter().map(|p| format!("{inner}{p},\n")).collect::<String>()))
}
/// Flutter for a layer and its subtree at `depth`. A layer a boolean parameter shows sits behind an
/// `if (…)`: Dart's lists take conditions, so the element itself carries it.
pub(crate) fn flutter(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, place: Place) {
let Some(n) = ctx.doc.node(id) else { return };
if id != ctx.root
&& let Some(p) = ctx.param(n, "visible")
{
out.push_str(&format!("{}if ({})\n", flutter_indent(depth), p.ident));
flutter_placed(ctx, n, depth + 1, out, place);
return;
}
flutter_placed(ctx, n, depth, out, place);
}
/// A layer at `depth` and what wraps it: where its parent puts it, then its opacity. Every closing
/// parenthesis sits at its own level, so the caller can end the element with a comma in a list.
fn flutter_placed(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, place: Place) {
if ctx.too_deep(depth) {
let pad = flutter_indent(depth);
out.push_str(&format!("{pad}// {} …\n{pad}const SizedBox()", one_line(&n.name)));
return;
}
let mut depth = depth;
let mut close: Vec<String> = Vec::new();
match place {
Place::Flow => {}
Place::At(x, y) => {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
out.push_str(&format!("{pad}Positioned(\n{inner}left: {},\n{inner}top: {},\n{inner}child: ", num(x), num(y)));
close.push(format!("\n{pad})"));
depth += 1;
}
Place::Fill => {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
out.push_str(&format!("{pad}Expanded(\n{inner}child: "));
close.push(format!("\n{pad})"));
depth += 1;
}
}
if n.opacity < 1.0 {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
out.push_str(&format!("{pad}Opacity(\n{inner}opacity: {},\n{inner}child: ", num(f64::from(n.opacity))));
close.push(format!("\n{pad})"));
depth += 1;
}
// A wrapper leaves its line open ("child: "), so the widget carries no indentation of its own.
let mut body = String::new();
flutter_widget(ctx, n, depth, &mut body);
out.push_str(if close.is_empty() { body.as_str() } else { body.trim_start() });
for c in close.iter().rev() {
out.push_str(c);
}
}
/// A layer's own look and its subtree as a Flutter widget at `depth`: a complete expression whose
/// closing parenthesis sits at `depth`.
fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
// Nested instances are calls; an instance swap parameter is a widget.
let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
Some(p) => Some(p.ident.clone()),
None => ctx.call(n, CodeLang::Flutter, depth).map(|(code, _)| code),
};
if let Some(call) = call {
out.push_str(call.trim_end_matches('\n'));
return;
}
match &n.kind {
NodeKind::Text(t) => {
let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone());
out.push_str(&format!("{pad}Text(\n{inner}{text},\n"));
match t.align_h {
TextAlignH::Left => {}
TextAlignH::Center => out.push_str(&format!("{inner}textAlign: TextAlign.center,\n")),
TextAlignH::Right => out.push_str(&format!("{inner}textAlign: TextAlign.right,\n")),
TextAlignH::Justified => out.push_str(&format!("{inner}textAlign: TextAlign.justify,\n")),
}
let s = &t.style;
let mut style: Vec<String> = Vec::new();
if !s.font_family.is_empty() {
style.push(format!("fontFamily: {}", literal(&s.font_family, true)));
}
style.push(format!("fontSize: {}", num(s.font_size)));
style.push(format!("fontWeight: {}", flutter_weight(s.weight())));
if let Some(c) = first_fill(n) {
style.push(format!("color: {}", kotlin_color(c)));
}
if s.italic() {
style.push("fontStyle: FontStyle.italic".into());
}
match s.line_height {
// Dart's height is a multiple of the font size, not a length.
LineHeight::Pixels(v) if v > 0.0 && s.font_size > 0.0 => style.push(format!("height: {}", num(v / s.font_size))),
LineHeight::Percent(p) if p > 0.0 => style.push(format!("height: {}", num(p / 100.0))),
_ => {}
}
match s.letter_spacing {
LetterSpacing::Pixels(v) if v != 0.0 => style.push(format!("letterSpacing: {}", num(v))),
LetterSpacing::Percent(p) if p != 0.0 && s.font_size > 0.0 => style.push(format!("letterSpacing: {}", num(p / 100.0 * s.font_size))),
_ => {}
}
match s.decoration {
TextDecoration::None => {}
TextDecoration::Underline => style.push("decoration: TextDecoration.underline".into()),
TextDecoration::Strikethrough => style.push("decoration: TextDecoration.lineThrough".into()),
}
let sp = flutter_indent(depth + 2);
out.push_str(&format!("{inner}style: TextStyle(\n{}{inner}),\n{pad})", style.iter().map(|x| format!("{sp}{x},\n")).collect::<String>()));
}
NodeKind::Ellipse { .. } | NodeKind::Rectangle => {
out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
if let Some(d) = box_decoration(n, &inner) {
out.push_str(&format!("{inner}decoration: {d},\n"));
}
out.push_str(&format!("{pad})"));
}
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
let children = ctx.kids(n);
let slot = ctx.param(n, "slot");
// The children block says whether it kept the padding for itself (an absolute child
// puts the line in a Stack at the parent's own box).
let mut kids = String::new();
let padded_inside = !(children.is_empty() && slot.is_none()) && flutter_children(ctx, n, &children, slot, depth + 1, &mut kids);
out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
if !padded_inside && let Some(p) = flutter_padding(n) {
out.push_str(&format!("{inner}padding: {p},\n"));
}
if let Some(d) = box_decoration(n, &inner) {
out.push_str(&format!("{inner}decoration: {d},\n"));
}
if children.is_empty() && slot.is_none() {
out.push_str(&format!("{pad})"));
return;
}
out.push_str(&format!("{inner}alignment: Alignment.topLeft,\n{inner}child: "));
out.push_str(kids.trim_start());
out.push_str(&format!(",\n{pad})"));
}
_ => out.push_str(&format!(
"{pad}// {}: export as an image asset\n{pad}Image.asset({}, width: {}, height: {})",
n.kind.label(),
literal(&format!("{}.png", n.name), true),
num(n.width),
num(n.height)
)),
}
}
/// The Row, Column or Stack holding a frame-like layer's children, at `depth`. Returns whether it
/// took the parent's padding over (see [`flutter_widget`]).
fn flutter_children(ctx: &Ctx, n: &Node, children: &[NodeId], slot: Option<&Param>, depth: usize, out: &mut String) -> bool {
let doc = ctx.doc;
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
// Where each child sits, and whether one of the children in the line asks it to stretch.
let mut placed: Vec<Placed> = Vec::new();
let mut stretch = false;
for c in children {
let Some(cn) = doc.node(*c) else { continue };
let place = match n.layout.as_ref() {
// A child that ignores its auto layout parent keeps its own offsets: it leaves the
// line, so it neither stretches it nor takes the room left in it.
Some(_) if cn.child_layout.absolute => {
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
Place::At(x, y)
}
Some(l) => {
let vertical = l.mode == LayoutMode::Vertical;
let (main, cross) = if vertical {
(cn.child_layout.sizing_v, cn.child_layout.sizing_h)
} else {
(cn.child_layout.sizing_h, cn.child_layout.sizing_v)
};
stretch |= cross == Sizing::Fill;
if main == Sizing::Fill { Place::Fill } else { Place::Flow }
}
// Free children sit at their position in the frame.
None => {
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
Place::At(x, y)
}
};
placed.push((*c, place));
}
let Some(l) = n.layout.as_ref() else {
out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n{inner}children: [\n"));
flutter_items(ctx, &placed, slot, depth + 2, out);
out.push_str(&format!("{inner}],\n{pad})"));
return false;
};
let (over, flow): (Vec<Placed>, Vec<Placed>) = placed.into_iter().partition(|(_, p)| matches!(p, Place::At(..)));
if over.is_empty() {
out.push_str(&flutter_line(ctx, l, &flow, slot, stretch, depth));
return false;
}
// A child at its own offsets goes over the line, in a Stack at the parent's own box, so its
// offsets stay the parent's and the line keeps the parent's padding.
let pad_edges = flutter_padding(n);
let list = flutter_indent(depth + 2);
let line_depth = depth + 3 + usize::from(pad_edges.is_some());
out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n{inner}children: [\n"));
if !flow.is_empty() || slot.is_some() {
// Filling the Stack gives the line the parent's size, which `Expanded` children need.
out.push_str(&format!("{list}Positioned.fill(\n{list} child: "));
match pad_edges {
Some(p) => {
out.push_str(&format!("Padding(\n{list} padding: {p},\n{list} child: "));
out.push_str(flutter_line(ctx, l, &flow, slot, stretch, line_depth).trim_start());
out.push_str(&format!(",\n{list} )"));
}
None => out.push_str(flutter_line(ctx, l, &flow, slot, stretch, line_depth).trim_start()),
}
out.push_str(&format!(",\n{list}),\n"));
}
flutter_items(ctx, &over, None, depth + 2, out);
out.push_str(&format!("{inner}],\n{pad})"));
true
}
/// The Row or Column holding a frame's flow children, at `depth`: a complete widget expression.
fn flutter_line(ctx: &Ctx, l: &AutoLayout, flow: &[Placed], slot: Option<&Param>, stretch: bool, depth: usize) -> String {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
let mut out = format!("{pad}{}(\n", if l.mode == LayoutMode::Vertical { "Column" } else { "Row" });
if let Some(m) = flutter_main_align(l.main_align) {
out.push_str(&format!("{inner}mainAxisAlignment: {m},\n"));
}
out.push_str(&format!("{inner}crossAxisAlignment: {},\n", flutter_cross_align(l.cross_align, stretch)));
if !stretch && matches!(l.cross_align, CrossAlign::Baseline) {
out.push_str(&format!("{inner}textBaseline: TextBaseline.alphabetic,\n"));
}
// The gap between the children; the space distributions already spread them.
if l.gap > 0.0 && flutter_main_align(l.main_align).is_none_or(|m| !m.contains("Space")) {
out.push_str(&format!("{inner}spacing: {},\n", num(l.gap)));
}
out.push_str(&format!("{inner}children: [\n"));
flutter_items(ctx, flow, slot, depth + 2, &mut out);
out.push_str(&format!("{inner}],\n{pad})"));
out
}
/// A children list (`[` already open) at `depth`; a slot shows the instance's content, or else the
/// main's.
fn flutter_items(ctx: &Ctx, kids: &[Placed], slot: Option<&Param>, depth: usize, out: &mut String) {
let list = flutter_indent(depth);
if let Some(p) = slot {
let name = &p.ident;
out.push_str(&format!("{list}...?{name},\n"));
out.push_str(&format!("{list}if ({name} == null) ...[\n"));
}
let kid = depth + usize::from(slot.is_some());
for (id, place) in kids {
flutter(ctx, *id, kid, out, *place);
out.push_str(",\n");
}
if slot.is_some() {
out.push_str(&format!("{list}],\n"));
}
}
+136 -22
View File
@@ -1,17 +1,17 @@
//! Component code: a component (or component set) becomes a React, Leptos, Vue, SwiftUI, Jetpack
//! Compose or ArkUI component whose properties are its parameters (a variant property → a union or
//! enum, boolean → a flag showing the layers bound to it, text → a string, instance swap → a view
//! parameter, slot → optional content), and an instance becomes a call passing the values it sets.
//! Variants of a set share one definition (the set's default variant); in React and Leptos their
//! differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode` and
//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its
//! real snippet instead.
//! Compose, ArkUI or Flutter component whose properties are its parameters (a variant property → a
//! union or enum, boolean → a flag showing the layers bound to it, text → a string, instance swap →
//! a view parameter, slot → optional content), and an instance becomes a call passing the values it
//! sets. Variants of a set share one definition (the set's default variant); in React and Leptos
//! their differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode`
//! and used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows
//! its real snippet instead.
use figcraft_doc::{Document, NodeId, NodeKind, PropDef};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift};
use crate::codegen::{CodeLang, Ctx, FLUTTER_IMPORT, Markup, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift};
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
use crate::params::{id_of, string, targets};
use crate::{bad, spec};
@@ -68,14 +68,14 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.componentCode",
"Component code",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui|flutter (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
false,
|s, p| {
let cmd = "inspect.componentCode";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
let lang = match string(p, "language") {
// One list of code languages, shared with codeConnect (see `code_connect::language`).
Some(l) => crate::code_connect::language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui"))?,
Some(l) => crate::code_connect::language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui|flutter"))?,
None => CodeLang::React,
};
let doc = s.doc()?;
@@ -108,10 +108,10 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// Code for any layer in React, Leptos, Vue, SwiftUI, Compose or ArkUI: an instance's call and its
/// component's definition, a component's (or set's) definition, or the layer tree (React and
/// Leptos wrap it in a function component), with the imports of the connected components it calls
/// at the top.
/// Code for any layer in React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter: an instance's call
/// and its component's definition, a component's (or set's) definition, or the layer tree (React
/// and Leptos wrap it in a function component), with the imports of the connected components it
/// calls at the top.
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
let Some(n) = doc.node(id) else { return String::new() };
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
@@ -119,7 +119,15 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
}
let comp = main_of(doc, id).and_then(|m| describe(doc, m));
let code = match (&n.kind, comp) {
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
(NodeKind::Instance { .. }, Some(c)) => {
let call = usage(doc, id, lang, 0).unwrap_or_default();
let def = definition(doc, &c, lang);
match def.strip_prefix(FLUTTER_IMPORT) {
// Dart's imports come before the code that uses them.
Some(rest) if lang == CodeLang::Flutter => format!("{FLUTTER_IMPORT}{call}\n{rest}"),
_ => format!("{call}\n{def}"),
}
}
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
_ => match lang {
CodeLang::React | CodeLang::Leptos | CodeLang::Vue => {
@@ -140,6 +148,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
arkui(&Ctx::new(doc, id), id, 0, &mut out, &[]);
out
}
CodeLang::Flutter => {
let mut out = String::new();
flutter(&Ctx::new(doc, id), id, 0, &mut out, Place::Flow);
out
}
_ => {
let mut out = String::new();
compose(&Ctx::new(doc, id), id, 0, &mut out);
@@ -328,24 +341,33 @@ const RESERVED: &[&str] = &[
"yield",
];
/// Types the generated SwiftUI, Compose and ArkUI code names: a variant enum may not shadow them.
/// Types the generated SwiftUI, Compose, ArkUI and Flutter code names: a variant enum may not shadow them.
const TYPES: &[&str] = &[
"Alignment",
"AnyView",
"Arrangement",
"Bool",
"Boolean",
"Border",
"BorderRadius",
"BorderSide",
"Box",
"BoxDecoration",
"BoxShadow",
"BoxShape",
"BuildContext",
"CaseIterable",
"Circle",
"CircleShape",
"Color",
"Column",
"Composable",
"CrossAxisAlignment",
"Edge",
"EdgeInsets",
"Ellipse",
"EmptyView",
"Expanded",
"FlexAlign",
"Font",
"FontStyle",
@@ -354,19 +376,28 @@ const TYPES: &[&str] = &[
"HStack",
"HorizontalAlign",
"Image",
"MainAxisAlignment",
"Modifier",
"Offset",
"Opacity",
"Positioned",
"Rect",
"RectangleShape",
"RoundedCornerShape",
"RoundedRectangle",
"Row",
"Shape",
"SizedBox",
"Stack",
"StatelessWidget",
"String",
"Text",
"TextAlign",
"TextBaseline",
"TextCase",
"TextDecoration",
"TextDecorationType",
"TextStyle",
"Unit",
"VStack",
"VerticalAlign",
@@ -481,6 +512,13 @@ fn cases(values: &[String], swift: bool) -> Vec<String> {
.collect()
}
/// Enum member names for variant values in Dart: lowerCamel, unique, never a keyword (a `default`
/// value becomes `defaultValue`).
fn dart_cases(values: &[String]) -> Vec<String> {
let mut used = Vec::new();
values.iter().map(|v| ident(v, &mut used)).collect()
}
/// A Rust string literal.
pub(crate) fn rust_string(s: &str) -> String {
format!("{:?}", s.chars().take(10_000).collect::<String>())
@@ -533,7 +571,7 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
let i = opts.iter().position(|o| Some(o.as_str()) == value.as_str()).unwrap_or(0);
format!("{}::{}", leptos_enum(c, p), rust_cases(opts).get(i).cloned().unwrap_or_else(|| "Unknown".into()))
}
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l.escapes_dollar()),
(ParamKind::Variant(opts), l) => {
let v = value.as_str().unwrap_or("");
if l == CodeLang::Vue {
@@ -542,10 +580,12 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
if l == CodeLang::React {
return js_string(v);
}
let swift = l == CodeLang::SwiftUi;
let i = opts.iter().position(|o| o == v).unwrap_or(0);
let case = cases(opts, swift).get(i).cloned().unwrap_or_else(|| "unknown".into());
if swift { format!(".{case}") } else { format!("{}.{case}", enum_name(c, p, false)) }
match l {
CodeLang::SwiftUi => format!(".{}", cases(opts, true).get(i).cloned().unwrap_or_else(|| "unknown".into())),
CodeLang::Flutter => format!("{}.{}", enum_name(c, p, false), dart_cases(opts).get(i).cloned().unwrap_or_else(|| "unknown".into())),
_ => format!("{}.{}", enum_name(c, p, false), cases(opts, false).get(i).cloned().unwrap_or_else(|| "Unknown".into())),
}
}
(ParamKind::InstanceSwap, l) => {
let t = id_of(value).and_then(|i| component_type(doc, i));
@@ -556,12 +596,14 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
(None, CodeLang::Leptos) => "|| ()".into(),
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
(Some(t), CodeLang::Flutter) => format!("{t}()"),
// ArkUI takes a builder: `@BuilderParam` is a `() => void`.
(Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"),
(None, CodeLang::ArkUi) => "() => {}".into(),
(Some(t), _) => format!("{{ {t}() }}"),
(None, _) if js => "null".into(),
(None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(),
(None, CodeLang::Flutter) => "const SizedBox.shrink()".into(),
(None, _) => "{}".into(),
}
}
@@ -616,7 +658,7 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option<(String, String)> {
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
let values = instance_values(doc, id, &c)?;
let pad = if matches!(lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) };
let pad = if matches!(lang, CodeLang::React | CodeLang::Vue | CodeLang::Flutter) { " ".repeat(depth) } else { indent(depth) };
// A connected component: its real snippet (its import is at the top of the code: `code`).
if let Some(link) = crate::code_connect::link(doc, id, lang) {
return Some((crate::code_connect::render(doc, &c, &values, link, lang, &pad), String::new()));
@@ -684,7 +726,9 @@ pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, dept
.collect();
if slots.is_empty() { format!("{pad}<{t}{attrs} />\n") } else { format!("{pad}<{t}{attrs}>\n{slots}{pad}</{t}>\n") }
}
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
CodeLang::SwiftUi | CodeLang::Flutter => {
format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", "))
}
CodeLang::ArkUi => {
let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ");
// ArkTS calls take an object literal, or nothing at all.
@@ -743,6 +787,15 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize, pref
}
(format!("() => {{\n{out}{}}}", indent(depth + 1)), String::new())
}
CodeLang::Flutter => {
// A slot holds a list of widgets, so it takes however many layers the slot has.
let mut out = String::new();
for k in kids {
flutter(&ctx, k, depth + 1, &mut out, Place::Flow);
out.push_str(",\n");
}
(format!("[\n{out}{}]", flutter_indent(depth)), String::new())
}
_ => {
let mut out = String::new();
for k in kids {
@@ -760,6 +813,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
match lang {
CodeLang::SwiftUi => swift_definition(doc, c),
CodeLang::Compose => compose_definition(doc, c),
CodeLang::Flutter => flutter_definition(doc, c),
CodeLang::Leptos => leptos(doc, c),
CodeLang::Vue => vue(doc, c),
CodeLang::ArkUi => arkui_definition(doc, c),
@@ -994,6 +1048,66 @@ fn compose_definition(doc: &Document, c: &Component) -> String {
out
}
/// A Flutter `StatelessWidget`: variant enums, a const constructor with defaults, and the widget
/// tree in `build`.
fn flutter_definition(doc: &Document, c: &Component) -> String {
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
let mut out = String::from(FLUTTER_IMPORT);
for p in &c.params {
if let ParamKind::Variant(opts) = &p.kind {
out.push_str(&format!("enum {} {{ {} }}\n\n", enum_name(c, p, false), dart_cases(opts).join(", ")));
}
}
let t = &c.type_name;
out.push_str(&format!("class {t} extends StatelessWidget {{\n"));
if c.params.is_empty() {
out.push_str(&format!(" const {t}({{super.key}});\n"));
} else {
out.push_str(&format!(" const {t}({{\n super.key,\n"));
for p in &c.params {
match flutter_default(doc, c, p) {
Some(d) => out.push_str(&format!(" this.{} = {d},\n", p.ident)),
None => out.push_str(&format!(" this.{},\n", p.ident)),
}
}
out.push_str(" });\n\n");
for p in &c.params {
out.push_str(&format!(" final {} {};\n", flutter_type(c, p), p.ident));
}
}
out.push_str("\n @override\n Widget build(BuildContext context) {\n return ");
let mut body = String::new();
flutter(&ctx, c.comp, 2, &mut body, Place::Flow);
out.push_str(body.trim_start());
out.push_str(";\n }\n}\n");
out
}
/// A parameter's Dart type.
fn flutter_type(c: &Component, p: &Param) -> String {
match &p.kind {
ParamKind::Variant(_) => enum_name(c, p, false),
ParamKind::Boolean => "bool".into(),
ParamKind::Text => "String".into(),
ParamKind::InstanceSwap => "Widget".into(),
// A slot holds however many layers it was filled with.
ParamKind::Slot => "List<Widget>?".into(),
}
}
/// A parameter's default as a Dart constant (`None` for a slot, which has none): Dart wants its
/// defaults to be constants, so an instance swap is a `const` widget.
fn flutter_default(doc: &Document, c: &Component, p: &Param) -> Option<String> {
match &p.kind {
ParamKind::Slot => None,
ParamKind::InstanceSwap => Some(match id_of(&p.default).and_then(|i| component_type(doc, i)) {
Some(t) => format!("const {t}()"),
None => "const SizedBox.shrink()".into(),
}),
_ => Some(value_expr(doc, c, p, &p.default, CodeLang::Flutter)),
}
}
/// An ArkUI (`@Component`) struct: variant enums, `@Prop` parameters, a default builder for each
/// instance swap and slot, and the `build` body.
fn arkui_definition(doc: &Document, c: &Component) -> String {
+1 -1
View File
@@ -188,7 +188,7 @@ fn threads_replies_and_resolving() {
|c| c.replies.iter().map(|m| m.text.as_str()).eq(["Sure", "Done"]),
"replies stay in the order they were written",
),
("comment.resolve", json!({"id": c, "author": "Sam"}), |c| c.resolved, "anyone in the file can resolve a thread, not just its author"),
("comment.resolve", json!({"id": c}), |c| c.resolved, "anyone in the file can resolve a thread, not just its author (it takes no author)"),
("comment.resolve", json!({"id": c, "resolved": false}), |c| !c.resolved, "a resolved thread can be reopened"),
("comment.resolve", json!({"id": c}), |c| c.resolved, "resolving again marks it resolved"),
(
+9 -4
View File
@@ -1,8 +1,9 @@
//! Inspect behaviour suite, written from the public documentation of design tools' inspect and
//! developer handoff modes: the reported layout (size, position, rotation, radius, opacity, blend),
//! colour formats, units, typography, the generated CSS, SwiftUI and Compose for common layers,
//! the asset list and redline measurements. Each rule is a table row or an assert with a one-line
//! derivation; where the documentation is silent the row says so and pins today's behaviour.
//! colour formats, units, typography, the generated CSS, SwiftUI, Compose and Flutter for common
//! layers, the asset list and redline measurements. Each rule is a table row or an assert with a
//! one-line derivation; where the documentation is silent the row says so and pins today's
//! behaviour.
// The tables read best as tuples.
#![allow(clippy::type_complexity)]
@@ -241,12 +242,16 @@ fn native_code_for_a_simple_layer() {
set(&mut s, &[r], json!({"cornerRadius": 8}));
let swift = code(&mut s, r, json!({"language": "swiftui"}));
let compose = code(&mut s, r, json!({"language": "compose"}));
let flutter = code(&mut s, r, json!({"language": "flutter"}));
// (code, expected fragment, derivation)
let rows: [(&str, &str, &str); 4] = [
let rows: [(&str, &str, &str); 7] = [
(&swift, "frame(width: 100, height: 50)", "SwiftUI sizes a view with frame, in points"),
(&swift, "Color(red: 1, green: 0, blue: 0, opacity: 1)", "docs silent: the fill as a SwiftUI colour"),
(&compose, "Modifier.size(100.dp, 50.dp)", "Compose sizes in dp"),
(&compose, "RoundedCornerShape(8.dp)", "docs silent: corner radius as a rounded shape"),
(&flutter, "width: 100,\n height: 50,", "Flutter sizes a Container in logical pixels"),
(&flutter, "Color(0xFFFF0000)", "docs silent: the fill as a Flutter colour"),
(&flutter, "borderRadius: BorderRadius.circular(8)", "docs silent: corner radius as a rounded border"),
];
for (c, want, why) in rows {
assert!(c.contains(want), "{want}: {why}\n{c}");
+6
View File
@@ -353,6 +353,8 @@ mod menu_gaps_tests;
#[cfg(test)]
mod nested_override_tests;
#[cfg(test)]
mod node_find_tests;
#[cfg(test)]
mod node_set_refusals_tests;
#[cfg(test)]
mod normalize_guard_tests;
@@ -385,6 +387,8 @@ mod pen_overlay_tests;
#[cfg(test)]
mod pixel_preview_tests;
#[cfg(test)]
mod placed_image_size_tests;
#[cfg(test)]
mod playground_tests;
#[cfg(test)]
mod plugin_data_tests;
@@ -497,6 +501,8 @@ mod tiff_tests;
#[cfg(test)]
mod transform_behaviour_tests;
#[cfg(test)]
mod unknown_params_tests;
#[cfg(test)]
mod variable_tests;
#[cfg(test)]
mod variables_behaviour_tests;
+2 -2
View File
@@ -158,8 +158,8 @@ fn viewport(page: NodeId, x: f64, y: f64, w: f64, h: f64) -> Presence {
#[test]
fn without_a_link_live_sessions_are_unavailable() {
let mut s = Session::new();
for cmd in ["collab.host", "collab.join"] {
let e = s.execute(cmd, &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap_err();
for (cmd, p) in [("collab.host", json!({})), ("collab.join", json!({"link": "figcraft://join?host=a:1&file=f&token=t"}))] {
let e = s.execute(cmd, &p).unwrap_err();
assert!(matches!(e, EngineError::Disabled(..)), "{cmd}: {e}");
}
assert_eq!(s.execute("collab.leave", &json!({})).unwrap()["ended"], false);
+196
View File
@@ -0,0 +1,196 @@
//! Finding layers without changing the selection, page or edit history (#996).
use figcraft_doc::*;
use serde_json::{Value, json};
use std::sync::Arc;
use crate::Session;
use crate::components::tests::{ex, idv};
fn add(s: &mut Session, parent: NodeId, kind: NodeKind, name: &str) -> NodeId {
s.doc_mut().unwrap().create(parent, kind, name, 0.0, 0.0, 100.0, 100.0).unwrap()
}
fn found(s: &mut Session, params: Value, ids: &[NodeId]) {
assert_eq!(ex(s, "node.find", params), json!({"ids": ids, "total": ids.len()}));
}
#[test]
fn node_find_filters_are_literal_case_insensitive_and_anded() {
let mut s = Session::new();
let page = s.page().unwrap();
let frame = add(&mut s, page, NodeKind::Frame, "CAFÉ [Card]");
let title = add(&mut s, frame, NodeKind::Text(TextData::new("HÉLLO 世界")), "Café Title");
let other = add(&mut s, frame, NodeKind::Text(TextData::new("Other")), "Title");
let rect = add(&mut s, page, NodeKind::Rectangle, "HÉLLO 世界");
found(&mut s, json!({"name": "CAFÉ"}), &[frame, title]);
found(&mut s, json!({"name": "[cArD]"}), &[frame]);
found(&mut s, json!({"type": "tExT"}), &[title, other]);
found(&mut s, json!({"text": "héLLo 世"}), &[title]);
found(&mut s, json!({"name": "title", "type": "TEXT", "text": "世界"}), &[title]);
found(&mut s, json!({"name": "café", "type": "RECTANGLE"}), &[]);
found(&mut s, json!({"text": "héllo", "name": "HÉLLO"}), &[]);
found(&mut s, json!({"text": ""}), &[title, other]);
found(&mut s, json!({"name": ""}), &[frame, title, other, rect]);
found(&mut s, json!({"name": ".*"}), &[]);
}
#[test]
fn node_find_matches_a_greek_final_sigma_both_ways() {
let mut s = Session::new();
let page = s.page().unwrap();
let os = add(&mut s, page, NodeKind::Text(TextData::new("ΛΟΓΟΣ")), "ΟΣ");
found(&mut s, json!({"name": "Σ"}), &[os]);
found(&mut s, json!({"name": "ς"}), &[os]);
found(&mut s, json!({"text": "λογοσ"}), &[os]);
}
#[test]
fn node_find_scope_is_preorder_strict_descendants_in_stored_order() {
let mut s = Session::new();
let page = s.page().unwrap();
let root = add(&mut s, page, NodeKind::Frame, "Root");
let first = add(&mut s, root, NodeKind::Frame, "First");
let nested = add(&mut s, first, NodeKind::Rectangle, "Nested");
let second = add(&mut s, root, NodeKind::Rectangle, "Second");
let outside = add(&mut s, page, NodeKind::Rectangle, "Outside");
s.doc_mut().unwrap().get_mut(first).unwrap().visible = false;
s.doc_mut().unwrap().get_mut(second).unwrap().locked = true;
found(&mut s, json!({}), &[root, first, nested, second, outside]);
found(&mut s, json!({"root": root}), &[first, nested, second]);
found(&mut s, json!({"root": root, "name": "root"}), &[]);
found(&mut s, json!({"root": nested}), &[]);
found(&mut s, json!({"root": page}), &[root, first, nested, second, outside]);
s.doc_mut().unwrap().reorder(second, 0).unwrap();
found(&mut s, json!({"root": root}), &[second, first, nested]);
}
#[test]
fn node_find_other_page_requires_root_and_does_not_switch_pages() {
let mut s = Session::new();
let page = s.page().unwrap();
let here = add(&mut s, page, NodeKind::Frame, "Here");
let other_page = s.doc_mut().unwrap().add_page("Other");
let root = add(&mut s, other_page, NodeKind::Frame, "There");
let child = add(&mut s, root, NodeKind::Rectangle, "Child");
found(&mut s, json!({}), &[here]);
found(&mut s, json!({"root": other_page}), &[root, child]);
found(&mut s, json!({"root": root}), &[child]);
assert_eq!(s.page().unwrap(), page);
}
#[test]
fn node_find_instances_match_main_name_and_visit_actual_children_only() {
let mut s = Session::new();
let page = s.page().unwrap();
let main = add(&mut s, page, NodeKind::Component { description: String::new(), props: Default::default() }, "CAFÉ [Button]");
let original = add(&mut s, main, NodeKind::Text(TextData::new("Original")), "Original");
let inst = idv(&ex(&mut s, "instance.create", json!({"component": main})));
let child = s.doc().unwrap().get(inst).unwrap().children[0];
ex(&mut s, "node.set", json!({"ids": [inst], "props": {"name": "Copy"}}));
ex(&mut s, "text.set", json!({"id": child, "text": "Changed"}));
let broken = add(&mut s, page, NodeKind::Instance { component: NodeId(u64::MAX), props: Default::default() }, "Missing");
found(&mut s, json!({"component": "café [bUt"}), &[inst]);
found(&mut s, json!({"component": "", "type": "instance", "name": "copy"}), &[inst]);
found(&mut s, json!({"component": "copy"}), &[]);
found(&mut s, json!({"component": "button", "text": "Changed"}), &[]);
found(&mut s, json!({"root": inst}), &[child]);
found(&mut s, json!({"root": inst, "text": "original"}), &[]);
found(&mut s, json!({"root": inst, "text": "changed"}), &[child]);
found(&mut s, json!({}), &[main, original, inst, child, broken]);
}
#[test]
fn node_find_accepts_every_canonical_type() {
let mut s = Session::new();
let page = s.page().unwrap();
let kinds = [
NodeKind::Frame,
NodeKind::Group,
NodeKind::Section { contents_hidden: false },
NodeKind::Rectangle,
NodeKind::Ellipse { start: 0.0, sweep: 1.0, inner: 0.0 },
NodeKind::Polygon { count: 3 },
NodeKind::Star { count: 5, inner_ratio: 0.5 },
NodeKind::Line,
NodeKind::Vector { network: Default::default() },
NodeKind::Text(TextData::new("Text")),
NodeKind::BooleanOp { op: BoolOp::Union },
NodeKind::Component { description: String::new(), props: Default::default() },
NodeKind::ComponentSet { description: String::new() },
NodeKind::Instance { component: NodeId(u64::MAX), props: Default::default() },
NodeKind::Slice,
];
for kind in kinds {
let name = kind.type_name();
let id = add(&mut s, page, kind, name);
assert_eq!(ex(&mut s, "node.get", json!({"id": id}))["type"], name);
found(&mut s, json!({"type": name.to_ascii_lowercase()}), &[id]);
}
found(&mut s, json!({"type": "PAGE"}), &[]);
}
#[test]
fn node_find_limit_caps_ids_not_total_and_zero_counts_only() {
let mut s = Session::new();
let page = s.page().unwrap();
let ids: Vec<_> = (0..105).map(|_| add(&mut s, page, NodeKind::Rectangle, "Match")).collect();
assert_eq!(ex(&mut s, "node.find", json!({})), json!({"ids": &ids[..100], "total": 105}));
assert_eq!(ex(&mut s, "node.find", json!({"limit": 2})), json!({"ids": &ids[..2], "total": 105}));
assert_eq!(ex(&mut s, "node.find", json!({"limit": 0})), json!({"ids": [], "total": 105}));
found(&mut s, json!({"limit": 10000}), &ids);
found(&mut s, json!({"name": "missing", "limit": 0}), &[]);
}
#[test]
fn node_find_rejects_bad_root_type_filters_and_limits() {
let mut s = Session::new();
for params in [
json!({"root": 999999}),
json!({"root": "nope"}),
json!({"root": {"id": 1}}),
json!({"type": "BOOLEANOP"}),
json!({"type": "FRAME "}),
json!({"type": ""}),
json!({"type": 3}),
json!({"name": []}),
json!({"text": true}),
json!({"component": {}}),
json!({"limit": -1}),
json!({"limit": 10001}),
json!({"limit": 1.5}),
json!({"limit": 1.0}),
json!({"limit": "2"}),
json!({"limit": true}),
json!({"limit": []}),
json!({"limit": 1e308}),
] {
assert!(s.execute("node.find", &params).is_err(), "{params}");
}
let error = s.execute("node.find", &json!({"type": "x".repeat(10000)})).unwrap_err().to_string();
assert!(error.contains("unknown type"), "{error}");
assert!(error.len() < 300, "unknown types are not echoed whole");
}
#[test]
fn node_find_leaves_document_selection_page_and_history_untouched() {
let mut s = Session::new();
let layer = idv(&ex(&mut s, "shape.rectangle", json!({"width": 10, "height": 10})));
ex(&mut s, "node.set", json!({"ids": [layer], "props": {"name": "Previous edit"}}));
ex(&mut s, "node.set", json!({"ids": [layer], "props": {"name": "Undone edit"}}));
ex(&mut s, "edit.undo", json!({}));
ex(&mut s, "select.set", json!({"ids": [layer]}));
let ds = s.ds().unwrap();
let (doc, selection, page, undo, redo) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.redo.len());
assert!(undo > 0 && redo > 0);
found(&mut s, json!({"name": "previous"}), &[layer]);
found(&mut s, json!({"name": "missing"}), &[]);
ex(&mut s, "node.find", json!({"limit": 0}));
assert!(s.execute("node.find", &json!({"root": 999999})).is_err());
let ds = s.ds().unwrap();
assert!(Arc::ptr_eq(&ds.doc, &doc));
assert_eq!((&ds.selection, ds.page, ds.history.undo.len(), ds.history.redo.len()), (&selection, page, undo, redo));
assert_eq!(ex(&mut s, "edit.undo", json!({}))["undone"], true);
assert_ne!(s.doc().unwrap().get(layer).unwrap().name, "Previous edit");
assert_eq!(s.ds().unwrap().history.undo.len(), undo - 1);
}
@@ -0,0 +1,104 @@
use base64::Engine as _;
use figcraft_doc::NodeId;
use serde_json::json;
use crate::Session;
const PIXELS_PER_METER_AT_144_DPI: u32 = 5669;
const PIXELS_PER_METER_AT_72_DPI: u32 = 2835;
const PIXELS_PER_METER_AT_300_DPI: u32 = 11811;
fn png(width: u32, height: u32) -> Vec<u8> {
figcraft_render::encode_png(&figcraft_render::Pixmap::new(width, height).unwrap()).unwrap()
}
fn crc32(bytes: &[u8]) -> u32 {
let mut crc = !0u32;
for &byte in bytes {
crc ^= u32::from(byte);
for _ in 0..8 {
crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
!crc
}
fn with_pixels_per_meter(png: &[u8], pixels_per_meter: u32) -> Vec<u8> {
let after_header = 8 + 8 + 13 + 4;
let mut chunk = b"pHYs".to_vec();
chunk.extend(pixels_per_meter.to_be_bytes());
chunk.extend(pixels_per_meter.to_be_bytes());
chunk.push(1);
let mut out = png[..after_header].to_vec();
out.extend(9u32.to_be_bytes());
out.extend(&chunk);
out.extend(crc32(&chunk).to_be_bytes());
out.extend(&png[after_header..]);
out
}
fn place(s: &mut Session, bytes: &[u8], extra: serde_json::Value) -> (f64, f64) {
let mut params = json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes)});
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let placed = s.execute("image.place", &params).unwrap();
let node = s.doc().unwrap().node(NodeId(placed["id"].as_u64().unwrap())).unwrap();
(node.width, node.height)
}
#[test]
fn an_image_is_placed_at_its_pixel_size() {
let mut s = Session::new();
assert_eq!(place(&mut s, &png(2400, 1200), json!({})), (2400.0, 1200.0));
assert_eq!(place(&mut s, &with_pixels_per_meter(&png(1440, 900), PIXELS_PER_METER_AT_72_DPI), json!({})), (1440.0, 900.0));
}
#[test]
fn a_retina_screenshot_is_placed_at_its_point_size() {
let mut s = Session::new();
let retina = with_pixels_per_meter(&png(800, 600), PIXELS_PER_METER_AT_144_DPI);
assert_eq!(figcraft_format::pixel_density(&retina), 2.0);
assert_eq!(place(&mut s, &retina, json!({})), (400.0, 300.0));
}
#[test]
fn a_print_resolution_is_not_read_as_a_screen_density() {
let mut s = Session::new();
let scan = with_pixels_per_meter(&png(600, 400), PIXELS_PER_METER_AT_300_DPI);
assert_eq!(figcraft_format::pixel_density(&scan), 1.0);
assert_eq!(place(&mut s, &scan, json!({})), (600.0, 400.0));
}
#[test]
fn a_density_near_a_screen_density_is_not_read_as_one() {
for (dpi, pixels_per_meter) in [(141, 5551), (143, 5630), (145, 5709)] {
let near = with_pixels_per_meter(&png(4, 4), pixels_per_meter);
assert_eq!(figcraft_format::pixel_density(&near), 1.0, "{dpi} dpi");
}
for (density, pixels_per_meter) in [(2.0, 5670), (3.0, 8504), (4.0, 11339)] {
let screen = with_pixels_per_meter(&png(4, 4), pixels_per_meter);
assert_eq!(figcraft_format::pixel_density(&screen), density, "{pixels_per_meter} pixels per metre");
}
}
#[test]
fn only_a_side_past_4096_is_fitted() {
let mut s = Session::new();
let (w, h) = place(&mut s, &png(8192, 100), json!({}));
assert_eq!((w, h), (4096.0, 50.0));
}
#[test]
fn an_explicit_size_still_wins() {
let mut s = Session::new();
let retina = with_pixels_per_meter(&png(800, 600), PIXELS_PER_METER_AT_144_DPI);
assert_eq!(place(&mut s, &retina, json!({"width": 200})), (200.0, 150.0));
assert_eq!(place(&mut s, &png(2400, 1200), json!({"width": 100, "height": 70})), (100.0, 70.0));
}
#[test]
fn bytes_that_are_not_png_have_no_density() {
assert_eq!(figcraft_format::pixel_density(b"GIF89a"), 1.0);
assert_eq!(figcraft_format::pixel_density(&[]), 1.0);
let truncated = &with_pixels_per_meter(&png(4, 4), PIXELS_PER_METER_AT_144_DPI)[..40];
assert_eq!(figcraft_format::pixel_density(truncated), 1.0);
}
@@ -74,3 +74,37 @@ fn auto_save_leaves_changes_made_on_disk_alone() {
assert_eq!(page_on_disk(&path), "CLI VENCEU");
let _ = std::fs::remove_file(&path);
}
/// The name of the page the active tab shows.
fn page_in_tab(s: &Session) -> String {
let ds = s.ds().unwrap();
ds.doc.node(ds.page).unwrap().name.clone()
}
/// Opening a file that is already open says when it changed on disk since the tab read or saved
/// it, and `reload` reads it again into the tab, unless the tab has unsaved changes (#997).
#[test]
fn opening_an_open_file_says_it_is_stale_and_reloads_on_request() {
let path = temp_file("reopen");
let mut setup = Session::new();
setup.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
setup.execute("file.save", &json!({"path": path})).unwrap();
let mut app = Session::new();
let index = app.execute("file.open", &json!({"path": path})).unwrap()["index"].clone();
let before = page_in_tab(&app);
assert_eq!(app.execute("file.open", &json!({"path": path})).unwrap(), json!({"index": index}), "unchanged: not stale");
let mut cli = Session::new();
cli.execute("file.open", &json!({"path": path})).unwrap();
cli.execute("page.rename", &json!({"name": "Renamed on disk by the CLI"})).unwrap();
cli.execute("file.save", &json!({})).unwrap();
assert_eq!(app.execute("file.open", &json!({"path": path})).unwrap(), json!({"index": index, "stale": true}));
assert_eq!(page_in_tab(&app), before, "without reload the tab keeps what it read");
assert_eq!(app.execute("file.open", &json!({"path": path, "reload": true})).unwrap(), json!({"index": index, "reloaded": true}));
assert_eq!(page_in_tab(&app), "Renamed on disk by the CLI");
assert_eq!(app.execute("file.open", &json!({"path": path})).unwrap(), json!({"index": index}), "read again: no longer stale");
app.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
let e = app.execute("file.open", &json!({"path": path, "reload": true})).unwrap_err().to_string();
assert!(e.contains("unsaved changes"), "{e}");
assert!(app.ds().unwrap().is_dirty(), "the unsaved edit is still there");
let _ = std::fs::remove_file(&path);
}
+1
View File
@@ -49,6 +49,7 @@ impl Session {
/// as it was.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
crate::cmd::refuse_unknown_params(id, params)?;
board::refuse_on_board(self, id)?;
// Revert replaces the document's uid: refuse before ending previews or syncing branches.
if id == "file.revert" && self.live().doc_uid.is_some() {
@@ -166,6 +166,20 @@ fn a_centred_stroke_on_a_sharp_star_makes_three_regions() {
assert_eq!(covers, vec![(false, true), (true, false), (true, true)], "fill only, stroke only, both");
}
#[test]
fn merging_every_region_of_one_stroked_shape_is_a_no_op() {
let mut s = Session::new();
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}})));
ex(&mut s, "node.set", json!({"id": r, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}}));
s.set_selection(vec![r]);
let count = ex(&mut s, "shapeBuilder.regions", json!({}))["regions"].as_array().unwrap().len();
assert!(count > 1, "fill and stroke make several regions");
let before = s.doc().unwrap().clone();
let out = ex(&mut s, "shapeBuilder.merge", json!({"regions": (0..count).collect::<Vec<_>>()}));
assert!(out["id"].is_null(), "{out}");
assert_eq!(*s.doc().unwrap(), before);
}
#[test]
fn stroke_regions_survive_junk() {
for j in [
+3 -3
View File
@@ -168,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.share",
"Share with a join code",
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
false,
share
),
@@ -182,7 +182,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.join",
"Join live session",
"{code: a join code (K7M-2QX-9TD from a server, asked at the server in Preferences; a longer one for the local network; spaces, dashes and case don't matter, O reads as 0 and I or L as 1) | link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=… (a session an app hosts, or a server's share link; a code here works too)} → the new session: {session, status: \"connecting\", …}; its file opens in a tab of its own (other files' sessions go on) and `collab.peers` tells when it's live (`sessions` lists it until then, and if it fails)",
"{code | link (a join code: K7M-2QX-9TD from a server, asked at the server in Preferences; a longer one for the local network; spaces, dashes and case don't matter, O reads as 0 and I or L as 1; or an invite link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=…, from a session an app hosts or a server's share link; either key takes either)} → the new session: {session, status: \"connecting\", …}; its file opens in a tab of its own (other files' sessions go on) and `collab.peers` tells when it's live (`sessions` lists it until then, and if it fails)",
false,
join
),
@@ -210,7 +210,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.manageFile",
"Manage published file",
"{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink {access?: view (default) | edit, label?} | revoke {link (a link's id)} | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename {name} | remove (the file is deleted from the server and leaves your online files)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, editLink, viewLink} | {done}",
"{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink | revoke | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename | remove (the file is deleted from the server and leaves your online files), access? (createLink: view (default) | edit), label? (createLink), link? (revoke: a link's id), name? (rename)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, editLink, viewLink} | {done}",
false,
manage_file
),
+43
View File
@@ -527,3 +527,46 @@ fn sketch_shared_text_styles_match_their_layers() {
assert_eq!(st.font_family, td.style.font_family);
assert_eq!(st.letter_spacing.resolve(13.0), td.style.letter_spacing.resolve(13.0), "{st:?}");
}
/// An auto-width text an override made longer grows from its aligned edge, as in Sketch's renders:
/// a left-aligned one keeps its left edge, a centred one its centre, a right-aligned one its right
/// edge.
#[test]
fn sketch_overridden_text_grows_from_its_aligned_edge() {
let font = json!({"name": "Helvetica", "size": 12});
let mut layers = Vec::new();
for (i, align) in [0, 2, 1].into_iter().enumerate() {
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font},
"paragraphStyle": {"_class": "paragraphStyle", "alignment": align}}});
let label = shape(
&format!("L{i}"),
&format!("L{i}"),
[70.0, 4.0, 14.0, 16.0],
json!({"_class": "text", "attributedString": {"string": "Go", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}),
);
let sid = format!("S{i}");
layers.push(shape("symbolMaster", &format!("M{i}"), [0.0, 100.0 * i as f64, 154.0, 24.0], json!({"symbolID": sid, "layers": [label]})));
let ov = json!([{"_class": "overrideValue", "overrideName": format!("L{i}_stringValue"), "value": "A much longer label"}]);
layers.push(shape(
"symbolInstance",
&format!("I{i}"),
[300.0, 100.0 * i as f64, 154.0, 24.0],
json!({"symbolID": sid, "overrideValues": ov}),
));
}
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &layers));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let bounds = |i: usize| {
let inst = doc.nodes().find(|n| n.name == format!("I{i}")).unwrap();
let label = doc.subtree(inst.id).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == format!("L{i}"))).unwrap();
let (b, l) = (doc.world_bounds(inst.id).unwrap(), doc.world_bounds(label).unwrap());
assert!(l.x1 - l.x0 > 40.0, "the label was measured: {l:?}");
(l.x0 - b.x0, l.x1 - b.x0)
};
let (left, centre, right) = (bounds(0), bounds(1), bounds(2));
assert!((left.0 - 70.0).abs() < 0.5, "{left:?}");
assert!(((centre.0 + centre.1) / 2.0 - 77.0).abs() < 0.5, "{centre:?}");
assert!((right.1 - 84.0).abs() < 0.5, "{right:?}");
}
+16 -5
View File
@@ -454,6 +454,9 @@ fn every_command_survives_junk_params() {
// would write to it.)
json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}),
json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}),
// Layer search roots, filters and limits as junk.
json!({"root": {"id": 2}, "name": [1], "type": "nope", "text": true, "component": [], "limit": -1}),
json!({"root": 999999, "limit": 1e308}),
// Batch step names and reference paths as junk.
json!({"commands": [{"command": "select.none", "as": "a"}, {"command": "select.none", "as": [1]}, {"command": "select.none", "as": "a"}]}),
json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [
@@ -478,11 +481,19 @@ fn every_command_survives_junk_params() {
continue;
}
for j in &junk {
let mut s = sess();
let _ = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
let _ = s.execute("text.create", &json!({"text": "t"}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
panic!("{cmd} panicked on {j}: {msg}");
// Each junk object as given (names the command doesn't take are refused) and cut down
// to the names it takes, so the junk values still reach its parsers.
let mut taken = j.clone();
if let (Some(names), Some(o)) = (crate::cmd::param_names(spec.id), taken.as_object_mut()) {
o.retain(|k, _| matches!(k.as_str(), "ids" | "id") || names.contains(&k.as_str()));
}
for params in if taken == *j { vec![j] } else { vec![j, &taken] } {
let mut s = sess();
let _ = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
let _ = s.execute("text.create", &json!({"text": "t"}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, params) {
panic!("{cmd} panicked on {params}: {msg}");
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
//! A parameter name a command doesn't take is refused, not ignored: a misspelt name would make the
//! command answer as if it hadn't been given (#1413).
use serde_json::{Value, json};
use crate::Session;
use crate::cmd::{all_commands, param_names};
fn err(s: &mut Session, cmd: &str, params: Value) -> String {
match s.execute(cmd, &params) {
Err(e) => e.to_string(),
Ok(v) => panic!("{cmd} {params} ran: {v}"),
}
}
/// The issue's repro: a misspelt `filter` listed the resolved thread as if no filter were given.
#[test]
fn a_misspelt_parameter_is_refused_and_named() {
let mut s = Session::new();
s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "a"})).unwrap();
s.execute("comment.resolve", &json!({"id": 1})).unwrap();
let e = err(&mut s, "comment.list", json!({"filterr": "open"}));
assert!(e.contains("unknown parameter `filterr`") && e.contains("it takes page, filter, sort"), "{e}");
assert_eq!(s.execute("comment.list", &json!({"filter": "open"})).unwrap()["comments"].as_array().map(Vec::len), Some(0));
let e = err(&mut s, "edit.undo", json!({"steps": 2, "stepz": 1}));
assert!(e.contains("unknown parameters `steps`, `stepz` (it takes none)"), "{e}");
}
/// `ids` and `id` name the layers a command acts on, documented or not (`params::targets` reads
/// either), so `{id}` keeps working where a doc only says `ids?`.
#[test]
fn ids_and_id_are_always_taken() {
let mut s = Session::new();
let r = crate::params::id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"]).unwrap();
assert_eq!(param_names("align.left"), Some(&["ids"][..]));
s.execute("align.left", &json!({"id": r})).unwrap();
s.execute("edit.undo", &json!({"ids": []})).unwrap();
}
/// Names a doc gives as alternatives (`id? | ids?`, `id?/ids?`, `path | data`) are all taken, and
/// `null` params or no params at all stay fine.
#[test]
fn documented_alternatives_and_empty_params_are_taken() {
assert_eq!(param_names("vector.fillRule").map(|n| n.contains(&"id") && n.contains(&"ids")), Some(true));
assert_eq!(param_names("text.setFeatures").map(|n| n.contains(&"id") && n.contains(&"ids")), Some(true));
assert_eq!(param_names("file.import").map(|n| n.contains(&"path") && n.contains(&"data") && n.contains(&"name")), Some(true));
assert_eq!(param_names("file.export").map(|n| n.contains(&"scale") && n.contains(&"size")), Some(true));
assert_eq!(param_names("collab.share").map(|n| n.contains(&"via") && !n.contains(&"local")), Some(true));
assert_eq!(param_names("component.editProperty").map(|n| n.contains(&"value") && n.contains(&"newValue")), Some(true));
// Docs whose shape used to hide names: a stray brace, a comma in an example, a second object.
assert_eq!(param_names("proto.interaction.add").map(|n| n.contains(&"delayMs") && n.contains(&"dest")), Some(true));
assert_eq!(param_names("collab.join"), Some(&["code", "link"][..]));
assert_eq!(param_names("proto.interaction.set").map(|n| n.contains(&"trigger") && n.contains(&"interaction")), Some(true));
// Commands that run another command's code take its names too.
assert_eq!(param_names("file.placeImage").map(|n| ["width", "height", "target", "mode"].iter().all(|k| n.contains(k))), Some(true));
assert_eq!(param_names("file.exportSvg").map(|n| ["scale", "size", "contentsOnly", "svgOutlineText"].iter().all(|k| n.contains(k))), Some(true));
// Alternative groups: `{x, y} | {points: …}`.
assert_eq!(param_names("vector.edit.paint"), Some(&["x", "y", "points"][..]));
assert_eq!(param_names("vector.edit.cut"), Some(&["segment", "t", "vertex", "from", "to"][..]));
// `node.set` documents its props and checks them itself.
assert_eq!(param_names("node.set"), None);
let mut s = Session::new();
for p in [json!(null), json!({})] {
s.execute("select.all", &p).unwrap();
}
}
/// Every top-level item of every `{…}` params doc gives a name, so no parameter a doc describes is
/// refused.
#[test]
fn every_doc_item_names_a_parameter() {
let nameless: Vec<String> = all_commands()
.iter()
.flat_map(|c| crate::cmd::doc_items(c.params).unwrap_or_default().into_iter().map(move |i| (c.id, i)))
.filter(|(_, i)| !i.trim().is_empty() && crate::cmd::item_names(i).is_empty())
.map(|(id, i)| format!("{id}: `{i}`"))
.collect();
assert!(nameless.is_empty(), "doc items without a name: {nameless:#?}");
}
/// A batch step key other than `command`, `params` and `as` (`args` for `params`) is refused, and
/// so is a step's unknown parameter, naming the step.
#[test]
fn batch_steps_refuse_unknown_keys_and_parameters() {
let mut s = Session::new();
let e = err(&mut s, "command.batch", json!({"commands": [{"command": "select.all"}, {"command": "inspect.code", "args": {"id": 2}}]}));
assert!(e.contains("step 1: unknown key `args`"), "{e}");
let e =
err(&mut s, "command.batch", json!({"commands": [{"command": "select.all"}, {"command": "comment.list", "params": {"filterr": "open"}}]}));
assert!(e.contains("comment.list") && e.contains("step 1: unknown parameter `filterr`"), "{e}");
}
+272 -2
View File
@@ -7,9 +7,9 @@
// The tables read best as tuples.
#![allow(clippy::type_complexity)]
use figcraft_doc::{HandleMirroring, NodeId, NodeKind, VectorNetwork};
use figcraft_doc::{HandleMirroring, NodeId, NodeKind, Region, Segment, VectorNetwork, WindingRule};
use figcraft_geom::kurbo::ParamCurve;
use figcraft_geom::{Point, Shape};
use figcraft_geom::{BezPath, Point, Shape};
use serde_json::{Value, json};
use crate::Session;
@@ -17,6 +17,276 @@ use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
use crate::vectoredit::{VectorEdit, network};
#[test]
fn region_paints_survive_simplify_offset_and_bucket_normalization() {
let mut s = Session::new();
let id = square(&mut s, false);
ex_to_vector(s.doc_mut().unwrap(), id);
let mut gradient = figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, figcraft_color::Color::BLACK);
gradient.from = (0.2, 0.1);
gradient.to = (0.8, 0.9);
let fills = vec![
figcraft_color::Paint::gradient(gradient),
figcraft_color::Paint::image("test-image", figcraft_color::ImageScaleMode::Crop),
figcraft_color::Paint::pattern(123),
];
{
let doc = s.doc_mut().unwrap();
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
network.regions[0].fills = Some(fills.clone());
}
ex(&mut s, "vector.simplify", json!({"ids": [id]}));
assert_eq!(net(&s, id).regions[0].fills, Some(fills.clone()), "simplify keeps the full stack");
ex(&mut s, "vector.offset", json!({"ids": [id], "offset": 2}));
assert_eq!(net(&s, id).regions[0].fills, Some(fills.clone()), "offset keeps the full stack");
// Add a divider so the legacy single region must split into independently painted faces.
ex(&mut s, "vector.edit.begin", json!({"id": id}));
let network = net(&s, id);
let (topseg, botseg) = (
network.segments.iter().position(|sg| network.vertices[sg.a].y == 0.0 && network.vertices[sg.b].y == 0.0).unwrap(),
network.segments.iter().position(|sg| network.vertices[sg.a].y == 104.0 && network.vertices[sg.b].y == 104.0).unwrap(),
);
let a = ex(&mut s, "vector.edit.addVertex", json!({"segment": topseg, "t": 0.5}))["vertex"].as_u64().unwrap();
let b = ex(&mut s, "vector.edit.addVertex", json!({"segment": botseg, "t": 0.5}))["vertex"].as_u64().unwrap();
ex(&mut s, "vector.edit.connect", json!({"a": a, "b": b}));
ex(&mut s, "vector.edit.paint", json!({"x": 25, "y": 52}));
let normalized = net(&s, id);
assert_eq!(normalized.regions.len(), 1, "unfilled face is removed while the other stays");
assert_eq!(normalized.regions[0].fills, Some(fills.clone()), "untouched face keeps the original stack");
assert!(!normalized.fill_contains(Point::new(27.0, 54.0)));
assert!(normalized.fill_contains(Point::new(77.0, 54.0)));
s.undo().unwrap();
assert!(net(&s, id).fill_contains(Point::new(25.0, 52.0)), "bucket change is undoable");
ex(&mut s, "vector.edit.end", json!({}));
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [id]}))["id"]).unwrap();
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
let flattened_net = net(&s, flattened);
assert!(flattened_net.regions.iter().all(|region| region.fills == Some(fills.clone())), "flatten keeps each source region stack");
assert!(flattened_net.regions.iter().all(|region| region.paint_space.is_some()), "flatten keeps source paint frames");
assert!(flattened_net.fill_contains(Point::new(25.0, 52.0)));
let prior_frame =
flattened_net.regions[0].paint_frame(s.doc().unwrap().get(flattened).unwrap().width, s.doc().unwrap().get(flattened).unwrap().height);
let prior_world_frame = s.doc().unwrap().world_transform(flattened) * prior_frame.2;
let regrouped = id_of(&ex(&mut s, "object.group", json!({"ids": [flattened]}))["id"]).unwrap();
let flattened_again = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [regrouped]}))["id"]).unwrap();
let after = net(&s, flattened_again);
let after_frame =
after.regions[0].paint_frame(s.doc().unwrap().get(flattened_again).unwrap().width, s.doc().unwrap().get(flattened_again).unwrap().height);
let after_world_frame = s.doc().unwrap().world_transform(flattened_again) * after_frame.2;
assert!(
prior_world_frame.as_coeffs().iter().zip(after_world_frame.as_coeffs()).all(|(before, after)| (before - after).abs() < 1e-8),
"flattening an already framed gradient preserves its source coordinates"
);
}
#[test]
fn explicit_and_inherited_region_paints_survive_refit() {
let mut s = Session::new();
let empty_id = square(&mut s, false);
ex_to_vector(s.doc_mut().unwrap(), empty_id);
{
let doc = s.doc_mut().unwrap();
if let NodeKind::Vector { network } = &mut doc.get_mut(empty_id).unwrap().kind {
network.regions[0].fills = Some(Vec::new());
}
}
ex(&mut s, "vector.simplify", json!({"ids": [empty_id]}));
ex(&mut s, "vector.offset", json!({"ids": [empty_id], "offset": 2}));
assert_eq!(net(&s, empty_id).regions[0].fills, Some(Vec::new()), "explicit empty paint stack survives refit");
let inherited_id = square(&mut s, false);
ex_to_vector(s.doc_mut().unwrap(), inherited_id);
{
let doc = s.doc_mut().unwrap();
if let NodeKind::Vector { network } = &mut doc.get_mut(inherited_id).unwrap().kind {
network.regions[0].fills = None;
}
}
ex(&mut s, "vector.offset", json!({"ids": [inherited_id], "offset": 2}));
let inherited_net = net(&s, inherited_id);
let inherited = &inherited_net.regions[0];
assert_eq!(inherited.fills, None, "inherited paints remain inherited after offset");
let inherited_space = inherited.paint_space.expect("offset binds inherited paints to the original frame");
assert_eq!((inherited_space.width, inherited_space.height), (100.0, 100.0));
}
#[test]
fn flatten_keeps_inherited_region_paints_inherited() {
let mut s = Session::new();
let inherited_flatten = square(&mut s, false);
let inherited_fills = s.doc().unwrap().get(inherited_flatten).unwrap().fills.clone();
ex_to_vector(s.doc_mut().unwrap(), inherited_flatten);
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(inherited_flatten).unwrap().kind {
network.regions[0].fills = None;
}
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [inherited_flatten]}))["id"]).unwrap();
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
assert!(
net(&s, flattened).regions.iter().all(|region| region.fills.is_none()),
"flatten keeps inherited paints inherited when output node has same fills"
);
assert_eq!(s.doc().unwrap().get(flattened).unwrap().fills, inherited_fills);
let plain = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 60}))["id"]).unwrap();
let plain_fills = s.doc().unwrap().get(plain).unwrap().fills.clone();
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [plain]}))["id"]).unwrap();
let flattened_plain = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
assert!(net(&s, flattened_plain).regions.iter().all(|region| region.fills.is_none()), "plain shapes inherit the flattened node's fill");
assert_eq!(s.doc().unwrap().get(flattened_plain).unwrap().fills, plain_fills);
let edited_fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
s.doc_mut().unwrap().get_mut(flattened_plain).unwrap().fills = edited_fills.clone();
assert_eq!(net(&s, flattened_plain).regions[0].fills, None, "changing the flattened node fill updates inherited geometry");
let different_source = square(&mut s, false);
s.doc_mut().unwrap().get_mut(different_source).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
ex_to_vector(s.doc_mut().unwrap(), different_source);
let source_fills = s.doc().unwrap().get(different_source).unwrap().fills.clone();
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(different_source).unwrap().kind {
network.regions[0].fills = None;
}
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [different_source]}))["id"]).unwrap();
s.doc_mut().unwrap().get_mut(group).unwrap().fills.clear();
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
assert_eq!(net(&s, flattened).regions[0].fills, Some(source_fills), "flatten retains paints that differ from output node fills");
}
#[test]
fn legacy_offset_preserves_the_gradients_world_paint_frame() {
let mut s = Session::new();
let legacy_gradient = square(&mut s, false);
let mut gradient = figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, figcraft_color::Color::BLACK);
gradient.from = (0.2, 0.1);
gradient.to = (0.8, 0.9);
s.doc_mut().unwrap().get_mut(legacy_gradient).unwrap().fills = vec![figcraft_color::Paint::gradient(gradient)];
ex_to_vector(s.doc_mut().unwrap(), legacy_gradient);
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(legacy_gradient).unwrap().kind {
network.regions[0].fills = None;
}
let before = net(&s, legacy_gradient).regions[0].paint_frame(100.0, 100.0).2;
let before_world = s.doc().unwrap().world_transform(legacy_gradient) * before;
ex(&mut s, "vector.offset", json!({"ids": [legacy_gradient], "offset": 2}));
let after_net = net(&s, legacy_gradient);
let after = after_net.regions[0]
.paint_frame(s.doc().unwrap().get(legacy_gradient).unwrap().width, s.doc().unwrap().get(legacy_gradient).unwrap().height)
.2;
let after_world = s.doc().unwrap().world_transform(legacy_gradient) * after;
assert!(
before_world.as_coeffs().iter().zip(after_world.as_coeffs()).all(|(before, after)| (before - after).abs() < 1e-8),
"legacy offset preserves the gradient's world paint frame"
);
}
#[test]
fn bucket_fills_an_explicitly_empty_face_and_leaves_the_other_empty() {
let mut s = Session::new();
let empty_faces =
id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L100 0 L100 100 L0 100 Z M200 0 L300 0 L300 100 L200 100 Z"}))["id"]).unwrap();
{
let doc = s.doc_mut().unwrap();
let node = doc.get_mut(empty_faces).unwrap();
node.fills.clear();
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
assert!(network.regions.iter().all(|region| region.fills.is_none()));
}
ex(&mut s, "vector.edit.begin", json!({"id": empty_faces}));
ex(&mut s, "vector.edit.paint", json!({"x": 50, "y": 50}));
let split = net(&s, empty_faces);
let left =
split.regions.iter().enumerate().find(|(index, _)| split.region_contains(*index, Point::new(50.0, 50.0))).map(|(_, region)| region).unwrap();
let right =
split.regions.iter().enumerate().find(|(index, _)| split.region_contains(*index, Point::new(250.0, 50.0))).map(|(_, region)| region).unwrap();
assert_eq!(left.fills, None, "clicking an explicitly empty face fills it");
assert_eq!(right.fills, Some(Vec::new()), "untouched inherited-empty face stays empty");
assert!(!s.doc().unwrap().get(empty_faces).unwrap().fills.is_empty(), "the painted target gets the layer default fill");
}
#[test]
fn legacy_simplify_combines_mixed_winding_regions() {
let mut s = Session::new();
let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L100 0 L100 100 L0 100 Z M25 25 L75 25 L75 75 L25 75 Z"}))["id"]).unwrap();
let mut second_path = BezPath::new();
second_path.move_to((50.0, 0.0));
second_path.line_to((150.0, 0.0));
second_path.line_to((150.0, 100.0));
second_path.line_to((50.0, 100.0));
second_path.close_path();
let second = VectorNetwork::from_path(&second_path);
{
let doc = s.doc_mut().unwrap();
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
network.regions[0].winding = WindingRule::EvenOdd;
let vertex_offset = network.vertices.len();
let segment_offset = network.segments.len();
network.vertices.extend(second.vertices);
network.segments.extend(second.segments.into_iter().map(|segment| Segment {
a: segment.a + vertex_offset,
b: segment.b + vertex_offset,
..segment
}));
network.regions.extend(second.regions.into_iter().map(|region| Region {
loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + segment_offset).collect()).collect(),
..region
}));
network.regions[1].winding = WindingRule::NonZero;
}
ex(&mut s, "vector.simplify", json!({"ids": [id]}));
let simplified = net(&s, id);
assert_eq!(simplified.regions.len(), 1, "legacy regions simplify as one combined fill");
assert!(!simplified.fill_contains(Point::new(60.0, 10.0)), "overlap cancels under combined even-odd winding");
assert!(!simplified.fill_contains(Point::new(40.0, 40.0)), "nested contour remains a hole");
assert!(simplified.fill_contains(Point::new(120.0, 50.0)), "non-overlapping area remains filled");
assert!(simplified.regions[0].fills.is_none());
assert!(simplified.regions[0].paint_space.is_none());
}
#[test]
fn legacy_evenodd_holes_survive_bucket_offset_and_flatten() {
let mut s = Session::new();
let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L200 0 L200 200 L0 200 Z M50 50 L150 50 L150 150 L50 150 Z"}))["id"]).unwrap();
{
let doc = s.doc_mut().unwrap();
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
network.regions[0].winding = figcraft_doc::WindingRule::EvenOdd;
}
assert!(!net(&s, id).fill_contains(Point::new(100.0, 100.0)), "center starts as an even-odd hole");
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.paint", json!({"x": 100, "y": 100}));
assert!(net(&s, id).fill_contains(Point::new(100.0, 100.0)), "bucket fills a legacy even-odd hole");
s.undo().unwrap();
ex(&mut s, "vector.edit.end", json!({}));
let flatten_source =
id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L200 0 L200 200 L0 200 Z M50 50 L150 50 L150 150 L50 150 Z"}))["id"]).unwrap();
{
let doc = s.doc_mut().unwrap();
if let NodeKind::Vector { network } = &mut doc.get_mut(flatten_source).unwrap().kind {
network.regions[0].winding = figcraft_doc::WindingRule::EvenOdd;
}
}
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [flatten_source]}))["id"]).unwrap();
let flattened_legacy = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
assert_eq!(net(&s, flattened_legacy).regions.len(), 1, "flatten combines unstyled legacy loops");
assert!(!net(&s, flattened_legacy).fill_contains(Point::new(100.0, 100.0)), "flatten preserves the legacy even-odd hole");
ex(&mut s, "vector.offset", json!({"ids": [id], "offset": 2}));
assert!(!net(&s, id).fill_contains(Point::new(100.0, 100.0)), "offset retains the even-odd hole");
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [id]}))["id"]).unwrap();
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
assert_eq!(net(&s, flattened).regions.len(), 1, "legacy region loops stay grouped");
assert!(!net(&s, flattened).fill_contains(Point::new(100.0, 100.0)), "flatten retains the even-odd hole");
}
/// Give vector `id` a painted region, converting a plain shape to a vector first.
fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) {
let n = doc.get(id).unwrap();
let (path, _) = figcraft_render::node_outline(doc, n).unwrap();
let mut network = VectorNetwork::from_path(&path);
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
doc.get_mut(id).unwrap().kind = NodeKind::Vector { network };
}
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
+8 -5
View File
@@ -88,8 +88,8 @@ fn two_squares() -> VectorNetwork {
Segment::line(3, 4),
],
regions: vec![
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero },
Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero },
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, ..Default::default() },
Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero, ..Default::default() },
],
}
}
@@ -114,8 +114,8 @@ fn two_lenses() -> VectorNetwork {
Segment { a: 0, b: 1, ta: (0.0, 50.0), tb: (0.0, 50.0) },
],
regions: vec![
Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero },
Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero },
Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() },
Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero, ..Default::default() },
],
}
}
@@ -131,7 +131,10 @@ fn one_shared_segment() -> VectorNetwork {
VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)],
segments: vec![Segment { a: 0, b: 1, ta: (0.0, -60.0), tb: (0.0, -60.0) }],
regions: vec![Region { loops: vec![vec![0]], winding: WindingRule::NonZero }, Region { loops: vec![vec![0]], winding: WindingRule::NonZero }],
regions: vec![
Region { loops: vec![vec![0]], winding: WindingRule::NonZero, ..Default::default() },
Region { loops: vec![vec![0]], winding: WindingRule::NonZero, ..Default::default() },
],
}
}
+41
View File
@@ -0,0 +1,41 @@
const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
const POINTS_PER_INCH: f64 = 72.0;
const METERS_PER_INCH: f64 = 0.0254;
const UNIT_METER: u8 = 1;
const MAX_DENSITY: f64 = 4.0;
const DENSITY_TOLERANCE: f64 = 0.005;
pub fn pixel_density(bytes: &[u8]) -> f64 {
png_pixels_per_meter(bytes).map_or(1.0, density_from_pixels_per_meter)
}
pub fn placed_size(pixel_width: u32, pixel_height: u32, density: f64, max_side: f64) -> (f64, f64) {
let (w, h) = (f64::from(pixel_width) / density, f64::from(pixel_height) / density);
let fit = (max_side / w.max(h)).min(1.0);
(w * fit, h * fit)
}
fn density_from_pixels_per_meter(pixels_per_meter: u32) -> f64 {
let ratio = f64::from(pixels_per_meter) * METERS_PER_INCH / POINTS_PER_INCH;
let whole = ratio.round();
if (2.0..=MAX_DENSITY).contains(&whole) && (ratio - whole).abs() < DENSITY_TOLERANCE { whole } else { 1.0 }
}
fn png_pixels_per_meter(bytes: &[u8]) -> Option<u32> {
let mut rest = bytes.strip_prefix(PNG_SIGNATURE)?;
while rest.len() >= 12 {
let length = usize::try_from(u32::from_be_bytes(rest[..4].try_into().ok()?)).ok()?;
let kind = &rest[4..8];
let data = rest.get(8..8usize.checked_add(length)?)?;
match kind {
b"pHYs" if data.len() == 9 => {
let x = u32::from_be_bytes(data[..4].try_into().ok()?);
let y = u32::from_be_bytes(data[4..8].try_into().ok()?);
return (data[8] == UNIT_METER && x == y).then_some(x);
}
b"IDAT" | b"IEND" => return None,
_ => rest = rest.get(12usize.checked_add(length)?..)?,
}
}
None
}
+419
View File
@@ -0,0 +1,419 @@
//! FigJam generated layers (#1341). All document content and outlines are synthetic.
use super::*;
/// The synthetic FigJam schema adds generated layout and text alignment records.
fn board_schema() -> Schema {
Schema::parse(&SCHEMA.replace(
"\nmessage Blob ",
", GeneratedData nodeGenerationData, GeneratedData derivedImmutableFrameData, string textAlignHorizontal, string textAlignVertical\nmessage GeneratedData NodeChange[] overrides, uint version\nmessage Blob ",
))
.unwrap()
}
/// Address a generated template child by its full GUID, independently of array order.
fn record(id: u64, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
fields.push(("guidPath", o(vec![("guids", arr(vec![o(vec![("sessionID", u(40_000_000)), ("localID", u(id))])]))])));
o(fields)
}
/// Synthetic styled text with an inline font-size override and explicit text paints.
fn text(chars: &str, size: f64) -> Vec<(&'static str, Kv<'static>)> {
vec![
(
"textData",
o(vec![
("characters", s(chars)),
("characterStyleIDs", arr(vec![u(0), u(1)])),
("styleOverrideTable", arr(vec![o(vec![("styleID", u(1)), ("fontSize", f(18.0))])])),
]),
),
("fontSize", f(size)),
("fontName", o(vec![("family", s("Synthetic Sans")), ("style", s("Bold"))])),
("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])),
("textAlignHorizontal", s("LEFT")),
("textAlignVertical", s("TOP")),
]
}
/// A synthetic note or shape with shuffled background, body and author records.
fn board_node(ty: &'static str) -> Kv<'static> {
let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)])), ("opacity", f(1.0))]);
let note = record(1, text("Olá\nIdea", 24.0));
let author = record(2, text("Test Author", 12.0));
let bounds = |id, x, y, w, h| record(id, vec![("size", xy(w, h)), ("transform", at(x, y))]);
node(
2,
Some((1, "!")),
ty,
"Board object",
vec![
("size", xy(240.0, 240.0)),
("transform", at(100.0, 150.0)),
("opacity", f(0.5)),
("shapeWithTextType", e("ELLIPSE")),
// Deliberately shuffled: records are addressed by their full template GUID.
("nodeGenerationData", o(vec![("overrides", arr(vec![author, background, note]))])),
(
"derivedImmutableFrameData",
o(vec![
("version", u(1)),
(
"overrides",
arr(vec![
bounds(2, 20.0, 204.0, 200.0, 16.0),
bounds(1, 20.0, 20.0, 200.0, 160.0),
record(
0,
vec![
("size", xy(240.0, 240.0)),
("transform", at(0.0, 0.0)),
("fillGeometry", arr(vec![o(vec![("commandsBlob", u(0)), ("windingRule", e("NONZERO"))])])),
],
),
]),
),
]),
),
],
)
}
/// Wrap one board object in a page with its synthetic triangle path blob.
fn message(layer: Kv<'static>) -> Kv<'static> {
o(vec![
("nodeChanges", arr(vec![node(0, None, "DOCUMENT", "Synthetic board", vec![]), node(1, Some((0, "!")), "CANVAS", "Page", vec![]), layer])),
("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(triangle_blob()))])])),
])
}
/// Encode and import a synthetic board through the public binary file importer.
fn imported(layer: Kv<'static>) -> (Document, Vec<String>) {
import(&canvas(&board_schema(), &message(layer)), "Synthetic board").unwrap()
}
/// Generated records retain editable geometry, styled text, paints, bounds and authors.
#[test]
fn generated_stickies_and_shapes_keep_their_background_text_and_author() {
for ty in ["STICKY", "SHAPE_WITH_TEXT"] {
let (doc, warnings) = imported(board_node(ty));
let frame = named(&doc, "Board object");
assert!(matches!(frame.kind, NodeKind::Frame));
assert_eq!(frame.opacity, 0.5);
assert_eq!(frame.children.len(), if ty == "STICKY" { 3 } else { 2 });
let background = doc.node(frame.children[0]).unwrap();
assert!(matches!(background.kind, NodeKind::Vector { .. }));
assert_eq!(background.fills[0].solid_color().unwrap().to_rgba8(), [255, 191, 64, 255]);
assert_eq!(background.opacity, 1.0);
let note = doc.node(frame.children[1]).unwrap();
let t = note.kind.text().unwrap();
assert_eq!(t.characters, "Olá\nIdea");
assert_eq!(t.style.font_size, 24.0);
assert_eq!(t.style.font_family, "Synthetic Sans");
assert_eq!(note.fills[0].solid_color().unwrap().to_rgba8(), [0, 0, 0, 255]);
assert_eq!(note.transform.translation(), figcraft_doc::geom::Vec2::new(20.0, 20.0));
assert_eq!((note.width, note.height), (200.0, 160.0));
assert!(t.runs.iter().any(|r| r.style.font_size == 18.0), "inline styles survive");
if ty == "STICKY" {
let author = doc.node(frame.children[2]).unwrap();
assert_eq!(author.kind.text().unwrap().characters, "Test Author");
assert_eq!(author.kind.text().unwrap().style.font_size, 12.0);
assert_eq!(author.transform.translation(), figcraft_doc::geom::Vec2::new(20.0, 204.0));
}
assert!(!warnings.iter().any(|w| w.contains("skipped")), "{warnings:?}");
}
}
/// Replace one fixture field, or add it when the synthetic object omits it.
fn set(value: &mut Kv<'static>, field: &'static str, replacement: Kv<'static>) {
let Kv::Object(fields) = value else { panic!("object") };
if let Some((_, old)) = fields.iter_mut().find(|(name, _)| *name == field) {
*old = replacement;
} else {
fields.push((field, replacement));
}
}
/// Incomplete text records warn while an independently valid background survives.
#[test]
fn missing_generated_bounds_warn_without_losing_the_background() {
let mut layer = board_node("STICKY");
set(
&mut layer,
"derivedImmutableFrameData",
o(vec![("version", u(1)), ("overrides", arr(vec![record(0, vec![("size", xy(240.0, 240.0)), ("transform", at(0.0, 0.0))])]))]),
);
let (doc, warnings) = imported(layer);
let frame = named(&doc, "Board object");
assert_eq!(frame.children.len(), 1);
let background = doc.node(frame.children[0]).unwrap();
assert!(matches!(background.kind, NodeKind::Rectangle));
assert_eq!(background.fills.len(), 1);
assert!(warnings.iter().any(|w| w.contains("missing its style or bounds")), "{warnings:?}");
}
/// Invalid child bounds are rejected before they reach the document model.
#[test]
fn invalid_generated_sizes_and_transforms_are_skipped() {
for (width, x) in [(-1.0, 20.0), (f64::INFINITY, 20.0), (200.0, f64::NAN)] {
let mut layer = board_node("STICKY");
set(
&mut layer,
"derivedImmutableFrameData",
o(vec![("version", u(1)), ("overrides", arr(vec![record(1, vec![("size", xy(width, 160.0)), ("transform", at(x, 20.0))])]))]),
);
// The fixture encoder refuses non-finite floats; exercise the mapper's guard directly.
let msg = message(layer);
let (json, warnings, _) = map::to_rest(&msg, "Invalid bounds", 100);
let (doc, _) = crate::rest::import(&json, &|_| None).unwrap();
assert!(named(&doc, "Board object").children.is_empty());
assert!(warnings.iter().any(|w| w.contains("invalid bounds")), "{warnings:?}");
}
}
/// Unknown layout versions retain the legacy outer-frame and text fallback.
#[test]
fn unsupported_generated_versions_keep_the_legacy_fallback() {
let mut layer = board_node("SHAPE_WITH_TEXT");
set(&mut layer, "derivedImmutableFrameData", o(vec![("version", u(2))]));
set(&mut layer, "textData", o(vec![("characters", s("Legacy text"))]));
set(&mut layer, "fillPaints", arr(vec![solid(0.0, 1.0, 0.0)]));
let (doc, warnings) = imported(layer);
let frame = named(&doc, "Board object");
assert_eq!(frame.fills.len(), 1);
assert_eq!(frame.children.len(), 1);
assert_eq!(doc.node(frame.children[0]).unwrap().kind.text().unwrap().characters, "Legacy text");
assert!(warnings.iter().any(|w| w.contains("unsupported")), "{warnings:?}");
}
/// Generated backgrounds and text consume the same node budget as ordinary layers.
#[test]
fn generated_children_stay_within_the_layer_budget() {
for cap in 1..6 {
let msg = message(board_node("STICKY"));
let (json, warnings, _) = map::to_rest(&msg, "Budget", cap);
/// Count every emitted layer, including generated children.
fn count(n: &Value) -> usize {
1 + n.get("children").and_then(Value::as_array).map_or(0, |kids| kids.iter().map(count).sum::<usize>())
}
assert_eq!(count(&json["document"]) - 2, cap.min(4), "{json}");
if cap < 4 {
assert!(warnings.iter().any(|w| w.contains("limit") || w.contains("skipped")), "{warnings:?}");
}
}
}
/// Malformed generated records remain safe to map and import.
#[test]
fn malformed_generated_records_never_panic() {
for junk in [Kv::Bool(false), f(f64::NAN), f(f64::INFINITY), s("junk"), arr(vec![]), o(vec![])] {
for field in ["nodeGenerationData", "derivedImmutableFrameData"] {
let mut layer = board_node("STICKY");
set(&mut layer, field, junk.clone());
let msg = message(layer);
let (json, _, _) = map::to_rest(&msg, "Junk", 100);
let _ = crate::rest::import(&json, &|_| None);
}
}
}
/// A non-rectangular background saved only as an editable network, with no path geometry.
#[test]
fn generated_background_reads_its_network_before_falling_back_to_a_rectangle() {
use super::super::network::{self, Network, Region, Segment, Vertex};
let outline = network::encode(&Network {
vertices: vec![Vertex { style: 0, x: 0.0, y: 0.0 }, Vertex { style: 0, x: 240.0, y: 0.0 }, Vertex { style: 0, x: 120.0, y: 240.0 }],
segments: (0..3).map(|a| Segment { style: 0, a, b: (a + 1) % 3, ta: (0.0, 0.0), tb: (0.0, 0.0) }).collect(),
regions: vec![Region { style: 0, non_zero: true, loops: vec![vec![0, 1, 2]] }],
});
for valid in [true, false] {
let mut layer = board_node("SHAPE_WITH_TEXT");
set(
&mut layer,
"derivedImmutableFrameData",
o(vec![
("version", u(1)),
(
"overrides",
arr(vec![record(
0,
vec![
("size", xy(240.0, 240.0)),
("transform", at(0.0, 0.0)),
("vectorData", o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", xy(240.0, 240.0))])),
],
)]),
),
]),
);
let mut msg = message(layer);
set(&mut msg, "blobs", arr(vec![o(vec![("bytes", Kv::Bytes(if valid { outline.clone() } else { b"damaged".to_vec() }))])]));
let (doc, warnings) = import(&canvas(&board_schema(), &msg), "Network background").unwrap();
let frame = named(&doc, "Board object");
let background = doc.node(frame.children[0]).unwrap();
if valid {
let NodeKind::Vector { network } = &background.kind else { panic!("saved triangle must remain a vector") };
assert_eq!(network.vertices.len(), 3);
assert_eq!(network.segments.len(), 3);
assert_eq!(network.regions.len(), 1);
assert!(!warnings.iter().any(|w| w.contains("outline is missing")), "{warnings:?}");
} else {
assert!(matches!(background.kind, NodeKind::Rectangle));
assert!(warnings.iter().any(|w| w.contains("outline is missing")), "{warnings:?}");
}
assert_eq!(background.fills[0].solid_color().unwrap().to_rgba8(), [255, 191, 64, 255]);
}
}
/// Missing, invalid or blank generated text must not hide a usable outer text record.
#[test]
fn outer_text_survives_when_generated_text_does_not() {
for ty in ["STICKY", "SHAPE_WITH_TEXT"] {
for broken in 0..3 {
let mut layer = board_node(ty);
set(&mut layer, "textData", o(vec![("characters", s("Outer saved text"))]));
set(&mut layer, "fontSize", f(30.0));
set(
&mut layer,
"derivedImmutableFrameData",
o(vec![
("version", u(1)),
(
"overrides",
arr(vec![
record(0, vec![("size", xy(240.0, 240.0)), ("transform", at(0.0, 0.0))]),
record(1, vec![("size", xy(if broken == 1 { -1.0 } else { 200.0 }, 160.0)), ("transform", at(20.0, 20.0))]),
]),
),
]),
);
if broken == 0 {
set(
&mut layer,
"nodeGenerationData",
o(vec![("overrides", arr(vec![record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))])]))]),
);
} else if broken == 2 {
set(
&mut layer,
"nodeGenerationData",
o(vec![(
"overrides",
arr(vec![record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]), record(1, text(" ", 24.0))]),
)]),
);
}
let (doc, _) = imported(layer);
let frame = named(&doc, "Board object");
assert_eq!(frame.children.len(), 2, "background and fallback text both survive");
assert!(matches!(doc.node(frame.children[0]).unwrap().kind, NodeKind::Rectangle));
let text = doc.node(frame.children[1]).unwrap().kind.text().unwrap();
assert_eq!(text.characters, "Outer saved text");
assert_eq!(text.style.font_size, 30.0);
}
}
}
/// Valid generated text remains authoritative, while the outer fallback still obeys the cap.
#[test]
fn outer_text_fallback_avoids_duplicates_and_respects_the_layer_budget() {
let mut layer = board_node("STICKY");
set(&mut layer, "textData", o(vec![("characters", s("Outer saved text"))]));
let (doc, _) = imported(layer.clone());
assert!(!doc.nodes().any(|n| n.kind.text().is_some_and(|t| t.characters == "Outer saved text")));
set(
&mut layer,
"derivedImmutableFrameData",
o(vec![("version", u(1)), ("overrides", arr(vec![record(0, vec![("size", xy(240.0, 240.0)), ("transform", at(0.0, 0.0))])]))]),
);
for cap in 1..4 {
let (json, _, _) = map::to_rest(&message(layer.clone()), "Fallback budget", cap);
let children = json["document"]["children"][0]["children"][0]["children"].as_array().map_or(0, Vec::len);
assert_eq!(children + 1, cap);
}
}
/// A damaged generated network still keeps usable fill or stroke outlines on the outer shape.
#[test]
fn outer_shape_outline_survives_a_damaged_generated_network() {
for field in ["fillGeometry", "strokeGeometry"] {
let mut layer = board_node("SHAPE_WITH_TEXT");
set(&mut layer, field, arr(vec![o(vec![("commandsBlob", u(0)), ("windingRule", e("NONZERO"))])]));
let paint = if field == "fillGeometry" { "fillPaints" } else { "strokePaints" };
set(&mut layer, "nodeGenerationData", o(vec![("overrides", arr(vec![record(0, vec![(paint, arr(vec![solid(1.0, 0.75, 0.25)]))])]))]));
set(
&mut layer,
"derivedImmutableFrameData",
o(vec![
("version", u(1)),
(
"overrides",
arr(vec![record(
0,
vec![
("size", xy(240.0, 240.0)),
("transform", at(0.0, 0.0)),
("vectorData", o(vec![("vectorNetworkBlob", u(1)), ("normalizedSize", xy(240.0, 240.0))])),
],
)]),
),
]),
);
let mut msg = message(layer);
set(&mut msg, "blobs", arr(vec![o(vec![("bytes", Kv::Bytes(triangle_blob()))]), o(vec![("bytes", Kv::Bytes(b"damaged".to_vec()))])]));
let (doc, warnings) = import(&canvas(&board_schema(), &msg), "Outer shape outline").unwrap();
let frame = named(&doc, "Board object");
let background = doc.node(frame.children[0]).unwrap();
let NodeKind::Vector { network } = &background.kind else { panic!("saved outer triangle must remain a vector") };
assert_eq!(network.vertices.len(), 3, "{field}");
assert_eq!(network.segments.len(), 3, "{field}");
assert_eq!(background.fills[0].solid_color().unwrap().to_rgba8(), [255, 191, 64, 255]);
assert!(frame.fills.is_empty());
assert!(!warnings.iter().any(|w| w.contains("using its rectangle")), "{warnings:?}");
}
}
/// A valid generated author cannot replace a missing, invalid or blank sticky body.
#[test]
fn outer_sticky_body_survives_alongside_the_generated_author() {
for broken in 0..3 {
let mut layer = board_node("STICKY");
set(&mut layer, "textData", o(vec![("characters", s("Outer saved body"))]));
set(&mut layer, "fontSize", f(30.0));
let mut styles = vec![record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]), record(2, text("Test Author", 12.0))];
if broken != 0 {
styles.push(record(1, text(if broken == 2 { " " } else { "Generated body" }, 24.0)));
}
set(&mut layer, "nodeGenerationData", o(vec![("overrides", arr(styles))]));
set(
&mut layer,
"derivedImmutableFrameData",
o(vec![
("version", u(1)),
(
"overrides",
arr(vec![
record(0, vec![("size", xy(240.0, 240.0)), ("transform", at(0.0, 0.0))]),
record(1, vec![("size", xy(if broken == 1 { -1.0 } else { 200.0 }, 160.0)), ("transform", at(20.0, 20.0))]),
record(2, vec![("size", xy(200.0, 16.0)), ("transform", at(20.0, 204.0))]),
]),
),
]),
);
let (doc, _) = imported(layer.clone());
let frame = named(&doc, "Board object");
assert_eq!(frame.children.len(), 3, "background, body and author survive (case {broken})");
let texts: Vec<_> = frame.children.iter().filter_map(|id| doc.node(*id).and_then(|n| n.kind.text())).collect();
assert_eq!(texts.len(), 2);
let body = texts.iter().find(|t| t.characters == "Outer saved body").unwrap();
assert_eq!(body.style.font_size, 30.0);
let author = texts.iter().find(|t| t.characters == "Test Author").unwrap();
assert_eq!(author.style.font_size, 12.0);
for cap in 1..5 {
let (json, _, _) = map::to_rest(&message(layer.clone()), "Author fallback budget", cap);
let children = json["document"]["children"][0]["children"][0]["children"].as_array().map_or(0, Vec::len);
assert_eq!(children + 1, cap);
}
}
}
+3
View File
@@ -157,6 +157,9 @@ impl<'d> Exporter<'d> {
/// The editable network (with per-point corner radii, handle mirroring and end caps) and,
/// when it has filled areas, their outline.
pub(super) fn vector(&mut self, n: &Node, net: &VectorNetwork, f: &mut Fields) {
if net.regions.iter().any(|region| region.fills.is_some() || region.paint_space.is_some()) {
self.warn("vector region paints were left out of the .fig export");
}
let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum();
if net.vertices.len() + net.segments.len() + entries > MAX_NETWORK {
self.warn("vector networks too large for the format were written as outlines");
+45
View File
@@ -827,6 +827,51 @@ fn round_trip_keeps_the_details() {
assert_eq!(label.characters, "Hi");
}
#[test]
fn fig_export_warns_when_region_paints_are_left_out() {
let mut doc = generate(3);
let Some(id) = doc.nodes().find_map(|node| match &node.kind {
NodeKind::Vector { network } if !network.regions.is_empty() => Some(node.id),
_ => None,
}) else {
panic!("generated document has no filled vector");
};
let node = doc.node_mut(id).unwrap();
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
let region = network.regions.first_mut().unwrap();
region.fills = Some(Vec::new());
let (_, warnings) = export(&doc, None).unwrap();
assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
}
#[test]
fn fig_export_warns_when_inherited_region_paints_have_a_paint_frame() {
let mut doc = generate(3);
let id = doc
.nodes()
.find_map(|node| match &node.kind {
NodeKind::Vector { network } if !network.regions.is_empty() => Some(node.id),
_ => None,
})
.unwrap();
let node = doc.node_mut(id).unwrap();
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
let region = network.regions.first_mut().unwrap();
region.fills = None;
region.paint_space = Some(RegionPaintSpace { width: 20.0, height: 10.0, transform: Affine::translate((4.0, 8.0)) });
let (_, warnings) = export(&doc, None).unwrap();
assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
let mut plain = doc;
let node = plain.node_mut(id).unwrap();
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
network.regions[0].paint_space = None;
let (_, warnings) = export(&plain, None).unwrap();
assert!(!warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
}
#[test]
fn written_files_decode_and_list_parents_first() {
for seed in 0..20 {
+13
View File
@@ -100,6 +100,15 @@ pub(super) fn adapt_warning(w: String) -> Option<String> {
// ---- small readers ----
/// A generated child is identified by one exact template GUID, not its array position.
fn generated_record<'a>(v: &View<'a>, field: &str, local_id: u32) -> Option<&'a Kv<'a>> {
v.get(field)?.get("overrides")?.list().iter().find(|row| {
row.get("guidPath").and_then(|path| path.get("guids")).map(Kv::list).is_some_and(|guids| {
guids.len() == 1 && num(&guids[0], "sessionID") == Some(40_000_000.0) && num(&guids[0], "localID") == Some(f64::from(local_id))
})
})
}
fn num(n: &Kv, k: &str) -> Option<f64> {
n.get(k).and_then(Kv::f64)
}
@@ -248,6 +257,9 @@ struct Mapper<'a> {
networks: Vec<Option<VectorNetwork>>,
/// Index in `networks` by what a network is made from: its blob, its scale and its style table.
network_ids: HashMap<(usize, u64, u64, usize), usize>,
/// Region paint elements (paints and gradient stops) read so far: one budget for the whole
/// file, since layers with region paints don't share their network.
region_paint_elements: usize,
}
impl<'a> Mapper<'a> {
@@ -322,6 +334,7 @@ impl<'a> Mapper<'a> {
reserved: 0,
networks: Vec::new(),
network_ids: HashMap::new(),
region_paint_elements: 0,
}
}
+9 -18
View File
@@ -1,5 +1,5 @@
use super::layer::{cap_name, own_text, svg_path, valid_size};
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, guid, json, matrix, network, num, rows};
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, 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
@@ -58,7 +58,7 @@ impl<'a> Mapper<'a> {
{
return false;
}
let Some(line) = connector_record(v, "derivedImmutableFrameData", 0) else { return false };
let Some(line) = generated_record(v, "derivedImmutableFrameData", 0) else { return false };
if line.get("transform").and_then(matrix).is_none() || valid_size(line.get("size")).is_none() {
return false;
}
@@ -76,7 +76,7 @@ impl<'a> Mapper<'a> {
.and_then(|index| self.blobs.get(index as usize))
.and_then(|blob| network::decode(blob))
.is_some_and(|network| !network.segments.is_empty());
(geometry || network) && connector_record(v, "nodeGenerationData", 0).is_some()
(geometry || network) && generated_record(v, "nodeGenerationData", 0).is_some()
}
pub(super) fn connector_snapshot_flatten(&mut self, out: &mut Obj, v: &View<'a>, rest_id: &str, space: Affine) {
@@ -121,7 +121,7 @@ impl<'a> Mapper<'a> {
/// Build the saved vector child or flattened terminal connector from the same child-only view.
fn connector_snapshot_vector(&mut self, v: &View<'a>, rest_id: &str, space: Affine, nested: bool) -> Option<Obj> {
let (Some(line), Some(line_style)) = (connector_record(v, "derivedImmutableFrameData", 0), connector_record(v, "nodeGenerationData", 0))
let (Some(line), Some(line_style)) = (generated_record(v, "derivedImmutableFrameData", 0), generated_record(v, "nodeGenerationData", 0))
else {
return None;
};
@@ -156,19 +156,19 @@ impl<'a> Mapper<'a> {
children.push(Value::Object(vector));
self.count += 1;
let Some(text_frame) = connector_record(v, "derivedImmutableFrameData", 1) else {
let Some(text_frame) = generated_record(v, "derivedImmutableFrameData", 1) else {
if connector_has_saved_label(v) {
self.warn("connector label was skipped: derived text bounds are missing");
}
return children;
};
let Some(background) = connector_record(v, "derivedImmutableFrameData", 2) else {
let Some(background) = generated_record(v, "derivedImmutableFrameData", 2) else {
if connector_has_saved_label(v) || valid_size(text_frame.get("size")).is_some_and(|(w, h)| w > 0.0 && h > 0.0) {
self.warn("connector label was skipped: derived background bounds are missing");
}
return children;
};
let Some(text_style) = connector_record(v, "nodeGenerationData", 1) else {
let Some(text_style) = generated_record(v, "nodeGenerationData", 1) else {
if valid_size(text_frame.get("size")).is_some_and(|(w, h)| w > 0.0 && h > 0.0) {
self.warn("connector label was skipped: text style data is missing");
}
@@ -200,7 +200,7 @@ impl<'a> Mapper<'a> {
return children;
}
let Some(background_style) = connector_record(v, "nodeGenerationData", 2) else {
let Some(background_style) = generated_record(v, "nodeGenerationData", 2) else {
self.warn("connector label background was skipped: style data is missing");
self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view);
return children;
@@ -252,18 +252,9 @@ impl<'a> Mapper<'a> {
}
}
/// A generated connector child is identified by one exact template GUID, not its array position.
fn connector_record<'a>(v: &View<'a>, field: &str, local_id: u32) -> Option<&'a Kv<'a>> {
v.get(field)?.get("overrides")?.list().iter().find(|row| {
row.get("guidPath").and_then(|path| path.get("guids")).map(Kv::list).is_some_and(|guids| {
guids.len() == 1 && num(&guids[0], "sessionID") == Some(40_000_000.0) && num(&guids[0], "localID") == Some(f64::from(local_id))
})
})
}
pub(super) fn connector_has_saved_label(v: &View<'_>) -> bool {
own_text(v).is_some()
|| connector_record(v, "nodeGenerationData", 1)
|| generated_record(v, "nodeGenerationData", 1)
.and_then(|r| r.get("textData"))
.and_then(|d| d.get("characters"))
.and_then(Kv::str)
+174 -40
View File
@@ -1,10 +1,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::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke, style_link};
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,
Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, generated_record, guid, json, matrix,
network, num, rows, size,
};
/// How the layers of a resized container follow it when the file gives no derived layout for
@@ -251,8 +251,12 @@ impl<'a> Mapper<'a> {
slot_content,
});
if stand.is_some_and(|s| s.0 == "FRAME") {
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 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));
@@ -339,6 +343,85 @@ impl<'a> Mapper<'a> {
}
}
/// FigJam notes and shapes keep their paints and text on generated template children,
/// not on the outer node. Keep the saved layout as ordinary editable layers (including a
/// note's author), without applying the background's opacity or transform to the wrapper.
/// When no generated body survives, retain usable outer text alongside successful children.
fn board_snapshot(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str, depth: usize) -> Option<Vec<Value>> {
if !matches!(own, "STICKY" | "SHAPE_WITH_TEXT") || v.get("nodeGenerationData").is_none() {
return None;
}
if v.get("derivedImmutableFrameData").and_then(|d| num(d, "version")) != Some(1.0) {
self.warn("note or shape generated layout is missing or unsupported; using its outer frame");
return None;
}
let mut children = Vec::new();
let mut has_body = false;
for local_id in 0..if own == "STICKY" { 3 } else { 2 } {
let style = generated_record(v, "nodeGenerationData", local_id);
let bounds = generated_record(v, "derivedImmutableFrameData", local_id);
let (Some(style), Some(bounds)) = (style, bounds) else {
if style.is_some() || bounds.is_some() {
self.warn("note or shape generated child is missing its style or bounds; child skipped");
}
continue;
};
let child = View::of(style).with([bounds]);
let chars = own_text(&child);
if local_id != 0 && chars.is_none() {
continue;
}
if valid_size(child.get("size")).is_none() || child.transform().is_none() {
self.warn("note or shape generated child has invalid bounds; child skipped");
continue;
}
if self.count >= self.budget() || depth >= MAX_DEPTH {
self.warn("note or shape generated children exceed the layer or nesting limit; remaining children skipped");
break;
}
let mut layer = Obj::new();
let ty = if local_id == 0 { "RECTANGLE" } else { "TEXT" };
layer.insert("id".into(), json!(format!("{rest_id};generated{local_id}")));
layer.insert("type".into(), json!(ty));
layer.insert("name".into(), json!(chars.map_or_else(|| "Background".into(), |c| c.chars().take(40).collect::<String>())));
self.common(&mut layer, &child, ty, Affine::IDENTITY, None);
if local_id == 0 {
// Network decoding is only enabled for vectors; choose that type before
// geometry sees a saved network, then restore the rectangle if it is unusable.
if child.get("vectorData").is_some() {
layer.insert("type".into(), json!("VECTOR"));
}
self.geometry(&mut layer, &child);
let outlined = |layer: &Obj| ["fillGeometry", "strokeGeometry", "vectorNetworkRef"].iter().any(|key| layer.contains_key(*key));
if own == "SHAPE_WITH_TEXT" && !outlined(&layer) {
self.geometry(&mut layer, v);
}
if outlined(&layer) {
layer.insert("type".into(), json!("VECTOR"));
} else {
layer.insert("type".into(), json!("RECTANGLE"));
if own == "SHAPE_WITH_TEXT" && !matches!(v.str("shapeWithTextType"), None | Some("SQUARE" | "ROUNDED_RECTANGLE")) {
self.warn("shape generated outline is missing or damaged; using its rectangle");
}
}
o.insert("fills".into(), json!([]));
o.insert("strokes".into(), json!([]));
} else {
self.text(&mut layer, &child);
if let Some(Value::Object(style)) = layer.get_mut("style") {
style.insert("textAutoResize".into(), json!("NONE"));
}
}
self.count += 1;
has_body |= local_id == 1;
children.push(Value::Object(layer));
}
if depth < MAX_DEPTH && !has_body {
children.extend(self.inner_text(o, v, rest_id, own == "STICKY"));
}
Some(children)
}
/// A non-rectangular shape-with-text's shape, as a vector under its frame (which gives up its
/// paints to it).
fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option<Value> {
@@ -566,6 +649,84 @@ impl<'a> Mapper<'a> {
size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n)
};
let (sx, sy) = (scale("x"), scale("y"));
let vertices: Vec<Value> = net
.vertices
.iter()
.map(|p| {
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
let st = style(p.style);
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
out["radius"] = json!(r);
}
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
Some("ANGLE") => out["mirror"] = json!("angle"),
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
Some("NONE") => out["mirror"] = json!("none"),
_ => {}
}
if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) {
out["join"] = json!(join.to_ascii_lowercase());
}
out
})
.collect();
let segments: Vec<Value> =
net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect();
let regions: Vec<Value> = net
.regions
.iter()
.map(|r| {
let mut region = json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }});
if r.style != 0 {
match style(r.style) {
Some(s) => {
let fills = s.get("fillPaints").and_then(|own_fills| {
if own_fills.list().iter().any(paint_bound) {
return Some(own_fills);
}
let linked_style = style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID")
.and_then(|id| self.nodes.get(&id).copied())
.filter(|style| style.get("styleType").and_then(Kv::str) == Some("FILL"));
match linked_style {
Some(style) => style.get("fillPaints"),
None => Some(own_fills),
}
});
if let Some(fills) = fills {
if let Kv::Array(source_paints) = fills {
let paint_cost = source_paints.iter().fold(source_paints.len(), |cost, paint| {
cost.saturating_add(paint.get("stops").map(Kv::list).unwrap_or_default().len())
});
let oversized_stops =
source_paints.iter().any(|paint| paint.get("stops").map(Kv::list).unwrap_or_default().len() > 256);
if source_paints.len() > 256 || oversized_stops || self.region_paint_elements.saturating_add(paint_cost) > 200_000
{
self.warn("vector region paint data exceeded import limits and was skipped");
return region;
}
self.region_paint_elements += paint_cost;
let paints: Vec<Value> = source_paints.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect();
if paints.len() != source_paints.len() {
self.warn("invalid vector region paints were skipped");
}
if paints.iter().any(has_bound_variables) {
self.warn("variable-bound vector region paints keep resolved appearance; live bindings were left out");
}
if s.get("styleIdForFill").is_some() {
self.warn("linked vector region fill styles keep resolved appearance; live style links were left out");
}
region["_figRegionFills"] = Value::Array(paints);
} else {
self.warn("invalid vector region fill paint list; layer fill used");
}
}
}
None => self.warn("vector region references a missing fill style; layer fill used"),
}
}
region
})
.collect();
// An absent layer join is the source default.
let layer_join = v.str("strokeJoin").unwrap_or("MITER");
let point_join = |p: &network::Vertex| style(p.style).and_then(|s| s.get("strokeJoin"));
@@ -573,9 +734,6 @@ impl<'a> Mapper<'a> {
let unresolved_style = net.vertices.iter().any(|p| p.style != 0 && style(p.style).is_none());
let unknown_join = net.vertices.iter().any(|p| point_join(p).is_some_and(|j| !j.str().is_some_and(known)));
let unsupported_join = net.vertices.iter().any(|p| point_join(p).and_then(Kv::str).is_some_and(|j| known(j) && j != layer_join));
if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) {
self.warn("filled areas of a vector with their own fill use the layer's fill");
}
if unresolved_style {
self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners");
}
@@ -604,42 +762,18 @@ impl<'a> Mapper<'a> {
o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node))));
}
// Every instance of a main shows the same network: it is written once and shared.
let mut entry = json!({});
entry["vertices"] = Value::Array(vertices);
entry["segments"] = Value::Array(segments);
entry["regions"] = Value::Array(regions.clone());
if regions.iter().any(|r| r.get("_figRegionFills").is_some()) {
o.insert("vectorNetwork".into(), entry);
return true;
}
let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize);
let id = match self.network_ids.get(&key) {
Some(id) => *id,
None => {
let vertices: Vec<Value> = net
.vertices
.iter()
.map(|p| {
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
let st = style(p.style);
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
out["radius"] = json!(r);
}
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
Some("ANGLE") => out["mirror"] = json!("angle"),
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
Some("NONE") => out["mirror"] = json!("none"),
_ => {}
}
if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) {
out["join"] = json!(join.to_ascii_lowercase());
}
out
})
.collect();
let segments: Vec<Value> = net
.segments
.iter()
.map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]}))
.collect();
let regions: Vec<Value> =
net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect();
let mut entry = json!({});
entry["vertices"] = Value::Array(vertices);
entry["segments"] = Value::Array(segments);
entry["regions"] = Value::Array(regions);
self.networks.push(crate::rest::network(&entry));
self.network_ids.insert(key, self.networks.len() - 1);
self.networks.len() - 1
+8
View File
@@ -217,6 +217,14 @@ pub(crate) fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option<Value> {
Some(Value::Object(o))
}
pub(super) fn has_bound_variables(value: &Value) -> bool {
match value {
Value::Object(object) => object.contains_key("boundVariables") || object.values().any(has_bound_variables),
Value::Array(values) => values.iter().any(has_bound_variables),
_ => false,
}
}
/// The variables bound to the fields of a paint stop, an effect or a layout grid, as its REST
/// `boundVariables`: `fields` pairs the format's field with the REST name.
pub(super) fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option<Value> {
+250
View File
@@ -264,6 +264,256 @@ fn tail_canvas() -> Vec<u8> {
canvas(&schema(), &tail_message())
}
fn region_canvas(fill_paints: Option<Vec<Kv<'static>>>, style_id: u32) -> Vec<u8> {
let mut message = tail_message();
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
let vector_data = field_mut(wave, "vectorData").unwrap();
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
let Kv::Object(fields) = styles.first_mut().unwrap() else { panic!() };
if let Some(fill_paints) = fill_paints {
fields.push(("fillPaints", arr(fill_paints)));
}
let mut network = network::decode(&square_blob()).unwrap();
network.regions[0].style = style_id;
let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
let bytes = field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap();
*bytes = Kv::Bytes(network::encode(&network));
canvas(&schema(), &message)
}
fn region_canvas_with_link(own: Option<Kv<'static>>, link_target: Option<u64>, linked_fill: Kv<'static>) -> Vec<u8> {
region_canvas_with_typed_link(own, link_target, "FILL", linked_fill)
}
fn region_canvas_with_typed_link(own: Option<Kv<'static>>, link_target: Option<u64>, style_type: &'static str, linked_fill: Kv<'static>) -> Vec<u8> {
let mut message = tail_message();
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
let vector_data = field_mut(wave, "vectorData").unwrap();
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
let Kv::Object(fields) = styles.first_mut().unwrap() else { panic!() };
if let Some(own) = own {
fields.push(("fillPaints", arr(vec![own])));
}
if let Some(target) = link_target {
fields.push(("styleIdForFill", o(vec![("guid", g(target))])));
}
nodes.push(node(50, None, "RECTANGLE", "Linked Fill", vec![("styleType", e(style_type)), ("fillPaints", arr(vec![linked_fill]))]));
let mut network = network::decode(&square_blob()).unwrap();
network.regions[0].style = 1;
let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
*field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap() = Kv::Bytes(network::encode(&network));
canvas(&schema(), &message)
}
fn field_mut<'a, 's>(value: &'a mut Kv<'s>, name: &str) -> Option<&'a mut Kv<'s>> {
if let Kv::Object(fields) = value {
return fields.iter_mut().find(|(key, _)| *key == name).map(|(_, field)| field);
}
None
}
#[test]
fn vector_region_fills_import_from_style_table() {
let solid = o(vec![
("type", e("SOLID")),
("color", o(vec![("r", f(0.2)), ("g", f(0.4)), ("b", f(0.8)), ("a", f(0.5))])),
("opacity", f(0.5)),
("visible", Kv::Bool(false)),
]);
let gradient = o(vec![
("type", e("GRADIENT_LINEAR")),
(
"stops",
arr(vec![
o(vec![("position", f(0.0)), ("color", o(vec![("r", f(1.0)), ("g", f(0.0)), ("b", f(0.0)), ("a", f(1.0))]))]),
o(vec![("position", f(1.0)), ("color", o(vec![("r", f(0.0)), ("g", f(0.0)), ("b", f(1.0)), ("a", f(0.25))]))]),
]),
),
]);
let image_hash = vec![0x12, 0x34, 0x56];
let image = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(image_hash.clone()))])), ("imageScaleMode", e("FILL"))]);
let wire = region_canvas(Some(vec![solid, gradient, image]), 1);
let (doc, warnings) = import(&wire, "Region fills").unwrap();
let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
assert_eq!((color.r, color.g, color.b, fills[0].opacity, fills[0].visible), (0.2, 0.4, 0.8, 0.25, false));
assert!(matches!(&fills[1].kind, figcraft_doc::color::PaintKind::Gradient(g) if g.stops.len() == 2 && g.stops[1].color.a == 0.25));
let figcraft_doc::color::PaintKind::Image(imported_image) = &fills[2].kind else { panic!("{fills:?}") };
let image_key = imported_image.image.clone();
assert!(doc.images.contains_key(&image_key), "image reference must retain its image placeholder");
assert!(!warnings.iter().any(|warning| warning.contains("their own fill use the layer's fill")), "{warnings:?}");
let saved = crate::save_bytes(&doc, None).unwrap();
let (reopened, _) = crate::load_bytes(&saved).unwrap();
assert_eq!(network_of(&reopened, "Wave").regions[0].fills, Some(fills.clone()));
assert!(reopened.images.contains_key(&image_key), "image reference must survive native save/reopen");
}
#[test]
fn vector_region_missing_and_empty_paints_remain_distinct() {
let (inherited, _) = import(&region_canvas(None, 1), "Inherited region").unwrap();
assert_eq!(network_of(&inherited, "Wave").regions[0].fills, None);
let (empty, _) = import(&region_canvas(Some(Vec::new()), 1), "Empty region").unwrap();
assert_eq!(network_of(&empty, "Wave").regions[0].fills, Some(Vec::new()));
}
#[test]
fn vector_region_linked_fill_uses_style_paints_without_recording_definition() {
let linked = solid(0.9, 0.1, 0.2);
let own = solid(0.1, 0.2, 0.9);
let wire = region_canvas_with_link(Some(own), Some(50), linked);
let (doc, _) = import(&wire, "Linked region style").unwrap();
let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
assert_eq!((color.r, color.g, color.b), (0.9, 0.1, 0.2));
assert!(doc.nodes().all(|node| node.name != "Linked Fill"), "style lookup must not add a style as a layer");
let archive = Archive::open(&wire).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let message = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let (rest, _, _) = map::to_rest(&message, "Linked region style", crate::rest::MAX_NODES);
assert!(rest.get("styles").is_none(), "regional link must not populate the layer styles map");
}
#[test]
fn vector_region_variable_bound_own_fill_wins_over_linked_style() {
let own =
o(vec![("type", e("SOLID")), ("color", o(vec![("r", f(0.1)), ("g", f(0.2)), ("b", f(0.9)), ("a", f(1.0))])), ("colorVar", alias_data(41))]);
let (doc, _) = import(&region_canvas_with_link(Some(own), Some(50), solid(0.9, 0.1, 0.2)), "Bound region style").unwrap();
let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9));
}
#[test]
fn vector_region_missing_fills_inherit_and_unresolved_link_keeps_own_fill() {
let (inherited, _) = import(&region_canvas_with_link(None, Some(50), solid(0.9, 0.1, 0.2)), "Inherited linked region").unwrap();
assert_eq!(network_of(&inherited, "Wave").regions[0].fills, None);
let own = solid(0.1, 0.2, 0.9);
let (fallback, _) = import(&region_canvas_with_link(Some(own), Some(999), solid(0.9, 0.1, 0.2)), "Unresolved linked region").unwrap();
let fills = network_of(&fallback, "Wave").regions[0].fills.as_ref().unwrap();
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9));
}
#[test]
fn vector_region_link_to_non_fill_style_keeps_own_fill() {
let own = solid(0.1, 0.2, 0.9);
let (doc, _) = import(&region_canvas_with_typed_link(Some(own), Some(50), "TEXT", solid(0.9, 0.1, 0.2)), "Text-linked region").unwrap();
let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9));
}
#[test]
fn vector_region_paint_budget_covers_the_whole_file() {
// 190 gradients of 255 stops: 48,640 elements per layer, so four layers fit and a fifth doesn't.
let stop = o(vec![("position", f(0.0)), ("color", o(vec![("r", f(1.0)), ("g", f(0.0)), ("b", f(0.0)), ("a", f(1.0))]))]);
let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("stops", arr(vec![stop; 255]))]);
let mut message = tail_message();
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
let vector_data = field_mut(wave, "vectorData").unwrap();
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
let Kv::Object(style_fields) = styles.first_mut().unwrap() else { panic!() };
style_fields.push(("fillPaints", arr(vec![gradient; 190])));
let wave = wave.clone();
for (index, position) in ["w", "x", "y", "z"].into_iter().enumerate() {
let mut copy = wave.clone();
let Kv::Object(fields) = &mut copy else { panic!() };
for (key, value) in fields.iter_mut() {
match *key {
"guid" => *value = g(60 + index as u64),
"name" => *value = Kv::Str(format!("Wave {index}")),
"parentIndex" => *value = o(vec![("guid", g(1)), ("position", s(position))]),
_ => {}
}
}
nodes.push(copy);
}
let mut network = network::decode(&square_blob()).unwrap();
network.regions[0].style = 1;
let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
*field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap() = Kv::Bytes(network::encode(&network));
let (doc, warnings) = import(&canvas(&schema(), &message), "Region paint budget").unwrap();
let painted = ["Wave", "Wave 0", "Wave 1", "Wave 2", "Wave 3"].iter().filter(|name| network_of(&doc, name).regions[0].fills.is_some()).count();
assert_eq!(painted, 4);
assert!(warnings.iter().any(|warning| warning.contains("region paint data exceeded import limits")), "{warnings:?}");
}
#[test]
fn vector_regions_keep_distinct_solid_paints() {
let mut message = tail_message();
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
let vector_data = field_mut(wave, "vectorData").unwrap();
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
let Kv::Object(first_style) = styles.first_mut().unwrap() else { panic!() };
first_style.push(("fillPaints", arr(vec![solid(0.1, 0.2, 0.9)])));
let Kv::Object(second_style) = o(vec![("styleID", u(2)), ("fillPaints", arr(vec![solid(0.9, 0.1, 0.2)]))]) else { panic!() };
styles.push(Kv::Object(second_style));
let mut network = network::decode(&square_blob()).unwrap();
network.regions[0].style = 1;
network.regions.push(network::Region { style: 2, non_zero: network.regions[0].non_zero, loops: network.regions[0].loops.clone() });
let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
let bytes = field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap();
*bytes = Kv::Bytes(network::encode(&network));
let (doc, _) = import(&canvas(&schema(), &message), "Two region fills").unwrap();
let regions = &network_of(&doc, "Wave").regions;
assert_eq!(regions.len(), 2);
let color = |index: usize| match &regions[index].fills.as_ref().unwrap()[0].kind {
figcraft_doc::color::PaintKind::Solid { color } => (color.r, color.g, color.b),
paint => panic!("{paint:?}"),
};
assert_eq!(color(0), (0.1, 0.2, 0.9));
assert_eq!(color(1), (0.9, 0.1, 0.2));
}
#[test]
fn vector_region_gradient_limit_warns_instead_of_silently_truncating() {
for count in [256, 257] {
let stops = (0..count)
.map(|i| {
let value = if i + 1 == count { 1.0 } else { 0.0 };
o(vec![
("position", f(i as f64 / (count - 1) as f64)),
("color", o(vec![("r", f(value)), ("g", f(value)), ("b", f(value)), ("a", f(1.0))])),
])
})
.collect();
let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("stops", arr(stops))]);
let (doc, warnings) = import(&region_canvas(Some(vec![gradient]), 1), "Bounded region gradient").unwrap();
let fills = &network_of(&doc, "Wave").regions[0].fills;
if count == 256 {
let figcraft_doc::color::PaintKind::Gradient(g) = &fills.as_ref().unwrap()[0].kind else { panic!() };
assert_eq!(g.stops.len(), 256);
assert_eq!(g.stops.last().unwrap().color.r, 1.0);
assert!(!warnings.iter().any(|w| w.contains("vector region paint data exceeded")));
} else {
assert!(fills.is_none());
assert!(warnings.iter().any(|w| w.contains("vector region paint data exceeded import limits")), "{warnings:?}");
}
}
}
#[test]
fn vector_region_invalid_paints_and_style_refs_warn() {
let malformed = o(vec![("opacity", f(0.5))]);
let unsupported = o(vec![("type", e("EMOJI"))]);
let (doc, warnings) = import(&region_canvas(Some(vec![malformed, unsupported]), 1), "Invalid region paints").unwrap();
assert_eq!(network_of(&doc, "Wave").regions[0].fills, Some(Vec::new()));
assert!(warnings.iter().any(|warning| warning.contains("invalid vector region paints were skipped")), "{warnings:?}");
assert!(warnings.iter().any(|warning| warning.contains("EMOJI paints are not supported")), "{warnings:?}");
let (doc, warnings) = import(&region_canvas(None, 9), "Missing region style").unwrap();
assert_eq!(network_of(&doc, "Wave").regions[0].fills, None);
assert!(warnings.iter().any(|warning| warning.contains("vector region references a missing fill style")), "{warnings:?}");
}
fn network_of<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::VectorNetwork {
match &named(doc, name).kind {
NodeKind::Vector { network } => network,
+6 -2
View File
@@ -13,7 +13,8 @@ use super::*;
/// Message field ids count from 1; enum values from 0.
const SCHEMA: &str = "
enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE STICKY VARIABLE VARIABLE_SET SHAPE_WITH_TEXT CONNECTOR TABLE
enum PaintType SOLID GRADIENT_LINEAR IMAGE
enum PaintType SOLID GRADIENT_LINEAR IMAGE EMOJI
enum ImageScaleMode STRETCH FIT FILL TILE
enum WindingRule NONZERO ODD
enum StyleType NONE FILL TEXT
enum StackMode NONE HORIZONTAL VERTICAL
@@ -51,7 +52,7 @@ message ConditionalActions Action[] actions, VariableData condition
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition
message VariableData AnyValue value, DataType dataType
message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar
message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode
message ParentIndex GUID guid, string position
message FontName string family, string style, string postscript
message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable
@@ -1248,3 +1249,6 @@ fn dump_samples() {
std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap();
}
}
#[path = "board_import_tests.rs"]
mod board;
+3
View File
@@ -23,8 +23,11 @@ pub mod rest;
pub mod sketch;
pub mod thumbnail;
pub use density::{pixel_density, placed_size};
pub use library::{is_valid_library_id, read_library, read_library_within};
mod density;
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Read, Seek, Write};
use std::sync::Arc;
+29 -3
View File
@@ -738,9 +738,30 @@ impl<'a> Ctx<'a> {
/// (exact look, editable as a shape): returns whether it does, and the caller moves the paints
/// with [`outline_stroke`] once the styles and variables of the stroke are read.
fn vector(&mut self, n: &mut Node, j: &Value) -> bool {
let net = match j.get("vectorNetworkRef") {
Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
None => j.get("vectorNetwork").map(network),
let net = match j.get("vectorNetwork") {
Some(net) => Some(network(net).map(|mut network| {
// Private `.fig` mapper handoff only. Public REST `vectorNetwork` remains
// native network JSON; it never consumes the private mapper paint fields.
if self.fig_sources.is_some()
&& let Some(regions) = net.get("regions").and_then(Value::as_array)
{
for (region, source) in network.regions.iter_mut().zip(regions) {
let Some(fills) = source.get("_figRegionFills") else { continue };
let Some(list) = fills.as_array() else {
self.warn("invalid vector region paints were skipped");
continue;
};
let before = list.len();
let wrapper = serde_json::json!({"fills": fills});
region.fills = props::paints(self, &wrapper, "fills");
if region.fills.as_ref().is_some_and(|parsed| parsed.len() < before) {
self.warn("invalid vector region paints were skipped");
}
}
}
network
})),
None => j.get("vectorNetworkRef").map(|r| r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
};
if let Some(net) = net {
match net {
@@ -1391,6 +1412,11 @@ pub(crate) fn network(v: &Value) -> Option<VectorNetwork> {
if !fits {
return None;
}
// Region paints come only from the validated `.fig` mapper handoff, never from public JSON.
for r in &mut net.regions {
r.fills = None;
r.paint_space = None;
}
for p in &mut net.vertices {
p.radius = p.radius.clamp(0.0, MAX_SIZE);
}
@@ -42,6 +42,24 @@ fn stroke_only_vector_shared_stroke_style_keeps_paints() {
}
}
#[test]
fn public_rest_ignores_region_fills_and_private_fig_handoff() {
let network = json!({
"vertices": [{"x": 0, "y": 0}, {"x": 10, "y": 0}, {"x": 10, "y": 10}],
"segments": [{"a": 0, "b": 1}, {"a": 1, "b": 2}, {"a": 2, "b": 0}],
"regions": [{"loops": [[0, 1, 2]], "winding": "nonZero", "fills": [{"type": "solid", "color": {"r": 0, "g": 1, "b": 0}}],
"paint_space": {"width": 10, "height": 10, "transform": [1, 0, 0, 1, 5, 5]},
"_figRegionFills": [{"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0}}]}]
});
let node = json!({"id": "1:1", "name": "Vector", "type": "VECTOR", "relativeTransform": at(0.0, 0.0),
"size": {"x": 10, "y": 10}, "vectorNetwork": network});
let (doc, _) = import(&file(json!([node])), &no_images).unwrap();
let vector = named(&doc, "Vector");
let NodeKind::Vector { network } = &vector.kind else { panic!("{:#?}", vector.kind) };
assert!(network.regions[0].fills.is_none());
assert!(network.regions[0].paint_space.is_none());
}
#[test]
fn stroke_colour_variable_on_outlined_vector_is_kept() {
let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}, "boundVariables": {"color": alias(BRAND)}});
+42 -2
View File
@@ -159,12 +159,15 @@ fn swatches_bind_solid_fills_only() {
}
/// Sketch 2025+ masters keep their background, corners and stack in their style and layout, and
/// their instances show the same.
/// their instances show the same (the background when the master includes it in instances and
/// exports).
#[test]
fn masters_and_instances_keep_frame_styles_and_stacks() {
let mut m = master("M", "S", "Button", 120.0, 44.0, vec![rect("Icon", 0.0, 0.0, 20.0, 20.0), rect("Label", 0.0, 0.0, 60.0, 20.0)]);
m["groupBehavior"] = json!(1);
m["clippingBehavior"] = json!(2);
m["includeBackgroundColorInInstance"] = json!(true);
m["includeBackgroundColorInExport"] = json!(true);
m["style"]["fills"] = json!([solid_fill(0.0, 0.0, 1.0, 1.0)]);
m["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [8], "style": 1, "smoothing": 0.6});
let m = stack(m, 0, 1, 1, 10.0, [16.0, 12.0, 16.0, 12.0]);
@@ -292,6 +295,8 @@ fn layering_type_decides_tint_or_background() {
bg["layeringType"] = json!(0);
let mut btn = master("MC", "SC", "Btn", 100.0, 20.0, vec![]);
btn["groupBehavior"] = json!(1);
btn["includeBackgroundColorInInstance"] = json!(true);
btn["includeBackgroundColorInExport"] = json!(true);
btn["style"]["fills"] = json!([bg]);
let doc = open_symbols(vec![instance("B", "SB", 0.0, 0.0, 100.0, 20.0, &[]), instance("C", "SC", 0.0, 50.0, 100.0, 20.0, &[])], vec![bar, btn]);
let b = doc.nodes().find(|n| n.name == "B").unwrap();
@@ -357,7 +362,8 @@ fn glass_and_progressive_blurs() {
assert!(matches!(node(&doc, "Glass").effects[0].kind, figcraft_doc::EffectKind::Glass(_)), "{warnings:?}");
let figcraft_doc::EffectKind::BackgroundBlur(b) = &node(&doc, "Prog").effects[0].kind else { panic!("not a background blur") };
assert_eq!(b.blur_type, figcraft_doc::BlurType::Progressive);
assert_eq!((b.start_radius, b.radius), (24.0, 0.0));
// Sketch's radius is the blur's standard deviation; FigCraft's twice it.
assert_eq!((b.start_radius, b.radius), (48.0, 0.0));
}
/// Angled (2), inside-square (3) and inside-arc (4) corners make the rectangle a path of that outline.
@@ -441,3 +447,37 @@ fn faded_stack_items_keep_their_sizing() {
let wrap = doc.node(row.children[0]).unwrap();
assert_eq!(format!("{:?}", wrap.child_layout.sizing_h), "Fill", "{:?}", wrap.child_layout);
}
/// Tints came with Sketch 64 (format 124 and newer): before them a group's fill draws nothing, so
/// its layers keep their own colours (seen on a Sketch 63 file's renders).
#[test]
fn group_fills_tint_only_from_sketch_64() {
let make = |version: u64| {
let white = styled(rect("Dot", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(1.0, 1.0, 1.0, 1.0)]));
let g = styled(group("G", 0.0, 0.0, 10.0, 10.0, vec![white]), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)]));
let z = sketch_zip(if version > 123 { "64" } else { "63.1" }, version, document(&["P"]), &[page("P", "Page", vec![g])], &[]);
import(&z, "f").unwrap().0
};
assert_eq!(solid_of(node(&make(123), "Dot")), Some([1.0, 1.0, 1.0]));
assert_eq!(solid_of(node(&make(124), "Dot")), Some([0.0, 0.0, 0.0]));
}
/// A stretching stack (`alignItems` 3) fills its items across, fixed and hugging ones too.
#[test]
fn stretching_stacks_fill_items_across() {
let mut fixed = rect("Fixed", 0.0, 0.0, 60.0, 20.0);
fixed["horizontalSizing"] = json!(0);
let mut hug = frame_group("Hug", 0.0, 20.0, 60.0, 20.0, vec![]);
hug["horizontalSizing"] = json!(1);
let col = |name: &str, align: i64| stack(frame_group(name, 0.0, 0.0, 60.0, 40.0, vec![hug.clone(), fixed.clone()]), 1, 0, align, 0.0, [0.0; 4]);
let (doc, _) = open(vec![col("Start", 0), col("Stretch", 3)]);
let sizing = |parent: &str, name: &str| {
let p = node(&doc, parent);
let n = p.children.iter().filter_map(|c| doc.node(*c)).find(|n| n.name == name).unwrap();
n.child_layout.sizing_h
};
use figcraft_doc::Sizing as LayoutSizing;
assert_eq!(sizing("Start", "Fixed"), LayoutSizing::Fixed);
assert_eq!(sizing("Stretch", "Fixed"), LayoutSizing::Fill);
assert_eq!(sizing("Stretch", "Hug"), LayoutSizing::Fill);
}
+23 -2
View File
@@ -96,7 +96,7 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) -
})
.collect();
let winding = winding(l);
let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() };
let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding, ..Default::default() }] } else { Vec::new() };
Some(VectorNetwork { vertices, segments, regions })
}
@@ -232,6 +232,21 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize,
out
}
/// `node` (REST JSON) and the layers in it without strokes.
fn unstroked(node: &mut Value, depth: usize) {
if depth > 64 {
return;
}
if let Some(m) = node.as_object_mut() {
m.remove("strokes");
if let Some(kids) = m.get_mut("children").and_then(Value::as_array_mut) {
for k in kids {
unstroked(k, depth + 1);
}
}
}
}
/// A shape group of one shape: that shape in the group's place, with its name and style.
fn single(cx: &mut Cx, l: &Value, only: &Value, parent: Affine, world: Affine, depth: usize, root: bool) -> Option<Value> {
let mut node = layer(cx, only, world, depth + 1, false)?;
@@ -301,6 +316,12 @@ fn combined(cx: &mut Cx, l: &Value, kids: &[Value], parent: Affine, world: Affin
if operands.is_empty() {
continue;
}
// Sketch draws a shape group in the group's style only. Its operands' own borders go: a
// FigCraft operand with only a stroke gives its stroke's band, not its area.
let mut operands = operands;
for o in &mut operands {
unstroked(o, 0);
}
let this = run.first().map_or("UNION", |c| boolean_op(c));
if i > 0 && this != op && chain.len() > 1 {
let inner = synthetic(l, op, parent, world, w, h, std::mem::take(&mut chain));
@@ -354,7 +375,7 @@ fn compound(cx: &mut Cx, run: &[&Value], winding: WindingRule) -> Option<VectorN
return None;
}
if !loops.is_empty() {
out.regions.push(Region { loops, winding });
out.regions.push(Region { loops, winding, ..Default::default() });
}
Some(out)
}
+17
View File
@@ -233,3 +233,20 @@ fn open_operands_of_booleans_are_closed() {
let NodeKind::Vector { network } = &node(&doc, "Snout").kind else { panic!("not a vector") };
assert_eq!(network.regions.len(), 1);
}
/// Sketch draws a shape group in the group's style only: its operands' own borders go, so a
/// FigCraft operand gives its area to the operation, not a stroke's band.
#[test]
fn shape_group_operands_lose_their_borders() {
let border = json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": 0, "thickness": 8}]);
let outer = styled(with_op(rect("Outer", 0.0, 0.0, 100.0, 100.0), 1), "borders", border.clone());
let hole = styled(with_op(rect("Hole", 25.0, 25.0, 50.0, 50.0), 1), "borders", border);
let g = styled(shape_group("Pin", 0.0, 0.0, 100.0, 100.0, vec![outer, hole]), "fills", json!([solid_fill(0.0, 0.0, 1.0, 1.0)]));
let doc = open(vec![g]);
let pin = node(&doc, "Pin");
assert!(matches!(pin.kind, NodeKind::BooleanOp { op: BoolOp::Subtract }), "{:?}", pin.kind);
assert_eq!(pin.fills.len(), 1);
for name in ["Outer", "Hole"] {
assert!(node(&doc, name).strokes.is_empty(), "{name}: {:?}", node(&doc, name).strokes);
}
}
+28
View File
@@ -148,3 +148,31 @@ fn unknown_shared_ids_are_ignored() {
open(document(&["P"]), vec![styled(text("T", 0.0, 0.0, 10.0, 10.0, "x", &[(0, 1, "Helvetica", 9.0, BLACK)]), "fills", json!([f]))]);
assert!(node(&doc, "T").bound.is_empty());
}
/// A bitmap's fills hold its own image under the style's fills, so they no longer match a shared
/// layer style: the fill isn't linked (its border and effects still are).
#[test]
fn bitmaps_keep_their_image_out_of_shared_fills() {
let png = {
let mut out = Vec::new();
image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
};
let mut d = document(&["P"]);
with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]);
let mut bitmap = linked(rect("Photo", 0.0, 0.0, 50.0, 50.0), card_style(), "CARD");
bitmap["_class"] = json!("bitmap");
bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"});
let z = sketch_zip(
"83.2",
139,
d,
&[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, vec![bitmap])])],
&[("images/a.png", &png)],
);
let (doc, _) = import(&z, "f").unwrap();
let n = node(&doc, "Photo");
assert!(matches!(n.fills[0].kind, PaintKind::Image(_)));
assert!(n.styles.fill.is_none(), "{:?}", n.styles.fill);
assert!(n.styles.effect.is_some());
}
+10 -2
View File
@@ -91,14 +91,22 @@ fn item(src: &Value, m: &mut JsonMap<String, Value>, row: bool, stretch: bool) {
Some(2) => "FILL",
_ => "FIXED",
};
let (h, v) = (sizing("horizontalSizing"), sizing("verticalSizing"));
let (mut h, mut v) = (sizing("horizontalSizing"), sizing("verticalSizing"));
// A stretching stack fills its items across, fixed and hugging ones too.
if stretch {
if row {
v = "FILL";
} else {
h = "FILL";
}
}
m.insert("layoutSizingHorizontal".into(), json!(h));
m.insert("layoutSizingVertical".into(), json!(v));
let (main, cross) = if row { (h, v) } else { (v, h) };
if main == "FILL" {
m.insert("layoutGrow".into(), json!(1));
}
if cross == "FILL" || stretch {
if cross == "FILL" {
m.insert("layoutAlign".into(), json!("STRETCH"));
}
}
+133 -10
View File
@@ -71,7 +71,13 @@ pub(crate) fn clear(node: &mut JsonMap<String, Value>) {
fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option<Value> {
let mut p = match f.get("fillType").and_then(Value::as_i64).unwrap_or(0) {
0 => json!({"type": "SOLID", "color": f.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}))}),
1 => gradient(f.get("gradient")?, w, h)?,
1 => {
let (g, resampled) = gradient(f.get("gradient")?, w, h)?;
if resampled {
cx.warn("gradients mixed in OKLab or OKLCH became sRGB gradients with stops along the same colours");
}
g
}
4 => {
let Some(r) = f.get("image").and_then(|i| i.get("_ref")).and_then(Value::as_str) else {
cx.warn("image fills stored inside the JSON were skipped");
@@ -121,13 +127,20 @@ fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option<Value> {
/// A gradient fill: `from` and `to` in 0–1 of the layer; a radial gradient's `elipseLength` is its
/// width across the main axis as a share of the main axis, measured in pixels.
fn gradient(g: &Value, w: f64, h: f64) -> Option<Value> {
/// Also whether its stops were resampled from OKLab or OKLCH.
fn gradient(g: &Value, w: f64, h: f64) -> Option<(Value, bool)> {
let point = |k: &str| g.get(k).and_then(Value::as_str).and_then(parse_point);
let (from, to) = (point("from").unwrap_or((0.5, 0.0)), point("to").unwrap_or((0.5, 1.0)));
let stops: Vec<Value> = list(g, "stops")
.iter()
.filter_map(|s| Some(json!({"position": num(s, "position")?.clamp(0.0, 1.0), "color": s.get("color").and_then(color)?})))
.collect();
// `colorInterpolation` 1 mixes in OKLab, 2 in OKLCH (the shorter way round the hue); FigCraft
// mixes in sRGB, so stops in between carry the curve.
let (stops, resampled) = match g.get("colorInterpolation").and_then(Value::as_i64) {
Some(mode @ (1 | 2)) => (oklab::resample(&stops, mode == 2), true),
_ => (stops, false),
};
let (ty, handles) = match g.get("gradientType").and_then(Value::as_i64).unwrap_or(0) {
1 => {
let e = num(g, "elipseLength").filter(|e| *e > 0.0).unwrap_or(1.0);
@@ -144,7 +157,7 @@ fn gradient(g: &Value, w: f64, h: f64) -> Option<Value> {
("GRADIENT_LINEAR", json!([pt(from), pt(to), pt(across)]))
}
};
Some(json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops}))
Some((json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops}), resampled))
}
fn pt((x, y): (f64, f64)) -> Value {
@@ -260,9 +273,12 @@ pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec<Value> {
let many = style.get("blurs").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().take(MAX_LIST);
for b in one.chain(many).filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) {
let radius = num(b, "radius").unwrap_or(0.0).clamp(0.0, 1e6);
// Sketch's blur radius is the blur's standard deviation (Gaussian, background and motion
// blurs alike); FigCraft's is twice it.
let deviation = radius * 2.0;
match b.get("type").and_then(Value::as_i64).unwrap_or(0) {
0 => out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius})),
3 => out.push(background_blur(b, radius)),
0 => out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": deviation})),
3 => out.push(background_blur(b, deviation)),
4 => out.push(json!({
"type": "GLASS",
"visible": true,
@@ -270,10 +286,11 @@ pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec<Value> {
"refraction": num(b, "distortion").unwrap_or(0.5).clamp(0.0, 1.0),
"depth": num(b, "depth").unwrap_or(0.5).clamp(0.0, 1.0),
})),
_ => {
// Motion and zoom blurs: a plain blur of half the radius softens about as much.
kind => {
// A motion blur softens an edge across it as much as a Gaussian one of the same
// radius; a zoom blur about as much as a plain blur of half its radius.
cx.warn("motion and zoom blurs were drawn as plain blurs");
out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius / 2.0}));
out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": if kind == 1 { deviation } else { radius / 2.0 }}));
}
}
}
@@ -311,10 +328,18 @@ pub(crate) fn color_controls(cx: &mut Cx, style: Option<&Value>) -> Option<Value
if get("hue", 0.0).abs() > 1e-6 {
cx.warn("hue changes of bitmaps were skipped");
}
// Sketch scales about mid-grey by the contrast and then adds the brightness,
// (v - 0.5)·C + 0.5 + b; FigCraft multiplies by the exposure E first, (v·E - 0.5)·c + 0.5.
// The two agree for c = C - 2b and E = C / c within FigCraft's ranges (c in 0..=2, E in
// 1/4..=4); past them the slope C stays exact and the offset gives.
let (b, contrast) = (get("brightness", 0.0).clamp(-1.0, 1.0), get("contrast", 1.0).clamp(0.0, 2.0));
let c = (contrast - 2.0 * b).clamp(1e-6, 2.0);
let e = (contrast / c).clamp(0.25, 4.0);
let c = (contrast / e).clamp(0.0, 2.0);
Some(json!({
"saturation": (get("saturation", 1.0) - 1.0).clamp(-1.0, 1.0),
"contrast": (get("contrast", 1.0) - 1.0).clamp(-1.0, 1.0),
"exposure": get("brightness", 0.0).clamp(-1.0, 1.0),
"contrast": (c - 1.0).clamp(-1.0, 1.0),
"exposure": (e.log2() / 2.0).clamp(-1.0, 1.0),
}))
}
@@ -432,3 +457,101 @@ fn list<'v>(v: &'v Value, key: &str) -> &'v [Value] {
fn num(v: &Value, key: &str) -> Option<f64> {
v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite())
}
/// Gradient stops mixed in OKLab or OKLCH, as sRGB stops close enough to follow the curve.
mod oklab {
use serde_json::{Value, json};
/// Stops added between each pair.
const STEPS: usize = 16;
/// Most stops given back.
const MAX_STOPS: usize = 1024;
type Lab = [f64; 3];
fn lin(v: f64) -> f64 {
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
fn enc(v: f64) -> f64 {
let v = v.max(0.0);
if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
}
fn to_lab([r, g, b]: [f64; 3]) -> Lab {
let (r, g, b) = (lin(r), lin(g), lin(b));
let l = (0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b).cbrt();
let m = (0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b).cbrt();
let s = (0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b).cbrt();
[
0.210_454_255_3 * l + 0.793_617_785_0 * m - 0.004_072_046_8 * s,
1.977_998_495_1 * l - 2.428_592_205_0 * m + 0.450_593_709_9 * s,
0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766_0 * s,
]
}
fn from_lab([l, a, b]: Lab) -> [f64; 3] {
let l_ = (l + 0.396_337_777_4 * a + 0.215_803_757_3 * b).powi(3);
let m_ = (l - 0.105_561_345_8 * a - 0.063_854_172_8 * b).powi(3);
let s_ = (l - 0.089_484_177_5 * a - 1.291_485_548_0 * b).powi(3);
[
enc(4.076_741_662_1 * l_ - 3.307_711_591_3 * m_ + 0.230_969_929_2 * s_),
enc(-1.268_438_004_6 * l_ + 2.609_757_401_1 * m_ - 0.341_319_396_5 * s_),
enc(-0.004_196_086_3 * l_ - 0.703_418_614_7 * m_ + 1.707_614_701_0 * s_),
]
.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 })
}
/// The colour `t` of the way from `a` to `b`; in OKLCH the hue turns the shorter way (a grey
/// end takes the other's hue).
fn mix(a: Lab, b: Lab, t: f64, lch: bool) -> Lab {
let lerp = |x: f64, y: f64| x + (y - x) * t;
if !lch {
return [lerp(a[0], b[0]), lerp(a[1], b[1]), lerp(a[2], b[2])];
}
let (ca, cb) = (a[1].hypot(a[2]), b[1].hypot(b[2]));
let (mut ha, mut hb) = (a[2].atan2(a[1]), b[2].atan2(b[1]));
if ca < 1e-4 {
ha = hb;
}
if cb < 1e-4 {
hb = ha;
}
let mut dh = hb - ha;
if dh > std::f64::consts::PI {
dh -= std::f64::consts::TAU;
} else if dh < -std::f64::consts::PI {
dh += std::f64::consts::TAU;
}
let (c, h) = (lerp(ca, cb), ha + dh * t);
[lerp(a[0], b[0]), c * h.cos(), c * h.sin()]
}
fn rgba(stop: &Value) -> Option<(f64, [f64; 3], f64)> {
let c = stop.get("color")?;
let n = |k: &str| c.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
Some((stop.get("position").and_then(Value::as_f64)?, [n("r")?, n("g")?, n("b")?], n("a").unwrap_or(1.0)))
}
pub(super) fn resample(stops: &[Value], lch: bool) -> Vec<Value> {
let mut parsed: Vec<(f64, [f64; 3], f64)> = stops.iter().filter_map(rgba).collect();
if parsed.len() != stops.len() || parsed.len() < 2 || parsed.len() * STEPS > MAX_STOPS {
return stops.to_vec();
}
parsed.sort_by(|a, b| a.0.total_cmp(&b.0));
let stop = |pos: f64, [r, g, b]: [f64; 3], a: f64| json!({"position": pos, "color": {"r": r, "g": g, "b": b, "a": a}});
let mut out = Vec::new();
for pair in parsed.windows(2) {
let [(p0, c0, a0), (p1, c1, a1)] = pair else { continue };
let (l0, l1) = (to_lab(*c0), to_lab(*c1));
for k in 0..STEPS {
let t = k as f64 / STEPS as f64;
out.push(stop(p0 + (p1 - p0) * t, from_lab(mix(l0, l1, t, lch)), a0 + (a1 - a0) * t));
}
}
if let Some((p, c, a)) = parsed.last() {
out.push(stop(*p, *c, *a));
}
out
}
}
+135
View File
@@ -364,3 +364,138 @@ fn linear_gradients_are_square_in_pixels() {
let (wx, wy) = ((g.width.0 - g.from.0) * 200.0, (g.width.1 - g.from.1) * 100.0);
assert!((wx * 200.0 + wy * 100.0).abs() < 1e-6, "{g:?}");
}
// Rules below were read off Sketch's own renders (sketchtool) of files built for each case.
/// Sketch's blur radius is the blur's standard deviation (Gaussian, background and motion blurs);
/// FigCraft's radius is twice it. A zoom blur stays a plain blur of half its radius.
#[test]
fn blur_radii_are_deviations() {
let blur = |ty: i64| json!({"_class": "blur", "isEnabled": true, "radius": 6, "type": ty});
let (doc, _) = open(vec![
styled(r("Gaussian"), "blur", blur(0)),
styled(r("Motion"), "blur", blur(1)),
styled(r("Zoom"), "blur", blur(2)),
styled(r("Background"), "blur", blur(3)),
]);
let radius = |name: &str| match &node(&doc, name).effects[0].kind {
EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b) => b.radius,
other => panic!("{name}: {other:?}"),
};
assert_eq!(radius("Gaussian"), 12.0);
assert_eq!(radius("Motion"), 12.0);
assert_eq!(radius("Zoom"), 3.0);
assert_eq!(radius("Background"), 12.0);
}
/// Sketch's colour controls scale by the contrast about mid-grey, then add the brightness; the
/// filters give the same tone curve.
#[test]
fn bitmap_brightness_adds_after_contrast() {
let png = {
let mut out = Vec::new();
image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
};
for (brightness, contrast) in [(-0.24, 1.0), (0.2, 1.0), (-0.2, 1.3), (0.1, 0.7), (-0.75, 1.0), (0.45, 1.0)] {
let mut bitmap = r("Photo");
bitmap["_class"] = json!("bitmap");
bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"});
bitmap["style"]["colorControls"] =
json!({"_class": "colorControls", "isEnabled": true, "brightness": brightness, "contrast": contrast, "hue": 0, "saturation": 1});
let (doc, _) = open_doc(document(&["P"]), vec![bitmap], &[("images/a.png", &png)]);
let PaintKind::Image(img) = &node(&doc, "Photo").fills[0].kind else { panic!("no image") };
let (e, c) = (2f64.powf(f64::from(img.filters.exposure) * 2.0), 1.0 + f64::from(img.filters.contrast));
// Past FigCraft's ranges the slope stays exact and only the offset gives.
assert!((e * c - contrast).abs() < 1e-4, "{brightness} {contrast}: slope {}", e * c);
if !(-0.5..=0.375).contains(&brightness) {
continue;
}
for v in [0.1, 0.4, 0.6, 0.9] {
let figcraft = (v * e - 0.5) * c + 0.5;
let sketch = (v - 0.5) * contrast + 0.5 + brightness;
assert!((figcraft - sketch).abs() < 1e-4, "{brightness} {contrast} at {v}: {figcraft} vs {sketch}");
}
}
}
/// A bitmap's own image lies under its style's fills: a disabled fill leaves it showing, an
/// enabled one covers it.
#[test]
fn bitmap_image_under_its_fills() {
let png = {
let mut out = Vec::new();
image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
};
let bitmap = |name: &str, enabled: bool| {
let mut b = r(name);
b["_class"] = json!("bitmap");
b["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"});
let mut f = solid_fill(1.0, 0.0, 0.0, 1.0);
f["isEnabled"] = json!(enabled);
styled(b, "fills", json!([f]))
};
let (doc, _) = open_doc(document(&["P"]), vec![bitmap("Off", false), bitmap("On", true)], &[("images/a.png", &png)]);
for (name, solid_visible) in [("Off", false), ("On", true)] {
let fills = &node(&doc, name).fills;
assert_eq!(fills.len(), 2, "{name}");
assert!(matches!(fills[0].kind, PaintKind::Image(_)) && fills[0].visible, "{name}");
assert!(matches!(fills[1].kind, PaintKind::Solid { .. }), "{name}");
assert_eq!(fills[1].visible, solid_visible, "{name}");
}
}
/// An outline mask's opacity fades the mask (its drawn copy) and what it clips together; an alpha
/// mask's opacity is its alpha already, so nothing more fades.
#[test]
fn faded_masks_fade_what_they_clip() {
let fill = json!([solid_fill(0.8, 0.8, 0.8, 1.0)]);
let faded = |name: &str, mode: i64| {
let mut m = styled(with(with(r(name), "hasClippingMask", json!(true)), "clippingMaskMode", json!(mode)), "fills", fill.clone());
m["style"]["contextSettings"] = json!({"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 0.5});
m
};
let (doc, _) = open(vec![
group("G1", 0.0, 0.0, 100.0, 50.0, vec![faded("Outline", 0), r("Clipped")]),
group("G2", 0.0, 100.0, 100.0, 50.0, vec![faded("Alpha", 1), r("Faded")]),
]);
let holder = doc.node(node(&doc, "Outline").parent.unwrap()).unwrap();
assert!((holder.opacity - 0.5).abs() < 1e-6, "{}", holder.opacity);
let kids: Vec<&str> = holder.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
assert_eq!(kids, ["Outline (drawn)", "Outline", "Clipped"]);
assert!((node(&doc, "Outline (drawn)").opacity - 1.0).abs() < 1e-6);
assert!((node(&doc, "Clipped").opacity - 1.0).abs() < 1e-6);
let alpha_parent = doc.node(node(&doc, "Alpha").parent.unwrap()).unwrap();
assert!((alpha_parent.opacity - 1.0).abs() < 1e-6, "{}", alpha_parent.opacity);
}
/// `colorInterpolation` 1 mixes a gradient in OKLab, 2 in OKLCH; FigCraft gets sRGB stops along
/// the same colours (values from Sketch's render of this gradient), and a note.
#[test]
fn oklab_gradients_become_srgb_stops() {
let stops = json!([{"_class": "gradientStop", "position": 0, "color": color(0.357, 0.294, 1.0, 1.0)},
{"_class": "gradientStop", "position": 1, "color": color(1.0, 0.478, 0.349, 1.0)}]);
let grad = |mode: i64| {
json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0),
"gradient": {"_class": "gradient", "gradientType": 0, "colorInterpolation": mode, "from": "{0, 0.5}", "to": "{1, 0.5}", "stops": stops.clone()}})
};
let (doc, warnings) = open(vec![
styled(r("Srgb"), "fills", json!([grad(0)])),
styled(r("Oklab"), "fills", json!([grad(1)])),
styled(r("Oklch"), "fills", json!([grad(2)])),
]);
let middle = |name: &str| {
let PaintKind::Gradient(g) = &node(&doc, name).fills[0].kind else { panic!("{name}: no gradient") };
let mid = g.stops.iter().find(|s| (s.position - 0.5).abs() < 1e-6).map(|s| [s.color.r, s.color.g, s.color.b]);
(g.stops.len(), mid)
};
assert_eq!(middle("Srgb"), (2, None));
let near = |got: Option<[f32; 3]>, want: [f32; 3]| got.is_some_and(|g| g.iter().zip(want).all(|(a, b)| (a * 255.0 - b).abs() <= 2.0));
let (n, mid) = middle("Oklab");
assert_eq!(n, 17);
assert!(near(mid, [170.0, 116.0, 184.0]), "{mid:?}");
let (_, mid) = middle("Oklch");
assert!(near(mid, [214.0, 75.0, 186.0]), "{mid:?}");
assert!(warnings.iter().any(|w| w.contains("OKLab")), "{warnings:?}");
}
+27 -7
View File
@@ -117,8 +117,9 @@ pub(crate) fn master_fills(m: &Value) -> Option<Value> {
/// What a master shows as a frame (its instances the same): clipping, and in Sketch 2025 and newer
/// its style (fills, borders, effects) and corners.
fn frame_look(cx: &mut Cx, m: &Value, w: f64, h: f64, node: &mut JsonMap<String, Value>) {
// `clippingBehavior` 2 doesn't clip.
node.insert("clipsContent".into(), json!(m.get("clippingBehavior").and_then(Value::as_i64) != Some(2)));
// A symbol clips only with `clippingBehavior` 1, whatever its `groupBehavior`; 0 (the
// default) and 2 don't.
node.insert("clipsContent".into(), json!(m.get("clippingBehavior").and_then(Value::as_i64) == Some(1)));
if m.get("groupBehavior").is_some()
&& let Some(st) = m.get("style")
{
@@ -190,10 +191,12 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo
let mut node = base(l, "INSTANCE", parent, world, w, h, root);
node.insert("componentId".into(), json!(str_field(&m, "do_objectID").unwrap_or(sid)));
frame_look(cx, &m, w, h, &mut node);
// Instances show the master's background only when it says so.
if bool_field(&m, "includeBackgroundColorInInstance") == Some(true)
&& let Some(f) = master_fills(&m)
{
// An instance shows its master's background (its fills, or an older file's background
// colour) only when the master includes it both in instances and in exports.
let shown = bool_field(&m, "includeBackgroundColorInInstance") == Some(true) && bool_field(&m, "includeBackgroundColorInExport") == Some(true);
if !shown {
node.remove("fills");
} else if let Some(f) = master_fills(&m) {
node.insert("fills".into(), f);
}
// An instance's fill is a tint for its layers, not a paint of its own.
@@ -224,7 +227,7 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo
let own = str_field(l, "do_objectID").map(|id| format!("{id}_fillColor"));
let tint = own
.and_then(|k| overrides.get(&k).filter(|c| c.is_object()).cloned())
.or_else(|| super::style::tint_of(l.get("style"), true))
.or_else(|| super::style::tint_of(l.get("style"), cx.tints))
.or_else(|| super::style::tint_of(copy.get("style"), false));
if let Some(t) = tint
&& let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut)
@@ -450,6 +453,23 @@ fn apply(
m.insert("style".into(), style);
}
}
// An image override on a shape: its top image fill's image (after a layer style override,
// which would replace the fills).
if str_field(l, "_class") != Some("bitmap")
&& let Some(img) = get("image").filter(|v| v.get("_ref").is_some())
&& let Some(f) = l
.get_mut("style")
.and_then(|s| s.get_mut("fills"))
.and_then(Value::as_array_mut)
.and_then(|f| {
f.iter_mut()
.rev()
.find(|p| p.get("fillType").and_then(Value::as_i64) == Some(4) && p.get("isEnabled").and_then(Value::as_bool) != Some(false))
})
.and_then(Value::as_object_mut)
{
f.insert("image".into(), img.clone());
}
if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) {
apply(kids, ov, nested, shared, symbols, depth + 1, budget);
}
+149 -5
View File
@@ -8,6 +8,13 @@ use super::{import, import_within_nodes};
const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0];
/// A 1×1 PNG whose red channel is `n`: images the importer can decode, one per `n`.
fn png(n: u8) -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(1, 1, image::Rgba([n, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// One page with `layers`, one "Symbols" page with `masters`, and `document` changes.
fn open_doc(doc_json: Value, layers: Vec<Value>, masters: Vec<Value>) -> (Document, Vec<String>) {
let z = sketch_zip("50.2", 103, doc_json, &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]);
@@ -233,26 +240,163 @@ fn scale_zero_is_unscaled() {
assert!(!warnings.iter().any(|w| w.contains("scaled")), "{warnings:?}");
}
/// A master's background shows in its instances only when Sketch says so.
/// A master's background shows in its instances only when the master includes it both in
/// instances and in exports (Sketch's own renders and canvas, on files built for it).
#[test]
fn master_background_in_instances_only_when_included() {
let bg = |id: &str, sid: &str, include: bool| {
let bg = |id: &str, sid: &str, instances: bool, exports: bool| {
let mut m = master(id, sid, id, 20.0, 20.0, vec![]);
m["hasBackgroundColor"] = json!(true);
m["backgroundColor"] = json!({"_class": "color", "red": 0, "green": 0, "blue": 0, "alpha": 1});
m["includeBackgroundColorInInstance"] = json!(include);
m["includeBackgroundColorInInstance"] = json!(instances);
m["includeBackgroundColorInExport"] = json!(exports);
m
};
let (doc, _) = open(
vec![instance("Hidden", "SH", 0.0, 0.0, 20.0, 20.0, &[]), instance("Shown", "SS", 50.0, 0.0, 20.0, 20.0, &[])],
vec![bg("MH", "SH", false), bg("MS", "SS", true)],
vec![
instance("Hidden", "SH", 0.0, 0.0, 20.0, 20.0, &[]),
instance("InstancesOnly", "SI", 25.0, 0.0, 20.0, 20.0, &[]),
instance("ExportsOnly", "SE", 50.0, 0.0, 20.0, 20.0, &[]),
instance("Shown", "SS", 75.0, 0.0, 20.0, 20.0, &[]),
],
vec![bg("MH", "SH", false, false), bg("MI", "SI", true, false), bg("ME", "SE", false, true), bg("MS", "SS", true, true)],
);
let (hidden, main) = instance_of(&doc, "Hidden");
assert!(hidden.fills.is_empty(), "{:?}", hidden.fills);
assert_eq!(main.fills.len(), 1, "the master shows its background");
assert!(instance_of(&doc, "InstancesOnly").0.fills.is_empty());
assert!(instance_of(&doc, "ExportsOnly").0.fills.is_empty());
assert_eq!(instance_of(&doc, "Shown").0.fills.len(), 1);
}
/// Sketch 2025+: a symbol's (`groupBehavior` 0) or frame master's fills are its background, shown
/// in instances on the same terms.
#[test]
fn style_background_in_instances_only_when_included() {
let m = |id: &str, sid: &str, behaviour: i64, shown: bool| {
let mut m = master(id, sid, id, 20.0, 20.0, vec![]);
m["groupBehavior"] = json!(behaviour);
m["style"]["fills"] = json!([solid_fill(1.0, 1.0, 1.0, 1.0)]);
m["includeBackgroundColorInInstance"] = json!(shown);
m["includeBackgroundColorInExport"] = json!(shown);
m
};
let (doc, _) = open_version(
"2026.3.1",
196,
vec![
instance("Symbol", "S0", 0.0, 0.0, 20.0, 20.0, &[]),
instance("Frame", "S1", 25.0, 0.0, 20.0, 20.0, &[]),
instance("Shown", "S2", 50.0, 0.0, 20.0, 20.0, &[]),
],
vec![m("M0", "S0", 0, false), m("M1", "S1", 1, false), m("M2", "S2", 0, true)],
);
assert!(instance_of(&doc, "Symbol").0.fills.is_empty());
assert!(instance_of(&doc, "Frame").0.fills.is_empty());
assert_eq!(instance_of(&doc, "Shown").0.fills.len(), 1);
}
/// A symbol clips its layers only with `clippingBehavior` 1, whatever its `groupBehavior`.
#[test]
fn symbols_clip_only_when_asked() {
let m = |id: &str, sid: &str, behaviour: Option<i64>, clipping: Option<i64>| {
let mut m = master(id, sid, id, 20.0, 20.0, vec![rect(&format!("R{id}"), 0.0, 10.0, 20.0, 20.0)]);
if let Some(b) = behaviour {
m["groupBehavior"] = json!(b);
}
if let Some(c) = clipping {
m["clippingBehavior"] = json!(c);
}
m
};
let masters = vec![
m("M0", "S0", Some(0), Some(0)),
m("M1", "S1", Some(1), Some(0)),
m("M2", "S2", Some(2), Some(0)),
m("M3", "S3", Some(1), Some(1)),
m("M4", "S4", Some(1), Some(2)),
m("M5", "S5", None, None),
];
let layers = (0..6).map(|i| instance(&format!("I{i}"), &format!("S{i}"), 30.0 * f64::from(i), 0.0, 20.0, 20.0, &[])).collect();
let (doc, _) = open_version("2026.3.1", 196, layers, masters);
let clips: Vec<bool> = (0..6).map(|i| instance_of(&doc, &format!("I{i}")).0.clip).collect();
assert_eq!(clips, [false, false, false, true, false, false]);
}
/// An image override on a shape replaces its top image fill's image.
#[test]
fn image_overrides_on_shapes() {
let pattern = |r: &str| {
json!({"_class": "fill", "isEnabled": true, "fillType": 4, "patternFillType": 1, "patternTileScale": 1,
"image": {"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": r}})
};
let avatar = styled(rect("Avatar", 0.0, 0.0, 20.0, 20.0), "fills", json!([pattern("images/a.png"), pattern("images/b.png")]));
let over = json!({"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": "images/c.png"});
let (a, b, c) = (png(1), png(2), png(3));
let z = sketch_zip(
"2026.3.1",
196,
document(&["P", "S"]),
&[
page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 20.0, 20.0, &[("Avatar_image", over)])]),
page("S", "Symbols", vec![master("M", "S", "Contact", 20.0, 20.0, vec![avatar])]),
],
&[("images/a.png", &a), ("images/b.png", &b), ("images/c.png", &c)],
);
let (doc, _) = import(&z, "f").unwrap();
let (inst, main) = instance_of(&doc, "I");
let images = |n: &Node| -> Vec<String> {
n.fills
.iter()
.filter_map(|p| match &p.kind {
figcraft_doc::color::PaintKind::Image(i) => Some(i.image.clone()),
_ => None,
})
.collect()
};
let (shown, master_images) = (images(inside(&doc, inst, "Avatar")), images(inside(&doc, main, "Avatar")));
assert_eq!(shown.len(), 2);
assert_ne!(master_images[0], master_images[1]);
assert_eq!(shown[0], master_images[0], "the lower fill keeps its image");
assert_ne!(shown[1], master_images[1], "the top fill takes the override");
}
/// An image override still shows when the same layer's style is overridden too (the style comes
/// first, then the image).
#[test]
fn image_overrides_survive_layer_style_overrides() {
let pattern = |r: &str| {
json!({"_class": "fill", "isEnabled": true, "fillType": 4, "patternFillType": 1, "patternTileScale": 1,
"image": {"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": r}})
};
let mut d = document(&["P", "S"]);
d["layerStyles"]["objects"] = json!([{"_class": "sharedStyle", "do_objectID": "LS", "name": "Round",
"value": {"_class": "style", "fills": [pattern("images/a.png")], "borders": []}}]);
let avatar = styled(rect("Avatar", 0.0, 0.0, 20.0, 20.0), "fills", json!([pattern("images/a.png")]));
let over = json!({"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": "images/c.png"});
let (a, c) = (png(1), png(3));
let z = sketch_zip(
"2026.3.1",
196,
d,
&[
page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 20.0, 20.0, &[("Avatar_layerStyle", json!("LS")), ("Avatar_image", over)])]),
page("S", "Symbols", vec![master("M", "S", "Contact", 20.0, 20.0, vec![avatar])]),
],
&[("images/a.png", &a), ("images/c.png", &c)],
);
let (doc, _) = import(&z, "f").unwrap();
let (inst, main) = instance_of(&doc, "I");
let image = |n: &Node| {
n.fills.iter().find_map(|p| match &p.kind {
figcraft_doc::color::PaintKind::Image(i) => Some(i.image.clone()),
_ => None,
})
};
let (shown, original) = (image(inside(&doc, inst, "Avatar")), image(inside(&doc, main, "Avatar")));
assert!(shown.is_some() && shown != original, "{shown:?} {original:?}");
}
/// How Sketch lays out a resized instance's layers, measured on its renders: a master of 200×100
/// with a 100×40 rectangle at (20, 30), shown at 320×60.
#[test]
+56 -8
View File
@@ -50,6 +50,9 @@ pub(crate) struct Cx {
/// The file is older than format 110 (Sketch 52): Sketch scales a resized instance's layers
/// instead of following their pins.
pub legacy_resize: bool,
/// The file is newer than format 123 (Sketch 63): a group's or instance's plain fill can be a
/// tint (Sketch 64 added tints; older files' group fills draw nothing).
pub tints: bool,
/// The symbols being expanded, outermost first (a symbol inside itself stops there).
pub chain: Vec<String>,
pub warnings: Vec<String>,
@@ -122,6 +125,7 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize, fonts: Op
operand_fill: None,
operand_border: false,
legacy_resize: archive.version > 0 && archive.version < 110,
tints: archive.version == 0 || archive.version > 123,
chain: Vec::new(),
warnings: Vec::new(),
networks: Vec::new(),
@@ -226,6 +230,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root:
super::path::put_shape(cx, shape, &mut node);
}
mask(l, &mut node);
let mut bitmap = None;
match class {
"artboard" | "frame" => {
let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) };
@@ -247,13 +252,24 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root:
if let Some(f) = super::style::color_controls(cx, l.get("style")) {
paint["filters"] = f;
}
node.insert("fills".into(), json!([paint]));
bitmap = Some(paint);
}
None => cx.warn("images stored inside the JSON were skipped"),
},
_ => {}
}
styles(cx, l, w, h, &mut node);
// A bitmap's own image lies under its style's fills; its fills then aren't a shared style's.
if let Some(paint) = bitmap {
let mut fills = vec![paint];
if let Some(Value::Array(f)) = node.remove("fills") {
fills.extend(f);
}
node.insert("fills".into(), Value::Array(fills));
if let Some(styles) = node.get_mut("styles").and_then(Value::as_object_mut) {
styles.remove("fill");
}
}
extras(cx, l, h, &mut node);
style_corners(l, &mut node);
if class == "MSImmutableHotspotLayer" || class == "hotspot" {
@@ -263,7 +279,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root:
// A group's or graphic's fill tints its layers (a frame's is its background).
let tinted;
let gb = l.get("groupBehavior").and_then(Value::as_i64);
let tint = if class == "group" { super::style::tint_of(l.get("style"), gb != Some(1)) } else { None };
let tint = if class == "group" { super::style::tint_of(l.get("style"), gb != Some(1) && cx.tints) } else { None };
let l = match tint.filter(|_| spend_on_tint(cx, l)) {
Some(t) => {
// A plain group's legacy fill is all tint; a frame keeps its background fills.
@@ -538,7 +554,8 @@ fn clip(s: &str) -> String {
/// the end, the mask and what it clips are wrapped in a group of their own, since a FigCraft mask
/// clips everything above it.
pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), depth: usize, root: bool) -> Vec<Value> {
let built: Vec<(bool, Value)> = layers(l)
// (ends a mask chain, is a drawn copy of the mask after it, the node)
let built: Vec<(bool, bool, Value)> = layers(l)
.iter()
.filter_map(|c| {
let breaks = bool_field(c, "shouldBreakMaskChain") == Some(true) || bool_field(c, "hasClippingMask") == Some(true);
@@ -548,15 +565,21 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64),
// Sketch also draws a mask with its own style, under what it clips; a FigCraft mask
// only clips, so a plain copy of it goes underneath.
// The copy ends an earlier mask's chain as the mask itself would.
Some(copy) => vec![(breaks, copy), (breaks, n)],
None => vec![(breaks, n)],
Some(copy) => vec![(breaks, true, copy), (breaks, false, n)],
None => vec![(breaks, false, n)],
})
.collect();
let mut out = Vec::new();
let mut i = 0;
while let Some((_, n)) = built.get(i) {
while let Some((_, _, n)) = built.get(i) {
// An outline mask's opacity fades the mask and what it clips together, as one group (an
// alpha mask's opacity already does, as alpha).
let fade = (n.get("isMask").and_then(Value::as_bool) == Some(true) && n.get("maskType").and_then(Value::as_str) != Some("ALPHA"))
.then(|| n.get("opacity").and_then(Value::as_f64))
.flatten()
.filter(|o| o.is_finite() && *o < 1.0);
let end = if n.get("isMask").and_then(Value::as_bool) == Some(true) {
built.iter().skip(i + 1).position(|(b, _)| *b).map(|k| i + 1 + k)
built.iter().skip(i + 1).position(|(b, _, _)| *b).map(|k| i + 1 + k).or_else(|| fade.map(|_| built.len()))
} else {
None
};
@@ -565,7 +588,29 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64),
i += 1;
continue;
};
let masked: Vec<Value> = built.get(i..end).unwrap_or_default().iter().map(|(_, n)| n.clone()).collect();
// A faded mask's drawn copy goes into the group too, at full opacity.
let start = match (fade, i.checked_sub(1).and_then(|k| built.get(k))) {
(Some(_), Some((_, true, _))) => {
out.pop();
i - 1
}
_ => i,
};
let masked: Vec<Value> = built
.get(start..end)
.unwrap_or_default()
.iter()
.map(|(_, copy, n)| {
let mut n = n.clone();
if *copy
&& fade.is_some()
&& let Some(m) = n.as_object_mut()
{
m.remove("opacity");
}
n
})
.collect();
let mut g = JsonMap::new();
g.insert("type".into(), json!("GROUP"));
g.insert("name".into(), json!("Mask group"));
@@ -575,6 +620,9 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64),
let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, size.0, size.1));
g.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()}));
}
if let Some(o) = fade {
g.insert("opacity".into(), json!(o.clamp(0.0, 1.0)));
}
g.insert("children".into(), Value::Array(masked));
out.push(Value::Object(g));
i = end;
+42 -1
View File
@@ -155,6 +155,9 @@ pub fn tool_definitions() -> Vec<Value> {
"name": string("Layer name"),
"text": string("Characters, for type text"),
"path": string("SVG path data, for type vector (e.g. \"M0 0 L100 0 L50 80 Z\")"),
"points": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "For type vector, instead of path: the points [[x, y], ...]"},
"closed": boolean("For type vector: close the path"),
"select": boolean("Select the new layer (default true)"),
"props": {"type": "object", "description": "Any set_properties keys applied at creation, e.g. {\"fill\":\"#3366FF\",\"cornerRadius\":12,\"fontSize\":24,\"layout\":\"vertical\",\"gap\":8,\"padding\":16}"},
}),
&["type"],
@@ -260,6 +263,7 @@ pub fn tool_definitions() -> Vec<Value> {
json!({
"path": string("File path"),
"variables": {"description": "REST API file JSON only: the local variables JSON (`GET /v1/files/:key/variables/local`) as a path or the JSON object. Default: `<name>.variables.json` or `variables.json` next to the file."},
"reload": boolean("A file that is already open: read it again from disk into its tab (refused while the tab has unsaved changes). Without it, the result says `stale: true` when the file changed on disk since the tab read or saved it"),
}),
&["path"],
),
@@ -289,6 +293,7 @@ pub fn tool_definitions() -> Vec<Value> {
"shift": boolean("Shift held"),
"alt": boolean("Alt/Option held"),
"cmd": boolean("Cmd (macOS) / Ctrl held"),
"ctrl": boolean("Ctrl held (the Control key on macOS)"),
"pressure": num("Pen pressure 0-1 (the Pencil records it as the stroke's width profile; a drag starts with it)"),
"pressureTo": num("Pen pressure 0-1 at a drag's end (interpolated along it)"),
}),
@@ -301,7 +306,7 @@ pub fn tool_definitions() -> Vec<Value> {
"Press key",
"Press a key or shortcut as if typed on the canvas: a menu shortcut (e.g. {\"key\":\"G\",\"cmd\":true} groups, {\"key\":\"A\",\"alt\":true,\"shift\":false} …), a tool key (R, O, T, F, V…), Escape, Delete, Enter, arrows (nudge; shift = big nudge). While editing text, keys go to the text editor (Backspace, Delete, Left, Right, Home, End, Enter, Escape, SelectAll).",
obj(
json!({"key": string("Key name: a letter/digit, Escape, Delete, Backspace, Enter, ArrowLeft…"), "shift": boolean("Shift"), "alt": boolean("Alt/Option"), "cmd": boolean("Cmd/Ctrl")}),
json!({"key": string("Key name: a letter/digit, Escape, Delete, Backspace, Enter, ArrowLeft…"), "shift": boolean("Shift"), "alt": boolean("Alt/Option"), "cmd": boolean("Cmd/Ctrl"), "ctrl": boolean("Ctrl (the Control key on macOS)")}),
&["key"],
),
false,
@@ -434,12 +439,45 @@ fn friendly_params(p: &mut Value) {
// ---------- dispatch ----------
/// Refuse argument names `schema` doesn't list, at the top and in the objects it describes inside
/// (a batch's steps); objects it leaves open (`params`) take any. `path` leads the names in the
/// error (`commands[0].`).
fn refuse_unknown_args(schema: &Value, args: &Value, path: &str) -> Result<(), String> {
if let (Some(props), Some(given)) = (schema.get("properties").and_then(Value::as_object), args.as_object()) {
let unknown: Vec<String> = given.keys().filter(|k| !props.contains_key(*k)).map(|k| format!("`{path}{k}`")).collect();
if !unknown.is_empty() {
let s = if unknown.len() == 1 { "" } else { "s" };
let takes: Vec<&str> = props.keys().map(String::as_str).collect();
let takes = if takes.is_empty() { "none".to_string() } else { takes.join(", ") };
return Err(format!("unknown argument{s} {} (it takes {takes})", unknown.join(", ")));
}
for (k, v) in given {
if let Some(inner) = props.get(k) {
refuse_unknown_args(inner, v, &format!("{path}{k}."))?;
}
}
}
if let (Some(items), Some(list)) = (schema.get("items"), args.as_array()) {
for (i, v) in list.iter().enumerate() {
refuse_unknown_args(items, v, &format!("{}[{i}].", path.trim_end_matches('.')))?;
}
}
Ok(())
}
/// Run one tool. Every failure becomes an `isError` result (never a panic).
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
let a = match args_obj(args) {
Ok(a) => a,
Err(e) => return ToolResult::error(e),
};
// Built once: the catalogue is the same on every call.
static TOOLS: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(tool_definitions);
if let Some(schema) = TOOLS.iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name)).and_then(|t| t.get("inputSchema"))
&& let Err(e) = refuse_unknown_args(schema, &Value::Object(a.clone()), "")
{
return ToolResult::error(e);
}
let r = match name {
"list_commands" => list_commands(b, &a),
"run_command" => run_command(b, &a),
@@ -1013,6 +1051,9 @@ fn open_file(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult,
Some(v @ (Value::String(_) | Value::Object(_))) => params["variables"] = v.clone(),
Some(_) => return Err("`variables` must be a path or the local variables JSON object".into()),
}
if let Some(reload) = a.get("reload").filter(|v| !v.is_null()) {
params["reload"] = reload.clone();
}
let r = exec(b, "file.open", params)?;
let doc = b.call("document.inspect", json!({"depth": 1}))?;
Ok(ToolResult::json(&json!({"opened": r, "document": doc})))
+30
View File
@@ -236,6 +236,10 @@ fn task_export_png_and_svg() {
c.ok("save_file", json!({"path": file.to_string_lossy()}));
let opened = c.ok("open_file", json!({"path": file.to_string_lossy()}));
assert!(opened["document"]["pages"][0]["children"].to_string().contains("Banner"));
// The tab is that file: `reload` reads it again from disk (#997).
let reread = c.ok("open_file", json!({"path": file.to_string_lossy(), "reload": true}));
assert_eq!(reread["opened"]["reloaded"], true, "{reread}");
assert!(reread["document"]["pages"][0]["children"].to_string().contains("Banner"));
std::fs::remove_dir_all(&dir).ok();
}
@@ -307,6 +311,32 @@ fn task_vector_edit_keys() {
assert!(c.call("run_command", json!({"command": "vector.edit.state", "params": {}}))["isError"] == true, "Enter left edit mode");
}
#[test]
fn task_node_find_without_selecting_then_use_ids_in_batch() {
let mut c = Client::new();
let card = c.create(json!({"type": "frame", "name": "Card"}));
let title = c.create(json!({"type": "text", "parent": card, "name": "Title", "text": "Hello"}));
c.ok("select", json!({"ids": [card]}));
let before = c.doc();
assert_eq!(c.run("node.find", json!({"root": card, "type": "text", "text": "HELLO"})), json!({"ids": [title], "total": 1}));
let after = c.doc();
for key in ["selection", "currentPage", "history"] {
assert_eq!(after[key], before[key], "{key}");
}
let out = c.ok(
"batch",
json!({"commands": [
{"command": "node.find", "params": {"root": card, "name": "title"}},
{"command": "node.set", "params": {"ids": [{"$step": 0, "field": "ids/0"}], "props": {"name": "Found title"}}}
]}),
);
assert_eq!(out[0], json!({"ids": [title], "total": 1}));
assert_eq!(c.node(&title)["name"], "Found title");
assert_eq!(c.doc()["selection"], before["selection"]);
c.run("edit.undo", json!({}));
assert_eq!(c.node(&title)["name"], "Title");
}
/// A whole card in one `batch` call: later steps reach the frames earlier steps created through
/// `{"$step": n}`, so the tree is built in one round-trip and one undo step.
#[test]
+11
View File
@@ -83,6 +83,13 @@ fn tools_call_happy_and_error_paths() {
assert!(c.err("pointer", json!({"x": 1})).contains("`y`"));
assert!(c.err("type_text", json!({"text": "hi"})).contains("not editing text"));
assert!(c.err("open_file", json!({"path": "/definitely/not/here.figcraft"})).contains("here.figcraft"));
// Names a tool or a command doesn't take are refused, not ignored (#1413).
let e = c.err("run_command", json!({"command": "inspect.code", "args": {"id": id, "language": "html"}}));
assert!(e.contains("unknown argument `args` (it takes command, params)"), "{e}");
let e = c.err("batch", json!({"commands": [{"command": "comment.list", "args": {"filter": "open"}}]}));
assert!(e.contains("unknown argument `commands[0].args`"), "{e}");
let e = c.err("run_command", json!({"command": "comment.list", "params": {"filterr": "open"}}));
assert!(e.contains("unknown parameter `filterr`"), "{e}");
// Protocol-level errors.
let r = c.rpc("tools/call", json!({"name": "no_such_tool", "arguments": {}}));
assert_eq!(r["error"]["code"], -32602);
@@ -92,6 +99,10 @@ fn tools_call_happy_and_error_paths() {
assert_eq!(r["error"]["code"], -32602);
// The document is unharmed by all of that.
assert_eq!(c.page_children().len(), 1);
// Arguments the tools pass on are in their schemas, so they still go through.
c.ok("create_node", json!({"type": "vector", "points": [[0, 0], [10, 0], [5, 8]], "closed": true, "select": false}));
c.ok("key", json!({"key": "Escape", "ctrl": false}));
c.ok("pointer", json!({"kind": "hover", "x": 1, "y": 1, "ctrl": false}));
}
#[test]
+7
View File
@@ -95,6 +95,13 @@ pub fn simplify(path: &BezPath) -> Result<BezPath, PathOpsError> {
binary(path, &BezPath::new(), linesweeper::BinaryOp::Union)
}
/// Normalize one path using its fill rule. Unlike [`faces`], this emits every contour without a
/// face-count cap, so render silhouettes preserve large imported networks.
pub fn simplify_with_rule(path: &BezPath, even_odd: bool) -> Result<BezPath, PathOpsError> {
let rule = if even_odd { linesweeper::FillRule::EvenOdd } else { linesweeper::FillRule::NonZero };
binary_by(path, &BezPath::new(), rule, linesweeper::BinaryOp::Union)
}
/// Combine `acc` (the operands so far) with the next operand `p` by `op`.
pub fn boolean_step(acc: &BezPath, p: &BezPath, op: BoolOp) -> Result<BezPath, PathOpsError> {
let op = match op {
+19 -2
View File
@@ -2,10 +2,10 @@
//! agreement with a uniform operation, and single steps.
use figcraft_doc::BoolOp;
use figcraft_geom::Rect;
use figcraft_geom::kurbo::{BezPath, Shape};
use figcraft_geom::{Point, Rect};
use crate::{boolean, boolean_ops, boolean_step};
use crate::{boolean, boolean_ops, boolean_step, simplify_with_rule};
fn sq(x: f64, y: f64, s: f64) -> BezPath {
Rect::new(x, y, x + s, y + s).to_path(0.1)
@@ -81,3 +81,20 @@ fn single_steps_and_edge_cases() {
bad.line_to((1.0, 1.0));
assert!(boolean_ops([(&a, BoolOp::Union), (&bad, BoolOp::Intersect)]).is_err());
}
#[test]
fn fill_rule_normalization_keeps_even_odd_holes_and_more_than_4096_contours() {
let mut ring = sq(0.0, 0.0, 20.0);
ring.extend(sq(5.0, 5.0, 10.0).elements().iter().copied());
let ring = simplify_with_rule(&ring, true).unwrap();
assert!(ring.winding(Point::new(2.0, 2.0)) != 0);
assert_eq!(ring.winding(Point::new(10.0, 10.0)), 0, "same-direction inner contour remains a hole");
let mut many = BezPath::new();
for i in 0..4097 {
many.extend(sq(i as f64 * 2.0, 0.0, 1.0).elements().iter().copied());
}
let all = simplify_with_rule(&many, false).unwrap();
assert!(all.winding(Point::new(8192.5, 0.5)) != 0, "last contour survives beyond the old 4096-face cap");
assert!((area(&all) - 4097.0).abs() < 1e-6);
}
+27 -2
View File
@@ -329,8 +329,33 @@ impl<'a> Exporter<'a> {
if let Some((path, r)) = &outline
&& closed
{
for p in &n.fills {
self.fill(s, path, rule(*r), p, PaintFrame { size: PaintSize { width: w, height: h }, transform: xf });
if let NodeKind::Vector { network } = &n.kind
&& network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())
{
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
for (i, region) in net.regions.iter().enumerate() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
if let Some(mut region_path) = net.region_path(i) {
let (pw, ph, frame) = region.paint_frame(w, h);
region_path.apply_affine(frame.inverse());
let fill_rule =
if region.winding == figcraft_doc::WindingRule::EvenOdd { FillRule::EvenOdd } else { FillRule::NonZero };
for p in paints {
self.fill(
s,
&region_path,
fill_rule,
p,
PaintFrame { size: PaintSize { width: pw, height: ph }, transform: xf * frame },
);
}
}
}
} else {
for p in &n.fills {
self.fill(s, path, rule(*r), p, PaintFrame { size: PaintSize { width: w, height: h }, transform: xf });
}
}
}
self.place_inner(s, inner);
+32 -1
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use super::*;
use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImageScaleMode, Paint};
use figcraft_doc::{BoolOp, Effect, EffectKind, ImageData, NodeKind, StrokeAlign, TextData};
use figcraft_doc::{BoolOp, Effect, EffectKind, ImageData, NodeKind, Region, Segment, StrokeAlign, TextData, VectorNetwork, Vertex, WindingRule};
pub(crate) fn page_count(pdf: &[u8]) -> usize {
let s = String::from_utf8_lossy(pdf);
@@ -51,6 +51,37 @@ pub(crate) fn has_inter() -> bool {
figcraft_text::find_face("Inter", "Regular").is_some()
}
#[test]
fn pdf_vector_region_paints_emit_independent_colors() {
let mut d = Document::new("regions");
let p = d.first_page().unwrap();
let network = VectorNetwork {
vertices: [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (10.0, 10.0)]
.into_iter()
.map(|(x, y)| Vertex::new(x, y))
.collect(),
segments: (0..8).map(|i| Segment::line(i, (i + 1) % 4 + (i / 4) * 4)).collect(),
regions: vec![
Region {
loops: vec![vec![0, 1, 2, 3]],
winding: WindingRule::NonZero,
fills: Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]),
..Default::default()
},
Region {
loops: vec![vec![4, 5, 6, 7]],
winding: WindingRule::NonZero,
fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
..Default::default()
},
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
],
};
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 20.0, 10.0).unwrap();
let content = inflated(&export_pdf(&d, &[id], &PdfOptions::default()).unwrap());
assert!(content.contains("1 0 0 rg") && content.contains("0 0 1 rg"), "{content}");
}
/// A page with two frames: shapes, gradients, an image, strokes, a mask, a boolean, effects, text.
pub(crate) fn sample_doc() -> (Document, Vec<NodeId>) {
let mut d = Document::new("sample");
+57 -7
View File
@@ -326,6 +326,27 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
Some((p, FillRule::Winding))
}
NodeKind::Vector { network } => {
if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let mut areas = BezPath::new();
for (i, region) in net.regions.iter().enumerate() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
if !paints.iter().any(|p| p.visible) {
continue;
}
let Some(path) = net.region_path(i) else { continue };
let even_odd = region.winding == figcraft_doc::WindingRule::EvenOdd;
if let Ok(normalized) = figcraft_pathops::simplify_with_rule(&path, even_odd) {
areas.extend(normalized.elements().iter().copied());
} else {
// Invalid geometry still gets the renderer's best-effort path.
areas.extend(path.elements().iter().copied());
}
}
let silhouette = figcraft_pathops::simplify(&areas).unwrap_or(areas);
return Some((silhouette, FillRule::Winding));
}
let rule =
if network.regions.iter().any(|r| r.winding == figcraft_doc::WindingRule::EvenOdd) { FillRule::EvenOdd } else { FillRule::Winding };
Some((network.fill_path(), rule))
@@ -702,6 +723,31 @@ fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
/// What paints over a node's fills, under its children and strokes (its inner shadows).
type OverFills<'a> = Option<&'a dyn Fn(&mut Pixmap)>;
/// A vector's network when any of its regions has paints or a paint frame of its own.
fn region_painted(n: &Node) -> Option<&figcraft_doc::VectorNetwork> {
match &n.kind {
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => Some(network),
_ => None,
}
}
/// Each region of a vector with region paints, filled with its own paints (or the layer's) in its
/// own paint frame.
fn fill_vector_regions(ctx: &Ctx, pix: &mut Pixmap, n: &Node, network: &figcraft_doc::VectorNetwork, xf: Affine) {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
for (i, region) in net.regions.iter().enumerate() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
if let Some(mut path) = net.region_path(i) {
let (pw, ph, frame) = region.paint_frame(n.width, n.height);
path.apply_affine(frame.inverse());
let Some(tp) = ts_path(&path) else { continue };
let rule = if region.winding == figcraft_doc::WindingRule::EvenOdd { FillRule::EvenOdd } else { FillRule::Winding };
fill_with(pix, &tp, paints, pw, ph, xf * frame, rule, ctx, None);
}
}
}
/// Fills, `over_fills`, children (clipped), strokes and text of a node, without opacity/effects.
fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, over_fills: OverFills) {
let doc = ctx.doc;
@@ -783,14 +829,18 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
let splatted = ctx.lod && closed && lod::splat_fills(pix, n, xf);
let clips = n.clip && n.kind.is_frame_like() && !n.children.is_empty();
let styled_network = region_painted(n);
// The outline fills, clips children to rounded corners and strokes booleans.
let outline = if splatted && !(clips && n.corners.uniform() != Some(0.0)) { None } else { node_outline(doc, n) };
if !splatted
&& let Some((path, rule)) = &outline
&& closed
&& let Some(tp) = ts_path(path)
{
fill_with(pix, &tp, &n.fills, w, h, xf, *rule, ctx, None);
let outline =
if styled_network.is_some() || (splatted && !(clips && n.corners.uniform() != Some(0.0))) { None } else { node_outline(doc, n) };
if !splatted && closed {
if let Some(network) = styled_network {
fill_vector_regions(ctx, pix, n, network, xf);
} else if let Some((path, rule)) = &outline
&& let Some(tp) = ts_path(path)
{
fill_with(pix, &tp, &n.fills, w, h, xf, *rule, ctx, None);
}
}
if let Some(f) = over_fills {
f(pix);
+3
View File
@@ -77,6 +77,9 @@ pub(crate) fn splattable_within<'a>(doc: &'a Document, n: &Node, depth: usize, m
{
return (ok, Some(levels));
}
if matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())) {
return (false, Some(0));
}
let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_) | PaintKind::Pattern(_));
let own = match &n.kind {
NodeKind::Text(t) => text_paints(n, t).any(image),
+14 -1
View File
@@ -5,7 +5,7 @@
use std::sync::{Arc, Once};
use figcraft_color::{Color, ImageScaleMode, Paint};
use figcraft_doc::{Document, ImageData, NodeId, NodeKind, TextData};
use figcraft_doc::{Document, ImageData, NodeId, NodeKind, Region, TextData, VectorNetwork};
use figcraft_geom::{Affine, Rect};
use crate::retained_tests::assert_same;
@@ -110,6 +110,19 @@ fn lod_is_off_unless_asked_for() {
assert!(!Options::default().lod);
}
#[test]
fn region_paints_disable_vector_and_ancestor_splatting() {
let mut d = Document::new("region LOD");
let page = d.first_page().unwrap();
let parent = d.create(page, NodeKind::Frame, "Parent", 0.0, 0.0, 4.0, 4.0).unwrap();
let mut network = VectorNetwork::default();
network.regions.push(Region { loops: Vec::new(), winding: Default::default(), fills: Some(Vec::new()), ..Default::default() });
let vector = d.create(parent, NodeKind::Vector { network }, "Styled", 0.0, 0.0, 2.0, 2.0).unwrap();
let node = d.node(vector).unwrap();
assert!(!lod::splattable(&d, node, 0));
assert!(!lod::splattable(&d, d.node(parent).unwrap(), 0));
}
#[test]
fn zoomed_out_previews_are_close_to_exact() {
let (d, p) = cards();
+172 -1
View File
@@ -1,6 +1,6 @@
use super::*;
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint};
use figcraft_doc::{Effect, EffectKind, NodeKind, Shadow, StrokeAlign, TextData};
use figcraft_doc::{Effect, EffectKind, NodeKind, Region, Segment, Shadow, StrokeAlign, TextData, VectorNetwork, Vertex, WindingRule};
fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] {
let i = ((y * p.width() + x) * 4) as usize;
@@ -28,6 +28,177 @@ fn solid_rect_and_page_background() {
assert_eq!(px(&pix, 5, 5), [0xF5, 0xF5, 0xF5, 255]);
}
#[test]
fn vector_regions_use_their_own_paints_and_empty_regions_do_not_erase() {
let mut d = figcraft_doc::Document::new("regions");
let page = d.first_page().unwrap();
let network = VectorNetwork {
vertices: [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (10.0, 10.0)]
.into_iter()
.map(|(x, y)| Vertex::new(x, y))
.collect(),
segments: (0..8).map(|i| Segment::line(i, (i + 1) % 4 + (i / 4) * 4)).collect(),
regions: vec![
Region {
loops: vec![vec![0, 1, 2, 3]],
winding: WindingRule::NonZero,
fills: Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]),
..Default::default()
},
Region {
loops: vec![vec![4, 5, 6, 7]],
winding: WindingRule::NonZero,
fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
..Default::default()
},
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(vec![]), ..Default::default() },
],
};
let id = d.create(page, NodeKind::Vector { network }, "V", 10.0, 10.0, 20.0, 10.0).unwrap();
d.get_mut(id).unwrap().fills.clear();
let pix = render_view(&d, page, &view(40, 30), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
assert_eq!(px(&pix, 15, 15), [255, 0, 0, 255]);
assert_eq!(px(&pix, 25, 15), [0, 0, 255, 255]);
assert_eq!(d.get(id).unwrap().fills.len(), 0);
}
#[test]
fn region_paint_space_preserves_gradient_image_and_pattern_placement() {
use figcraft_color::{ImageScaleMode, PaintKind, PatternPaint};
use figcraft_doc::RegionPaintSpace;
use figcraft_geom::{Affine, Rect, Shape};
let mut d = Document::new("paint frames");
let page = d.first_page().unwrap();
let tile = d.create(page, NodeKind::Rectangle, "Tile", 500.0, 500.0, 8.0, 6.0).unwrap();
d.get_mut(tile).unwrap().fills = vec![Paint::solid(Color::from_hex("F05020").unwrap())];
let mut image = Pixmap::new(8, 4).unwrap();
for (i, pixel) in image.pixels_mut().iter_mut().enumerate() {
*pixel = tiny_skia::PremultipliedColorU8::from_rgba((i % 8 * 30) as u8, (i / 8 * 60) as u8, 30, 255).unwrap();
}
d.images.insert(
"region-frame-image".into(),
figcraft_doc::ImageData { bytes: std::sync::Arc::new(encode_png(&image).unwrap()), width: 8, height: 4 },
);
let mut pattern = PatternPaint::new(tile.0);
pattern.spacing = (3.0, 4.0);
let paints = [
Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK)),
Paint::image("region-frame-image", ImageScaleMode::Fill),
Paint::image("region-frame-image", ImageScaleMode::Tile),
Paint { kind: PaintKind::Pattern(pattern), ..Paint::solid(Color::WHITE) },
];
let frame = Affine::translate((35.0, 25.0)) * Affine::rotate(0.3);
let id = d.create(page, NodeKind::Rectangle, "Source", 0.0, 0.0, 60.0, 30.0).unwrap();
for paint in paints {
let n = d.get_mut(id).unwrap();
n.kind = NodeKind::Rectangle;
n.transform = frame;
n.width = 60.0;
n.height = 30.0;
n.fills = vec![paint.clone()];
let before = render_view(&d, page, &view(120, 100), &Options::default()).unwrap();
let mut path = Rect::new(0.0, 0.0, 60.0, 30.0).to_path(0.1);
path.apply_affine(frame);
let mut network = VectorNetwork::from_path(&path);
for region in &mut network.regions {
region.fills = Some(vec![paint.clone()]);
region.paint_space = Some(RegionPaintSpace { width: 60.0, height: 30.0, transform: frame });
}
let n = d.get_mut(id).unwrap();
n.kind = NodeKind::Vector { network };
n.transform = Affine::IDENTITY;
n.width = 120.0;
n.height = 100.0;
n.fills.clear();
let after = render_view(&d, page, &view(120, 100), &Options::default()).unwrap();
for (a, b) in before.data().iter().zip(after.data()) {
assert!(a.abs_diff(*b) <= 1, "paint moved after reboxing: {:?}", paint.kind);
}
}
}
#[test]
fn styled_vector_mask_unions_region_windings_and_excludes_unpainted_regions() {
let mut d = figcraft_doc::Document::new("region mask");
let page = d.first_page().unwrap();
let group = d.create(page, NodeKind::Group, "Mask group", 0.0, 0.0, 60.0, 20.0).unwrap();
let mut network = VectorNetwork::default();
let mut regions = Vec::new();
for x in [0.0, 10.0, 30.0, 40.0, 50.0] {
let v = network.vertices.len();
network.vertices.extend([(x, 0.0), (x + 20.0, 0.0), (x + 20.0, 20.0), (x, 20.0)].map(|(x, y)| Vertex::new(x, y)));
let s = network.segments.len();
network.segments.extend((0..4).map(|i| Segment::line(v + i, v + (i + 1) % 4)));
let fills = match regions.len() {
0 => Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]),
1 => Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
2 => Some(vec![Paint { visible: false, ..Paint::solid(Color::from_hex("00FF00").unwrap()) }]),
3 => None,
_ => Some(Vec::new()),
};
regions.push(Region {
loops: vec![(s..s + 4).collect()],
winding: if regions.len() == 1 { WindingRule::EvenOdd } else { WindingRule::NonZero },
fills,
..Default::default()
});
}
network.regions = regions;
let mask = d.create(group, NodeKind::Vector { network }, "Mask", 0.0, 0.0, 60.0, 20.0).unwrap();
{
let n = d.get_mut(mask).unwrap();
n.fills.clear();
n.is_mask = true;
n.mask_type = figcraft_doc::MaskType::Vector;
}
let content = d.create(group, NodeKind::Rectangle, "Content", 0.0, 0.0, 60.0, 20.0).unwrap();
d.get_mut(content).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let pix = render_view(&d, page, &view(60, 20), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
assert_eq!(px(&pix, 5, 10), [0, 0, 0, 255], "visible left region masks");
assert_eq!(px(&pix, 15, 10), [0, 0, 0, 255], "overlap remains in union despite mixed winding");
assert_eq!(px(&pix, 35, 10), [255, 255, 255, 255], "hidden region excluded from styled silhouette");
assert_eq!(px(&pix, 45, 10), [255, 255, 255, 255], "unpainted inherited region excluded");
assert_eq!(px(&pix, 55, 10), [255, 255, 255, 255], "explicit-empty region excluded");
}
#[test]
fn inherited_paint_frames_keep_overlapping_regions_in_vector_masks() {
use figcraft_geom::{Affine, Point, Shape};
let mut d = Document::new("inherited region mask");
let page = d.first_page().unwrap();
let group = d.create(page, NodeKind::Group, "Mask group", 0.0, 0.0, 40.0, 20.0).unwrap();
let mut network = VectorNetwork::default();
for x in [0.0, 10.0] {
let v = network.vertices.len();
let s = network.segments.len();
network.vertices.extend([(x, 0.0), (x + 20.0, 0.0), (x + 20.0, 20.0), (x, 20.0)].map(|(x, y)| Vertex::new(x, y)));
network.segments.extend((0..4).map(|i| Segment::line(v + i, v + (i + 1) % 4)));
network.regions.push(Region {
loops: vec![(s..s + 4).collect()],
winding: WindingRule::EvenOdd,
fills: None,
paint_space: Some(figcraft_doc::RegionPaintSpace { width: 20.0, height: 20.0, transform: Affine::translate((x, 0.0)) }),
});
}
let mask = d.create(group, NodeKind::Vector { network }, "Mask", 0.0, 0.0, 30.0, 20.0).unwrap();
let n = d.get_mut(mask).unwrap();
n.fills = vec![Paint::solid(Color::BLACK)];
n.is_mask = true;
n.mask_type = figcraft_doc::MaskType::Vector;
let (outline, rule) = node_outline(&d, d.node(mask).unwrap()).unwrap();
assert_eq!(rule, FillRule::Winding);
assert_ne!(outline.winding(Point::new(15.0, 10.0)), 0, "independent inherited regions retain their overlap");
let content = d.create(group, NodeKind::Rectangle, "Content", 0.0, 0.0, 40.0, 20.0).unwrap();
d.get_mut(content).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let pix = render_view(&d, page, &view(40, 20), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
for x in [5, 15, 25] {
assert_eq!(px(&pix, x, 10), [0, 0, 0, 255]);
}
assert_eq!(px(&pix, 35, 10), [255, 255, 255, 255]);
}
#[test]
fn zoom_scales() {
let (d, p, _) = doc_with_rect(Color::BLACK);
+16 -11
View File
@@ -28,8 +28,11 @@ impl Fonts {
fn load() -> Self {
Fonts(figcraft_text::fontdb::family_names().into_iter().map(|f| f.to_lowercase()).collect())
}
/// Installed under this name, or under the family it names (`system-ui` on a Mac is its
/// System Font, see `fontdb::apple_alias`).
fn has(&self, family: &str) -> bool {
self.0.contains(&family.to_lowercase())
let key = family.to_lowercase();
self.0.contains(&key) || figcraft_text::fontdb::apple_alias(&key).is_some_and(|target| self.0.contains(target))
}
}
@@ -67,8 +70,9 @@ fn generic(name: &str) -> Option<&'static [&'static str]> {
})
}
/// The family to use: the first the SVG lists that's installed, else the first it names, else the
/// default.
/// The family to use: the first the SVG lists that's installed (a generic family as the first of
/// its installed stand-ins, unless a font answers to its name: `system-ui` on a Mac), else the
/// first it names, else the default.
fn family(list: &[FontFamily], fonts: &Fonts) -> String {
let name = |f: &FontFamily| match f {
FontFamily::Serif => "serif".to_string(),
@@ -80,14 +84,11 @@ fn family(list: &[FontFamily], fonts: &Fonts) -> String {
};
for f in list {
let n = name(f);
match generic(&n) {
Some(cands) => {
if let Some(c) = cands.iter().find(|c| fonts.has(c)) {
return (*c).to_string();
}
}
None if fonts.has(&n) => return n,
None => {}
if fonts.has(&n) {
return n;
}
if let Some(c) = generic(&n).and_then(|cands| cands.iter().find(|c| fonts.has(c))) {
return (*c).to_string();
}
}
list.iter().map(name).find(|n| generic(n).is_none() && !n.is_empty()).unwrap_or_else(|| TextStyle::default().font_family)
@@ -343,3 +344,7 @@ fn add_layer(cx: &mut Ctx, layer: TextLayer<'_, '_, '_>) {
}
cx.add(n, parent);
}
#[cfg(test)]
#[path = "text_tests.rs"]
mod tests;
+32
View File
@@ -0,0 +1,32 @@
//! Which family an SVG's `font-family` list gives a text layer.
use usvg::FontFamily;
use super::{Fonts, family};
fn fonts(installed: &[&str]) -> Fonts {
Fonts(installed.iter().map(|f| f.to_lowercase()).collect())
}
fn named(list: &[&str]) -> Vec<FontFamily> {
list.iter()
.map(|n| match *n {
"sans-serif" => FontFamily::SansSerif,
n => FontFamily::Named(n.to_string()),
})
.collect()
}
/// The names CSS gives the system font keep their name on a Mac, where they draw in its System
/// Font, and fall back as before without it.
#[test]
fn css_system_font_names_keep_their_name_where_the_system_font_is() {
let mac = fonts(&["System Font", "Inter", "Helvetica"]);
assert_eq!(family(&named(&["-apple-system", "BlinkMacSystemFont", "system-ui", "sans-serif"]), &mac), "-apple-system");
assert_eq!(family(&named(&["BlinkMacSystemFont", "sans-serif"]), &mac), "BlinkMacSystemFont");
assert_eq!(family(&named(&["system-ui", "sans-serif"]), &mac), "system-ui");
assert_eq!(family(&named(&["Helvetica", "system-ui"]), &mac), "Helvetica", "an installed family the SVG lists first still wins");
let elsewhere = fonts(&["Inter", "DejaVu Sans"]);
assert_eq!(family(&named(&["-apple-system", "BlinkMacSystemFont", "system-ui", "sans-serif"]), &elsewhere), "Inter");
assert_eq!(family(&named(&["-apple-system"]), &elsewhere), "-apple-system", "nothing installed: the first name it gives");
}
+26 -5
View File
@@ -494,11 +494,32 @@ impl W<'_> {
} else if let Some((path, rule)) = &outline
&& closed
{
let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
for p in &n.fills {
if let Some(a) = self.paint_attr(p, w, h) {
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(path));
self.body.push_str(&clipped(&paint_clip, el));
if let NodeKind::Vector { network } = &n.kind
&& network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())
{
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
for (i, region) in net.regions.iter().enumerate() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
let fr = if region.winding == figcraft_doc::WindingRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
if let Some(mut region_path) = net.region_path(i) {
let (pw, ph, frame) = region.paint_frame(w, h);
region_path.apply_affine(frame.inverse());
for p in paints {
if let Some(a) = self.paint_attr(p, pw, ph) {
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf * frame), path_d(&region_path));
self.body.push_str(&clipped(&paint_clip, el));
}
}
}
}
} else {
let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
for p in &n.fills {
if let Some(a) = self.paint_attr(p, w, h) {
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(path));
self.body.push_str(&clipped(&paint_clip, el));
}
}
}
}
+48 -1
View File
@@ -1,6 +1,6 @@
use super::*;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, NodeKind};
use figcraft_doc::{Document, Effect, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
#[test]
fn export_basic_shapes() {
@@ -23,6 +23,38 @@ fn export_basic_shapes() {
assert!(d2.validate().is_empty());
}
#[test]
fn export_vector_region_paints_independently() {
let mut d = Document::new("regions");
let p = d.first_page().unwrap();
let network = VectorNetwork {
vertices: [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (10.0, 10.0)]
.into_iter()
.map(|(x, y)| Vertex::new(x, y))
.collect(),
segments: (0..8).map(|i| Segment::line(i, (i + 1) % 4 + (i / 4) * 4)).collect(),
regions: vec![
Region {
loops: vec![vec![0, 1, 2, 3]],
winding: WindingRule::NonZero,
fills: Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]),
..Default::default()
},
Region {
loops: vec![vec![4, 5, 6, 7]],
winding: WindingRule::NonZero,
fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
..Default::default()
},
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
],
};
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 20.0, 10.0).unwrap();
let svg = export_svg(&d, &[id], &SvgOptions::default()).unwrap();
assert!(svg.contains("#FF0000") && svg.contains("#0000FF"), "{svg}");
assert_eq!(svg.matches("<path ").count(), 2, "empty region emits no path");
}
#[test]
fn import_rejects_garbage() {
let mut d = Document::new("t");
@@ -30,6 +62,21 @@ fn import_rejects_garbage() {
assert!(import_svg(&mut d, b"not svg", p, 0.0, 0.0, "x").is_err());
}
#[test]
fn region_gradient_export_keeps_the_original_paint_box() {
use figcraft_color::{Gradient, GradientKind};
use figcraft_doc::RegionPaintSpace;
use figcraft_geom::{Affine, Rect, Shape};
let mut d = Document::new("region gradient");
let page = d.first_page().unwrap();
let mut network = VectorNetwork::from_path(&Rect::new(20.0, 10.0, 80.0, 40.0).to_path(0.1));
network.regions[0].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK))]);
network.regions[0].paint_space = Some(RegionPaintSpace { width: 60.0, height: 30.0, transform: Affine::translate((20.0, 10.0)) });
let id = d.create(page, NodeKind::Vector { network }, "V", 0.0, 0.0, 120.0, 100.0).unwrap();
let svg = export_svg(&d, &[id], &SvgOptions::default()).unwrap();
assert!(svg.contains("x1=\"30\" y1=\"0\" x2=\"30\" y2=\"30\""), "{svg}");
}
#[test]
fn import_gradient_and_groups() {
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50">
+5 -5
View File
@@ -1069,12 +1069,12 @@ fn public_family(family: &str) -> Option<String> {
/// The family Apple's system fonts are installed under, for the names designs use for them
/// (lowercase): SF Pro and SF UI are the variable `SFNS.ttf` ("System Font"), its text and display
/// cuts its optical sizes.
fn apple_alias(key: &str) -> Option<&'static str> {
/// cuts its optical sizes, as are the names CSS gives the system font (`system-ui`,
/// `-apple-system`, `BlinkMacSystemFont`, `.AppleSystemUIFont`).
pub fn apple_alias(key: &str) -> Option<&'static str> {
Some(match key.trim_start_matches('.') {
"sf pro" | "sf pro text" | "sf pro display" | "sf ui text" | "sf ui display" | "sf ns" | "sf ns text" | "sf ns display" | "san francisco" => {
"system font"
}
"sf pro" | "sf pro text" | "sf pro display" | "sf ui text" | "sf ui display" | "sf ns" | "sf ns text" | "sf ns display" | "san francisco"
| "system-ui" | "-apple-system" | "blinkmacsystemfont" | "applesystemuifont" => "system font",
"sf compact text" | "sf compact display" => "sf compact",
_ => return None,
})
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more