mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 12:22:07 +00:00
Merge main into #1498
This commit is contained in:
Generated
+90
@@ -939,11 +939,46 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-atomic"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-font-catalog"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"fontique",
|
||||
"skrifa 0.47.0",
|
||||
"thiserror 2.0.21",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-font-store"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-atomic",
|
||||
"craft-font-catalog",
|
||||
"flate2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"skrifa 0.47.0",
|
||||
"thiserror 2.0.21",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
@@ -1746,6 +1781,8 @@ version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
"craft-font-catalog",
|
||||
"craft-font-store",
|
||||
"deunicode",
|
||||
"figcraft-collab",
|
||||
"figcraft-color",
|
||||
@@ -1777,6 +1814,7 @@ name = "figcraft-format"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-layout",
|
||||
"figcraft-pathops",
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -1866,6 +1904,7 @@ dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
"figcraft-format",
|
||||
"figcraft-geom",
|
||||
"figcraft-pathops",
|
||||
"figcraft-text",
|
||||
@@ -1922,6 +1961,7 @@ name = "figcraft-text"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"craft-font-catalog",
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
"harfrust 0.13.3",
|
||||
@@ -2115,6 +2155,28 @@ dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontique"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"linebender_resource_handle",
|
||||
"memmap2",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"parlance",
|
||||
"read-fonts 0.41.0",
|
||||
"roxmltree 0.21.2",
|
||||
"smallvec",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"yeslogic-fontconfig-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -3424,6 +3486,16 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -3682,6 +3754,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parlance"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e"
|
||||
|
||||
[[package]]
|
||||
name = "pdf-writer"
|
||||
version = "0.15.0"
|
||||
@@ -5358,6 +5436,7 @@ dependencies = [
|
||||
"rustls-platform-verifier",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6503,6 +6582,17 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yeslogic-fontconfig-sys"
|
||||
version = "6.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"once_cell",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.4"
|
||||
|
||||
+5
-1
@@ -42,6 +42,10 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
|
||||
# pull a second egui family version).
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
|
||||
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
|
||||
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
|
||||
@@ -136,7 +140,7 @@ lto = "thin"
|
||||
codegen-units = 4
|
||||
|
||||
# The browser build only (apps/figcraft-web/index.html: data-cargo-profile). Optimised for size:
|
||||
# packaging/web/package.sh fails a .wasm over 25 MiB - 512 KiB (Cloudflare's per-file cap is 25 MiB).
|
||||
# packaging/web/package.sh fails a .wasm over 24 MiB (Cloudflare's per-file cap is 25 MiB).
|
||||
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
|
||||
[profile.web-release]
|
||||
inherits = "release"
|
||||
|
||||
+27
-1
@@ -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-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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); 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
|
||||
@@ -136,7 +136,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged.
|
||||
- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
|
||||
- **2026-10-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged.
|
||||
- **2026-10-27** · M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged.
|
||||
- **2026-10-27** · Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return one outcome per step. References to failed steps fail with both step numbers; successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. Numbers unchanged.
|
||||
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
|
||||
- **2026-10-25** · M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged.
|
||||
- **2026-10-25** · 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** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
|
||||
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
|
||||
@@ -352,14 +355,37 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. |
|
||||
| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged |
|
||||
| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. |
|
||||
| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). |
|
||||
| 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged |
|
||||
| 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged |
|
||||
| 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged |
|
||||
| 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. |
|
||||
| 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged |
|
||||
| 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) |
|
||||
| 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged |
|
||||
| 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 | M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. 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 | M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity 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 |
|
||||
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
|
||||
|
||||
@@ -11,7 +11,13 @@ use serde_json::{Value, json};
|
||||
use crate::{CliError, CliResult};
|
||||
|
||||
pub fn load(path: &str) -> CliResult<Document> {
|
||||
load_noted(path).map(|(d, _)| d)
|
||||
let (doc, notes) = load_noted(path)?;
|
||||
if ext(path) == "penpot" {
|
||||
for note in notes {
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// The document with the loader's notes (repairs of a damaged file, or import warnings).
|
||||
@@ -120,7 +126,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
|
||||
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
|
||||
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
|
||||
"fig" => fig_bytes(doc)?,
|
||||
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))),
|
||||
"penpot" => penpot_bytes(doc)?,
|
||||
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig, penpot)"))),
|
||||
};
|
||||
write(out, &bytes)
|
||||
}
|
||||
@@ -134,6 +141,14 @@ fn fig_bytes(doc: &Document) -> CliResult<Vec<u8>> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn penpot_bytes(doc: &Document) -> CliResult<Vec<u8>> {
|
||||
let (bytes, warnings) = figcraft_engine::cmd::file::penpot_bytes(doc).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
for w in warnings {
|
||||
eprintln!("note: {w}");
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// A page as PDF: its top-level frames one page each, or (without frames) everything on one page.
|
||||
fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult<Vec<u8>> {
|
||||
let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) {
|
||||
@@ -237,12 +252,16 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
println!("{out}");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
if t.format.as_deref().map_or(ext(out) == "penpot", |f| f.eq_ignore_ascii_case("penpot")) {
|
||||
write(out, &penpot_bytes(&doc)?)?;
|
||||
println!("{out}");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
let page = page_named(&doc, t.page.as_deref())?;
|
||||
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
|
||||
let fmt = match t.format.as_deref() {
|
||||
Some(f) => {
|
||||
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig)")))?
|
||||
}
|
||||
Some(f) => image_format(&f.to_ascii_lowercase())
|
||||
.ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig|penpot)")))?,
|
||||
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
|
||||
};
|
||||
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
|
||||
@@ -321,6 +340,11 @@ fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &fig
|
||||
pub fn convert(input: &str, out: &str) -> CliResult<ExitCode> {
|
||||
let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?;
|
||||
refuse_dropping_images(input, out, loaded.images_past_budget)?;
|
||||
if ext(input) == "penpot" {
|
||||
for note in &loaded.issues {
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
}
|
||||
let doc = loaded.doc;
|
||||
let page = doc
|
||||
.current_page
|
||||
|
||||
@@ -17,7 +17,7 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
|
||||
|
||||
USAGE:
|
||||
figcraft-cli info <file> [--json]
|
||||
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
|
||||
figcraft-cli convert <in> <out>
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
@@ -33,11 +33,11 @@ Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): te
|
||||
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
|
||||
indexed the first time text is laid out, as in the app.
|
||||
|
||||
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
||||
Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
||||
wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an
|
||||
image layer). `export` writes one file per top-level frame into the <out> directory, or one file when
|
||||
<out> has an image extension or --node is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
|
||||
not open every file); PDFs take print options (a bleed like 3mm or 0.125in,
|
||||
not open every file; `penpot`, or an <out> ending in .penpot: the whole file as a Penpot 2.x archive); PDFs take print options (a bleed like 3mm or 0.125in,
|
||||
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
|
||||
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
|
||||
@@ -13,6 +13,40 @@ fn tmp(name: &str) -> std::path::PathBuf {
|
||||
d
|
||||
}
|
||||
|
||||
/// Conversion and image commands must report the Penpot loader's notes too.
|
||||
#[test]
|
||||
fn penpot_import_notes_reach_convert_export_and_render() {
|
||||
use figcraft_doc::proto::{Action, Easing, Interaction, Transition, Trigger};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
let d = tmp("penpot-notes");
|
||||
let mut doc = Document::new("Prototype");
|
||||
let page = doc.first_page().unwrap();
|
||||
let home = doc.create(page, NodeKind::Frame, "Home", 0.0, 0.0, 40.0, 40.0).unwrap();
|
||||
let dest = doc.create(page, NodeKind::Frame, "Next", 60.0, 0.0, 40.0, 40.0).unwrap();
|
||||
doc.node_mut(home).unwrap().interactions.push(Interaction {
|
||||
trigger: Trigger::OnClick,
|
||||
actions: vec![Action::Navigate { dest, preserve_scroll: false }],
|
||||
transition: Transition::Instant,
|
||||
duration_ms: 0,
|
||||
easing: Easing::Linear,
|
||||
delay_ms: 0,
|
||||
});
|
||||
let input = d.join("source.penpot");
|
||||
std::fs::write(&input, figcraft_format::penpot::export(&doc).unwrap().0).unwrap();
|
||||
for (command, name) in [("convert", "out.figcraft"), ("export", "out.svg"), ("render", "out.png")] {
|
||||
let mut cmd = bin();
|
||||
cmd.arg(command).arg(&input);
|
||||
if command != "convert" {
|
||||
cmd.arg("-o");
|
||||
}
|
||||
let out = cmd.arg(d.join(name)).output().unwrap();
|
||||
let notes = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "{command}: {notes}");
|
||||
assert!(notes.contains("note: Imported 1 Penpot prototype connection"), "{command}: {notes}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_terminator_allows_dash_prefixed_filenames() {
|
||||
let dir = tmp("option-terminator");
|
||||
|
||||
@@ -43,8 +43,8 @@ Options (also keys of the --config TOML file):
|
||||
--data DIR the data directory (default ./figcraft-data)
|
||||
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
|
||||
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
|
||||
max_connections, max_people, compact_after, compact_mb, max_plugins,
|
||||
max_plugin_mb, max_plugins_mb; for a public server: open_publish,
|
||||
max_connections, max_people, compact_after, compact_mb, max_log_mb,
|
||||
max_plugins, max_plugin_mb, max_plugins_mb; for a public server: open_publish,
|
||||
operator_contact, terms_url, trusted_proxy, idle_days, max_total_mb,
|
||||
max_files_per_ip, max_mb_per_ip, max_connections_per_ip,
|
||||
connects_per_minute, publishes_per_hour, messages_per_second
|
||||
|
||||
@@ -39,6 +39,9 @@ pub struct Config {
|
||||
pub compact_after: usize,
|
||||
/// …or once the log is this large (MiB).
|
||||
pub compact_mb: u64,
|
||||
/// Most MiB of recent batches a file in use keeps in memory for apps that reconnect (one
|
||||
/// further behind catches up with the whole file).
|
||||
pub max_log_mb: u64,
|
||||
/// Most plugin versions the plugin directory keeps (0 turns it off).
|
||||
pub max_plugins: usize,
|
||||
/// Largest plugin bundle, in MiB.
|
||||
@@ -84,6 +87,7 @@ impl Default for Config {
|
||||
max_people: 256,
|
||||
compact_after: 1000,
|
||||
compact_mb: 16,
|
||||
max_log_mb: 64,
|
||||
max_plugins: 500,
|
||||
max_plugin_mb: 16,
|
||||
max_plugins_mb: 1024,
|
||||
@@ -209,6 +213,7 @@ impl Config {
|
||||
("max_people", self.max_people as u64),
|
||||
("compact_after", self.compact_after as u64),
|
||||
("compact_mb", self.compact_mb),
|
||||
("max_log_mb", self.max_log_mb),
|
||||
("max_plugin_mb", self.max_plugin_mb),
|
||||
("max_plugins_mb", self.max_plugins_mb),
|
||||
] {
|
||||
@@ -216,7 +221,7 @@ impl Config {
|
||||
return Err(format!("`{name}` must be at least 1"));
|
||||
}
|
||||
}
|
||||
if self.file_mb() > 4096 || self.compact_mb > 4096 {
|
||||
if self.file_mb() > 4096 || self.compact_mb > 4096 || self.max_log_mb > 4096 {
|
||||
return Err("sizes are in MiB and at most 4096".into());
|
||||
}
|
||||
// A bundle travels in one message (at most 64 MiB) next to its entry.
|
||||
@@ -274,17 +279,19 @@ impl Config {
|
||||
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// The collaboration core's part: when open, messages as large as a file at most, and the
|
||||
/// batches kept for reconnecting clients (in memory, and in the file's snapshot) at most a
|
||||
/// quarter of a file's bytes (256 KiB at least).
|
||||
/// The collaboration core's part: the batches kept for reconnecting clients (in memory, and
|
||||
/// in the file's snapshot) at most `max_log_mb`; when open, messages as large as a file at
|
||||
/// most, and those batches also at most a quarter of a file's bytes (256 KiB at least).
|
||||
fn core(&self) -> figcraft_collab::Limits {
|
||||
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
|
||||
let bytes = |mb: u64| usize::try_from(mb.saturating_mul(1 << 20)).unwrap_or(usize::MAX);
|
||||
let base =
|
||||
figcraft_collab::Limits { max_peers: self.max_people, max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() };
|
||||
if !self.open_publish {
|
||||
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
|
||||
}
|
||||
let max = self.open_message();
|
||||
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
|
||||
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
|
||||
let log = (bytes(self.file_mb()) / 4).max(256 << 10).min(base.max_log_bytes);
|
||||
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: log, ..base }
|
||||
}
|
||||
|
||||
/// The transport's part. A private server keeps the defaults; open to the public, connections
|
||||
|
||||
@@ -584,6 +584,19 @@ fn files_above_64_mib_fit_every_limit() {
|
||||
assert_eq!(NetLimits::default().max_server_message, 1 << 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_batches_kept_for_reconnecting_apps_follow_max_log_mb() {
|
||||
let log = |cfg: Config| cfg.store().core.max_log_bytes;
|
||||
assert_eq!(log(Config::default()), 64 << 20, "a private server's log is bounded too");
|
||||
assert_eq!(log(Config { max_log_mb: 8, ..Config::default() }), 8 << 20);
|
||||
// Open to the public: also at most a quarter of a file (16 MiB unless set).
|
||||
assert_eq!(log(Config { open_publish: true, ..Config::default() }), 4 << 20);
|
||||
assert_eq!(log(Config { open_publish: true, max_log_mb: 1, ..Config::default() }), 1 << 20);
|
||||
for mb in [0, 4097] {
|
||||
assert!(Config { max_log_mb: mb, ..Config::default() }.check().is_err(), "max_log_mb = {mb}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_files_warn_the_operator() {
|
||||
let public_url = Some("wss://figcraft.example.com/".into());
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# The browser build (trunk runs cargo from this folder; packaging/web/package.sh). With simd128 the
|
||||
# glyph rasterizer's SIMD code compiles for wasm SIMD instead of its much larger scalar fallback:
|
||||
# about 1.6 MB less wasm. Every current browser has wasm SIMD (Safari since 16.4). The plugin
|
||||
# workspace is built from the repository root, so its modules stay without SIMD for the plugin runtime.
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = ["-Ctarget-feature=+simd128"]
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>FigCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue" data-weak-refs />
|
||||
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue --enable-simd" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; overscroll-behavior: none; background: #f5f5f5; }
|
||||
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
|
||||
|
||||
@@ -131,9 +131,10 @@ fn document_level_data_round_trips() {
|
||||
x: 1.0,
|
||||
y: 2.0,
|
||||
anchor: None,
|
||||
first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![] },
|
||||
first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![], agent: false },
|
||||
resolved: false,
|
||||
replies: vec![],
|
||||
// An AI agent's reply reaches the others with its name and marker.
|
||||
replies: vec![Message { agent: true, ..Message::new("Claude (agent)", "Done", 2) }],
|
||||
read_by: None,
|
||||
});
|
||||
b.images.insert("hash".into(), ImageData { bytes: Arc::new(vec![1, 2, 3, 250]), width: 2, height: 1 });
|
||||
@@ -250,3 +251,62 @@ fn invalid_changes_are_refused_and_lenient_mode_skips_them() {
|
||||
let mut r = a.clone();
|
||||
assert!(r.apply(&[Change::Delete { id: NodeId(12345) }, Change::Remove { entry: Entry::Comment(1) }], Mode::Strict).is_ok());
|
||||
}
|
||||
|
||||
/// Where `id` goes among `kids`, as placement worked before it searched: after every other sibling
|
||||
/// that sorts before it by `(key, id)`, counted one by one.
|
||||
fn place_counting(kids: &mut Vec<NodeId>, keys: &BTreeMap<NodeId, String>, id: NodeId) {
|
||||
kids.retain(|c| *c != id);
|
||||
let key = |c: NodeId| keys.get(&c).map_or("", String::as_str);
|
||||
let at = kids.iter().filter(|c| (key(**c), **c) < (key(id), id)).count();
|
||||
kids.insert(at, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_sibling_placements_keep_the_order_of_counting_them() {
|
||||
// The reference counts, so it's quadratic: placing 20,000 here took 20 s before the search
|
||||
// and 0.3 s after (debug build).
|
||||
const N: u64 = 5_000;
|
||||
let mut doc = Document::new("Order");
|
||||
let page = doc.first_page().unwrap();
|
||||
let parents = [page, doc.create(page, NodeKind::Frame, "A", 0.0, 0.0, 10.0, 10.0).unwrap()];
|
||||
let mut r = Replica::new(doc);
|
||||
let mut rng = crate::sim::Rng::new(1065);
|
||||
// Few whole keys and the fractions between them: many ties (broken by id), many fractions.
|
||||
let whole = crate::frac::n_between(None, None, 12).unwrap();
|
||||
let mut pool = whole.clone();
|
||||
pool.extend(whole.windows(2).map(|w| crate::frac::between(Some(&w[0]), Some(&w[1])).unwrap()));
|
||||
let mut keys: BTreeMap<NodeId, String> = parents.iter().map(|p| (*p, r.key(*p).to_string())).collect();
|
||||
let mut kids: BTreeMap<NodeId, Vec<NodeId>> = parents.iter().map(|p| (*p, r.document().node(*p).unwrap().children.clone())).collect();
|
||||
let mut parent_of = BTreeMap::new();
|
||||
// One batch of creations in a scrambled id order, under either parent.
|
||||
let mut creates = Vec::new();
|
||||
for i in 0..N {
|
||||
let (id, p, key) = (NodeId(1_000_000 + (i * 7919) % N), parents[rng.below(2)], pool[rng.below(pool.len())].clone());
|
||||
keys.insert(id, key.clone());
|
||||
parent_of.insert(id, p);
|
||||
place_counting(kids.get_mut(&p).unwrap(), &keys, id);
|
||||
creates.push(Change::Create { id, kind: Json::of(&NodeKind::Rectangle).unwrap(), parent: Some(p), position: key, props: vec![] });
|
||||
}
|
||||
// Then a batch of moves: to another key (often a tie), or under either parent (its own too).
|
||||
let mut moves = Vec::new();
|
||||
for _ in 0..N / 2 {
|
||||
let id = NodeId(1_000_000 + rng.below(N as usize) as u64);
|
||||
if rng.chance(0.5) {
|
||||
let key = pool[rng.below(pool.len())].clone();
|
||||
keys.insert(id, key.clone());
|
||||
place_counting(kids.get_mut(&parent_of[&id]).unwrap(), &keys, id);
|
||||
moves.push(Change::Set { id, prop: Prop::Position, value: Json::of(&key).unwrap() });
|
||||
} else {
|
||||
let p = parents[rng.below(2)];
|
||||
let old = parent_of.insert(id, p).unwrap();
|
||||
kids.get_mut(&old).unwrap().retain(|c| *c != id);
|
||||
place_counting(kids.get_mut(&p).unwrap(), &keys, id);
|
||||
moves.push(Change::Set { id, prop: Prop::Parent, value: Json::of(&Some(p)).unwrap() });
|
||||
}
|
||||
}
|
||||
r.apply(&creates, Mode::Strict).unwrap();
|
||||
r.apply(&moves, Mode::Strict).unwrap();
|
||||
for p in parents {
|
||||
assert_eq!(r.document().node(p).unwrap().children, kids[&p], "children of {p}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,8 @@ pub struct Limits {
|
||||
pub max_log_batches: usize,
|
||||
/// Most changes in those batches together.
|
||||
pub max_log_changes: usize,
|
||||
/// About how many bytes those batches may hold together ([`server::weight`]).
|
||||
/// About how many bytes those batches may hold together ([`server::weight`]). 64 MiB by
|
||||
/// default, so a long busy session (on a desktop host too) keeps a bounded log.
|
||||
pub max_log_bytes: usize,
|
||||
}
|
||||
|
||||
@@ -102,7 +103,7 @@ impl Default for Limits {
|
||||
max_name: 64,
|
||||
max_log_batches: 10_000,
|
||||
max_log_changes: 1_000_000,
|
||||
max_log_bytes: usize::MAX,
|
||||
max_log_bytes: 64 << 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,43 @@ fn a_long_absence_catches_up_with_a_snapshot() {
|
||||
assert_eq!(a.view(), s.replica());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_log_is_bounded_in_bytes_and_a_client_behind_it_catches_up_with_a_snapshot() {
|
||||
// The defaults are what a desktop host runs with.
|
||||
let limits = Limits::default();
|
||||
assert_eq!(limits.max_log_bytes, 64 << 20);
|
||||
let mut s = Server::new(base(), "tok", limits);
|
||||
let mut a = join(&mut s);
|
||||
let mut b = join(&mut s);
|
||||
let page = s.document().first_page().unwrap();
|
||||
a.disconnect();
|
||||
s.leave(a.id());
|
||||
// Nine renames of 8 MiB each: more than the log holds, in a few batches.
|
||||
for i in 0..9 {
|
||||
let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = format!("{i}{}", "y".repeat(8 << 20))).unwrap();
|
||||
let out = s.receive(b.id(), m);
|
||||
deliver(&mut b, &out);
|
||||
assert!(s.log_bytes() <= limits.max_log_bytes, "{} bytes kept", s.log_bytes());
|
||||
}
|
||||
assert!(s.log_bytes() > 0, "recent batches are still kept");
|
||||
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!() };
|
||||
let (_, out) = s.join(h).unwrap();
|
||||
assert!(matches!(&out[0], Outgoing::To(_, ServerMsg::Welcome { catch_up: CatchUp::Snapshot(_), .. })));
|
||||
deliver(&mut a, &out);
|
||||
assert_eq!(a.view(), s.replica());
|
||||
// Within the log, a short absence still gets the missed batch only.
|
||||
a.disconnect();
|
||||
s.leave(a.id());
|
||||
let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = "Later".into()).unwrap();
|
||||
let out = s.receive(b.id(), m);
|
||||
deliver(&mut b, &out);
|
||||
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!() };
|
||||
let (_, out) = s.join(h).unwrap();
|
||||
assert!(matches!(&out[0], Outgoing::To(_, ServerMsg::Welcome { catch_up: CatchUp::Delta(d), .. }) if d.len() == 1));
|
||||
deliver(&mut a, &out);
|
||||
assert_eq!(a.view(), s.replica());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_a_snapshot_undo_leaves_what_others_changed_meanwhile() {
|
||||
let mut s = Server::new(base(), "tok", Limits { max_log_batches: 2, ..Limits::default() });
|
||||
|
||||
@@ -175,9 +175,15 @@ impl Replica {
|
||||
}
|
||||
|
||||
/// Where `id` with `key` goes among `siblings` (sorted by `(key, id)`; `id` itself skipped):
|
||||
/// how many of them sort before it.
|
||||
/// how many of them sort before it. A binary search on each side of `id`, which may still
|
||||
/// sit where its old key put it.
|
||||
fn slot(&self, siblings: &[NodeId], id: NodeId, key: &str) -> usize {
|
||||
siblings.iter().filter(|c| **c != id && (self.key(**c), **c) < (key, id)).count()
|
||||
let before = |c: &NodeId| (self.key(*c), *c) < (key, id);
|
||||
let (head, tail) = match siblings.iter().position(|c| *c == id) {
|
||||
Some(i) => (siblings.get(..i).unwrap_or_default(), siblings.get(i + 1..).unwrap_or_default()),
|
||||
None => (siblings, &[][..]),
|
||||
};
|
||||
head.partition_point(before) + tail.partition_point(before)
|
||||
}
|
||||
|
||||
/// Apply `changes` in order. Node changes come first in a batch made by [`diff`](crate::diff);
|
||||
|
||||
+18
-13
@@ -557,6 +557,7 @@ impl Server {
|
||||
let doc = self.state.document();
|
||||
let mut props: BTreeMap<NodeId, BTreeSet<Prop>> = BTreeMap::new();
|
||||
let mut gone: BTreeSet<NodeId> = BTreeSet::new();
|
||||
let (mut asked, mut sent) = (BTreeSet::new(), BTreeSet::new());
|
||||
let mut out = Vec::new();
|
||||
for c in changes {
|
||||
match c {
|
||||
@@ -569,9 +570,11 @@ impl Server {
|
||||
Change::Delete { id } => {
|
||||
gone.insert(*id);
|
||||
}
|
||||
// The same target has the same fix: look each one up once, and send each fix once.
|
||||
other => {
|
||||
if let Some(fix) = docprops::current(doc, other)?
|
||||
&& !out.contains(&fix)
|
||||
if Target::of(other).is_some_and(|t| asked.insert(t))
|
||||
&& let Some(fix) = docprops::current(doc, other)?
|
||||
&& Target::of(&fix).is_some_and(|t| sent.insert(t))
|
||||
{
|
||||
out.push(fix);
|
||||
}
|
||||
@@ -589,19 +592,21 @@ impl Server {
|
||||
nodes.extend(t.write(self.state.value(&t)?.as_ref()));
|
||||
}
|
||||
}
|
||||
let mut restored = std::collections::BTreeSet::new();
|
||||
// Each deleted node's subtree, pre-order, every node once: a restored node's whole subtree
|
||||
// went out with it, so the walk stops there (deleting a deep chain stays linear).
|
||||
let mut restored = BTreeSet::new();
|
||||
for id in gone {
|
||||
match doc.node(id) {
|
||||
Some(_) => {
|
||||
for d in doc.subtree(id) {
|
||||
if restored.insert(d)
|
||||
&& let Some(n) = doc.node(d)
|
||||
{
|
||||
nodes.push(create_change(n, self.state.key(d))?);
|
||||
}
|
||||
}
|
||||
if !doc.contains(id) {
|
||||
nodes.push(Change::Delete { id });
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![id];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Some(n) = doc.node(d) else { continue };
|
||||
if restored.insert(d) {
|
||||
nodes.push(create_change(n, self.state.key(d))?);
|
||||
stack.extend(n.children.iter().rev());
|
||||
}
|
||||
None => nodes.push(Change::Delete { id }),
|
||||
}
|
||||
}
|
||||
nodes.extend(out);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use crate::change::{Change, Json, Prop};
|
||||
use crate::change::{Change, Entry, Json, Prop};
|
||||
use crate::message::{ClientMsg, Hello, PROTOCOL, Presence, ServerMsg};
|
||||
use crate::replica::Mode;
|
||||
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
|
||||
@@ -217,6 +217,43 @@ fn fixes_roll_a_rejected_client_back_to_the_server_state() {
|
||||
assert_eq!(b.view(), s.replica());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_deep_delete_restores_each_node_once_in_linear_time() {
|
||||
const DEPTH: usize = 20_000;
|
||||
let (mut d, _, frame) = doc();
|
||||
let mut chain = vec![frame];
|
||||
while chain.len() < DEPTH {
|
||||
let parent = *chain.last().unwrap();
|
||||
chain.push(d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 1.0, 1.0).unwrap());
|
||||
}
|
||||
let mut s = Server::new(d, "tok", Limits::default());
|
||||
let a = join(&mut s);
|
||||
// Every node of the chain deleted, plus a rename of a node nobody has: refused whole.
|
||||
let missing = NodeId(u64::MAX >> 1);
|
||||
let mut changes: Vec<Change> = chain.iter().map(|&id| Change::Delete { id }).collect();
|
||||
changes.push(Change::Set { id: missing, prop: Prop::Name, value: Json("x".into()) });
|
||||
// As many comment threads put, each twice: they go back to not existing, once each.
|
||||
let put = |c: u64| Change::Put { entry: Entry::Comment(c), value: Json(serde_json::json!({})) };
|
||||
changes.extend((1..=DEPTH as u64).chain(1..=DEPTH as u64).map(put));
|
||||
let started = std::time::Instant::now();
|
||||
let (_, fixes) = rejected(&mut s, a.id(), changes);
|
||||
let took = started.elapsed();
|
||||
assert_eq!(fixes.first(), Some(&Change::Delete { id: missing }));
|
||||
let restored: Vec<NodeId> = fixes[1..=DEPTH]
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
Change::Create { id, .. } => *id,
|
||||
other => panic!("not a creation: {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(restored, chain, "each deleted node comes back once, parents first");
|
||||
let removed: Vec<Change> = (1..=DEPTH as u64).map(|c| Change::Remove { entry: Entry::Comment(c) }).collect();
|
||||
assert_eq!(fixes[DEPTH + 1..], removed[..], "each entry once, in batch order");
|
||||
// Walking every deleted node's subtree again and comparing each fix with all the earlier
|
||||
// ones took 27 s in a debug build; once each takes 0.1 s.
|
||||
assert!(took < std::time::Duration::from_secs(10), "{took:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_batches_apply_under_pending_ones() {
|
||||
let (d, _, frame) = doc();
|
||||
|
||||
+15
-1
@@ -486,9 +486,23 @@ impl ImagePaint {
|
||||
{
|
||||
return c;
|
||||
}
|
||||
self.fitted_crop(iw, ih, w, h, false)
|
||||
}
|
||||
|
||||
/// The crop transform that keeps the picture where its scale mode draws it: the crop in effect
|
||||
/// in Crop mode, the Fit placement in Fit mode, else (Fill, Tile) the Fill placement.
|
||||
pub fn placement_crop(&self, iw: f64, ih: f64, w: f64, h: f64) -> [f64; 6] {
|
||||
match self.scale_mode {
|
||||
ImageScaleMode::Crop => self.effective_crop(iw, ih, w, h),
|
||||
mode => self.fitted_crop(iw, ih, w, h, mode == ImageScaleMode::Fit),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Fill placement (`fit`: the Fit placement) as a crop transform.
|
||||
fn fitted_crop(&self, iw: f64, ih: f64, w: f64, h: f64, fit: bool) -> [f64; 6] {
|
||||
let (rw, rh) = self.rotated_size(iw.max(1.0), ih.max(1.0));
|
||||
let (w, h) = (w.max(1e-6), h.max(1e-6));
|
||||
let s = (w / rw).max(h / rh);
|
||||
let s = if fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) };
|
||||
let (x0, y0) = ((w - rw * s) / 2.0, (h - rh * s) / 2.0);
|
||||
[w / (rw * s), 0.0, 0.0, h / (rh * s), -x0 / (rw * s), -y0 / (rh * s)]
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct Message {
|
||||
pub edited: u64,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub reactions: Vec<Reaction>,
|
||||
/// Written by an AI agent (a command run for an MCP client), not by a person in the app.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub agent: bool,
|
||||
}
|
||||
|
||||
/// A reply in a comment thread.
|
||||
@@ -37,7 +40,7 @@ fn is_zero(v: &u64) -> bool {
|
||||
|
||||
impl Message {
|
||||
pub fn new(author: impl Into<String>, text: impl Into<String>, created: u64) -> Self {
|
||||
Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new() }
|
||||
Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new(), agent: false }
|
||||
}
|
||||
|
||||
/// `user` left the `emoji` reaction.
|
||||
@@ -180,10 +183,16 @@ impl Comment {
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte ranges of `@Name` mentions of `people` in `text` (names match ignoring ASCII case; the
|
||||
/// longest name wins; a mention starts the text or follows a non-alphanumeric character and ends
|
||||
/// before one).
|
||||
/// The byte ranges of `@Name` mentions of `people` in `text` (see [`mentions`]).
|
||||
pub fn mention_ranges(text: &str, people: &[&str]) -> Vec<Range<usize>> {
|
||||
mentions(text, people).into_iter().filter(|(_, who)| who.is_some()).map(|(r, _)| r).collect()
|
||||
}
|
||||
|
||||
/// Every `@` mention in `text`: its byte range and the one of `people` it names (names match
|
||||
/// ignoring ASCII case; the longest name wins), or `None` for a name nobody in `people` has (then
|
||||
/// the letters and digits after the `@`). A mention starts the text or follows a non-alphanumeric
|
||||
/// character and ends before one, so `a@b.com` mentions nobody.
|
||||
pub fn mentions<'p>(text: &str, people: &[&'p str]) -> Vec<(Range<usize>, Option<&'p str>)> {
|
||||
let mut out = Vec::new();
|
||||
let mut from = 0;
|
||||
while let Some(off) = text.get(from..).and_then(|t| t.find('@')) {
|
||||
@@ -194,15 +203,16 @@ pub fn mention_ranges(text: &str, people: &[&str]) -> Vec<Range<usize>> {
|
||||
continue;
|
||||
}
|
||||
let rest = text.get(at + 1..).unwrap_or("");
|
||||
let best = people
|
||||
let who = people
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|n| !n.is_empty())
|
||||
.filter(|n| rest.get(..n.len()).is_some_and(|h| h.eq_ignore_ascii_case(n)))
|
||||
.filter(|n| rest.get(n.len()..).and_then(|t| t.chars().next()).is_none_or(|c| !c.is_alphanumeric()))
|
||||
.map(|n| n.len())
|
||||
.max();
|
||||
if let Some(len) = best {
|
||||
out.push(at..at + 1 + len);
|
||||
.max_by_key(|n| n.len());
|
||||
let len = who.map_or_else(|| rest.find(|c: char| !c.is_alphanumeric()).unwrap_or(rest.len()), str::len);
|
||||
if len > 0 {
|
||||
out.push((at..at + 1 + len, who));
|
||||
from = at + 1 + len;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +445,21 @@ pub struct TextData {
|
||||
/// data small.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<Box<TextPath>>,
|
||||
/// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout).
|
||||
/// Layout follows them only while the box width and everything that moves a line break are
|
||||
/// as they were saved, so an edit that could change the wrap drops them.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub saved_breaks: Option<Box<SavedBreaks>>,
|
||||
}
|
||||
|
||||
/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a
|
||||
/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct SavedBreaks {
|
||||
pub starts: Vec<usize>,
|
||||
pub width: f64,
|
||||
pub for_text: u64,
|
||||
}
|
||||
|
||||
/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the
|
||||
@@ -503,6 +518,43 @@ impl TextData {
|
||||
Self { characters: characters.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
/// A stable hash of what decides where lines break: everything but paints, links, style links,
|
||||
/// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it
|
||||
/// stays the same across builds and can be saved).
|
||||
pub fn layout_key(&self) -> u64 {
|
||||
let clear = |s: &mut TextStyle| {
|
||||
(s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None);
|
||||
};
|
||||
let mut t = TextData {
|
||||
align_v: TextAlignV::default(),
|
||||
paragraph_spacing: 0.0,
|
||||
list_spacing: 0.0,
|
||||
leading_trim: LeadingTrim::default(),
|
||||
max_lines: 0,
|
||||
saved_breaks: None,
|
||||
..self.clone()
|
||||
};
|
||||
clear(&mut t.style);
|
||||
t.runs.iter_mut().for_each(|r| clear(&mut r.style));
|
||||
t.normalize();
|
||||
serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
|
||||
}
|
||||
|
||||
/// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box
|
||||
/// `width` wide.
|
||||
pub fn save_breaks(&mut self, starts: Vec<usize>, width: f64) {
|
||||
self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() }));
|
||||
}
|
||||
|
||||
/// The saved line starts (empty: no wrapped line) while they still hold: the same box width and
|
||||
/// layout content, and starts increasing inside the text.
|
||||
pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> {
|
||||
let b = self.saved_breaks.as_deref()?;
|
||||
let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count());
|
||||
let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0);
|
||||
(inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice())
|
||||
}
|
||||
|
||||
/// How far text on a path paints past its path's box (glyphs stand on the path, lines after
|
||||
/// the first run beside it): about one and a half of the largest size per line; 0 off a path.
|
||||
pub fn path_reach(&self) -> f64 {
|
||||
|
||||
@@ -33,6 +33,12 @@ deunicode = { workspace = true }
|
||||
same-file = { workspace = true }
|
||||
web-time = "1.1.0"
|
||||
|
||||
# Downloading a missing font (desktop only; the web keeps its built-in fonts). The bundled index
|
||||
# (~620 KB) lets the Missing fonts window tell which fonts can be downloaded without a request.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-font-store = { workspace = true, features = ["bundled-index"] }
|
||||
craft-font-catalog = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
zip = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
|
||||
@@ -39,6 +39,7 @@ use figcraft_doc::{
|
||||
use figcraft_geom::Affine;
|
||||
use serde_json::Value;
|
||||
|
||||
mod cycles;
|
||||
mod diff;
|
||||
mod guard;
|
||||
mod instances;
|
||||
@@ -47,6 +48,9 @@ mod rebase;
|
||||
mod sync;
|
||||
mod variants;
|
||||
|
||||
pub use cycles::{
|
||||
break_component_cycles, check_component_cycles, component_cycle, contains_itself_error, cycle_text, new_cycle, would_contain_itself,
|
||||
};
|
||||
pub use diff::{diff_groups, property_owners, set_boolean_default, variable_groups};
|
||||
pub use guard::{check_override_edit, check_structure_edit, refused_override, unlink_layers};
|
||||
pub(crate) use instances::copy_group;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! A component can't contain its own instances, directly or through other components (#1084):
|
||||
//! every instance would show itself without end. Each variant is a component of its own here: one
|
||||
//! holding an instance of another variant of its set is fine unless that one leads back to it.
|
||||
//! Commands that would close such a loop are refused ([`check_component_cycles`]);
|
||||
//! files that hold one are broken on opening ([`break_component_cycles`]); and a rebuild never
|
||||
//! expands an instance inside a copy of itself ([`expansion_layers`]), for loops that arrive some
|
||||
//! other way (two edits merged in a live session).
|
||||
|
||||
use super::patch::rebuild_instances;
|
||||
use super::{BTreeMap, BTreeSet, Document, NodeId, NodeKind, PROP_BINDING, PropDef, inside_instance, root_instance};
|
||||
|
||||
/// The components each main component holds instances of.
|
||||
type Graph = BTreeMap<NodeId, BTreeSet<NodeId>>;
|
||||
|
||||
fn is_component(doc: &Document, id: NodeId) -> bool {
|
||||
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
|
||||
}
|
||||
|
||||
/// The main components `id` is (in).
|
||||
fn hosts(doc: &Document, id: NodeId) -> BTreeSet<NodeId> {
|
||||
std::iter::once(id).chain(doc.ancestors(id)).filter(|a| is_component(doc, *a)).collect()
|
||||
}
|
||||
|
||||
/// The components instance `id` shows: its main, and those its instance swap properties pick
|
||||
/// (they swap a layer inside it when it's rebuilt).
|
||||
fn shown(doc: &Document, id: NodeId) -> Vec<NodeId> {
|
||||
let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return Vec::new() };
|
||||
let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|n| &n.kind) else { return Vec::new() };
|
||||
let swaps =
|
||||
defs.iter().filter(|(_, d)| matches!(d, PropDef::InstanceSwap { .. })).filter_map(|(k, _)| props.get(k).and_then(crate::params::id_of));
|
||||
std::iter::once(*component).chain(swaps).filter(|c| is_component(doc, *c)).collect()
|
||||
}
|
||||
|
||||
fn graph(doc: &Document) -> Graph {
|
||||
let mut g = Graph::new();
|
||||
for n in doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })) {
|
||||
let shown = shown(doc, n.id);
|
||||
for h in hosts(doc, n.id) {
|
||||
g.entry(h).or_default().extend(shown.iter().copied());
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// A loop in `g`, its first component repeated last (`A, B, A`).
|
||||
fn find_cycle(g: &Graph) -> Option<Vec<NodeId>> {
|
||||
let next = |u: &NodeId| -> Vec<NodeId> { g.get(u).map(|s| s.iter().rev().copied().collect()).unwrap_or_default() };
|
||||
let mut done: BTreeSet<NodeId> = BTreeSet::new();
|
||||
for start in g.keys() {
|
||||
if done.contains(start) {
|
||||
continue;
|
||||
}
|
||||
// Depth first: the path so far, and the components each step on it has left to visit.
|
||||
let mut path = vec![*start];
|
||||
let mut todo = vec![next(start)];
|
||||
while let Some(left) = todo.last_mut() {
|
||||
match left.pop() {
|
||||
Some(u) if path.contains(&u) => {
|
||||
let at = path.iter().position(|p| *p == u)?;
|
||||
let mut cycle = path.split_off(at);
|
||||
cycle.push(u);
|
||||
return Some(cycle);
|
||||
}
|
||||
Some(u) if done.contains(&u) => {}
|
||||
Some(u) => {
|
||||
todo.push(next(&u));
|
||||
path.push(u);
|
||||
}
|
||||
None => {
|
||||
todo.pop();
|
||||
done.extend(path.pop());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A loop of components whose mains contain each other's instances, if the document has one.
|
||||
pub fn component_cycle(doc: &Document) -> Option<Vec<NodeId>> {
|
||||
find_cycle(&graph(doc))
|
||||
}
|
||||
|
||||
/// A loop as its names: `Card → Button → Card`.
|
||||
fn cycle_names(doc: &Document, cycle: &[NodeId]) -> String {
|
||||
cycle.iter().map(|u| doc.node(*u).map_or_else(|| u.to_string(), |n| format!("\"{}\"", n.name))).collect::<Vec<_>>().join(" → ")
|
||||
}
|
||||
|
||||
/// Whether an instance of `component` under `parent` would contain itself: `parent` is (in) a
|
||||
/// main that instance shows, directly or through other components.
|
||||
pub fn would_contain_itself(doc: &Document, component: NodeId, parent: NodeId) -> bool {
|
||||
let hosts = hosts(doc, parent);
|
||||
if hosts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let g = graph(doc);
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut todo = vec![component];
|
||||
while let Some(u) = todo.pop() {
|
||||
if hosts.contains(&u) {
|
||||
return true;
|
||||
}
|
||||
if seen.insert(u) {
|
||||
todo.extend(g.get(&u).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Why an instance of `component` can't go where it would contain itself.
|
||||
pub fn contains_itself_error(doc: &Document, component: NodeId) -> String {
|
||||
let name = doc.node(component).map(|n| n.name.clone()).unwrap_or_default();
|
||||
format!("an instance of \"{name}\" can't go inside \"{name}\" or a component it contains: a component can't contain itself")
|
||||
}
|
||||
|
||||
/// The loop `after` has and `before` didn't, if layers `touched` (edited, added or removed) closed
|
||||
/// one: only layers in main components can.
|
||||
pub fn new_cycle<'a>(before: &Document, after: &Document, mut touched: impl Iterator<Item = &'a NodeId>) -> Option<String> {
|
||||
if !touched.any(|d| !hosts(after, *d).is_empty()) {
|
||||
return None;
|
||||
}
|
||||
cycle_text(after).filter(|_| component_cycle(before).is_none())
|
||||
}
|
||||
|
||||
/// The document's loop of components containing each other's instances ([`component_cycle`]),
|
||||
/// as its names.
|
||||
pub fn cycle_text(doc: &Document) -> Option<String> {
|
||||
component_cycle(doc).map(|c| cycle_names(doc, &c))
|
||||
}
|
||||
|
||||
/// Refuse an edit that makes components contain each other's instances (it is rolled back). A
|
||||
/// document that already held a loop stays editable.
|
||||
pub fn check_component_cycles(before: &Document, after: &Document) -> Result<(), String> {
|
||||
match new_cycle(before, after, after.dirty().iter()) {
|
||||
Some(names) => Err(format!("a component can't contain an instance of itself, directly or through other components: {names}")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The instance to detach to cut `from → to` out of a loop: one in a main of `from` showing `to`,
|
||||
/// preferably one of the main's own (not a layer of another instance, else its outer instance).
|
||||
fn cut_point(doc: &Document, from: NodeId, to: NodeId) -> Option<NodeId> {
|
||||
let found: Vec<NodeId> = doc
|
||||
.nodes()
|
||||
.filter(|n| matches!(n.kind, NodeKind::Instance { .. }))
|
||||
.map(|n| n.id)
|
||||
.filter(|i| shown(doc, *i).contains(&to) && hosts(doc, *i).contains(&from))
|
||||
.collect();
|
||||
found.iter().copied().find(|i| !inside_instance(doc, *i)).or_else(|| found.first().and_then(|i| root_instance(doc, *i)))
|
||||
}
|
||||
|
||||
/// Detach instance `top` and every instance inside it: its layers stay as they are, linked to no
|
||||
/// main.
|
||||
fn detach_all(doc: &mut Document, top: NodeId) {
|
||||
for d in doc.subtree(top) {
|
||||
let modes = match doc.node(d).map(|n| &n.kind) {
|
||||
Some(NodeKind::Instance { component, .. }) => doc.node(*component).map(|m| m.var_modes.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let Some(n) = doc.node_mut(d) else { continue };
|
||||
if let Some(modes) = modes {
|
||||
n.kind = NodeKind::Frame;
|
||||
n.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
|
||||
for (c, m) in modes {
|
||||
n.var_modes.entry(c).or_insert(m);
|
||||
}
|
||||
}
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
n.prop_refs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Break every loop of components containing each other's instances (a file from elsewhere, or
|
||||
/// written before they were refused): an instance closing each loop is detached, and the instances
|
||||
/// of its main rebuild without it. Returns a note for each.
|
||||
pub fn break_component_cycles(doc: &mut Document) -> Vec<String> {
|
||||
let mut notes = Vec::new();
|
||||
// Each pass detaches an instance: never more passes than there are layers.
|
||||
for _ in 0..doc.nodes().count() {
|
||||
let Some(cycle) = component_cycle(doc) else { break };
|
||||
let (Some(from), Some(to)) = (cycle.iter().rev().nth(1).copied(), cycle.last().copied()) else { break };
|
||||
let Some(cut) = cut_point(doc, from, to) else { break };
|
||||
let name = doc.node(cut).map(|n| n.name.clone()).unwrap_or_default();
|
||||
detach_all(doc, cut);
|
||||
rebuild_instances(doc, &BTreeSet::from([from]));
|
||||
notes.push(format!("\"{name}\" was detached: components can't contain their own instances ({})", cycle_names(doc, &cycle)));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
/// The layers of `component` an instance `inst` of it copies, in pre-order: all of them but those
|
||||
/// inside a nested instance of a component being expanded already (its own, or a main `inst` is
|
||||
/// in), which would show itself again. `None` when `inst` itself is in its own main.
|
||||
pub(super) fn expansion_layers(doc: &Document, inst: NodeId, component: NodeId) -> Option<Vec<NodeId>> {
|
||||
let mut stops = hosts(doc, inst);
|
||||
if !stops.insert(component) {
|
||||
return None;
|
||||
}
|
||||
let repeats = |kind: &NodeKind| matches!(kind, NodeKind::Instance { component: c, .. } if stops.contains(c));
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<NodeId> = doc.node(component).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
|
||||
while let Some(id) = stack.pop() {
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
out.push(id);
|
||||
if out.len() > 5_000_000 {
|
||||
break;
|
||||
}
|
||||
if !repeats(&n.kind) {
|
||||
stack.extend(n.children.iter().rev());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::cycles::{contains_itself_error, would_contain_itself};
|
||||
use super::diff::{binding_group, bindings_of};
|
||||
use super::sync::{insert_linked, sync_instance_depth};
|
||||
use super::{
|
||||
@@ -16,8 +17,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
|
||||
if !matches!(comp.kind, NodeKind::Component { .. }) {
|
||||
return Err(DocError::Invalid(format!("{component} is not a component")));
|
||||
}
|
||||
if doc.is_ancestor(component, parent) {
|
||||
return Err(DocError::Invalid("an instance can't be placed inside its own main component".into()));
|
||||
if would_contain_itself(doc, component, parent) {
|
||||
return Err(DocError::Invalid(contains_itself_error(doc, component)));
|
||||
}
|
||||
let id = doc.alloc_id();
|
||||
let mut root = comp.clone();
|
||||
|
||||
@@ -500,7 +500,7 @@ fn sized_own(n: &Node) -> bool {
|
||||
|
||||
/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
|
||||
/// outwards), each outermost instance once, inner components first.
|
||||
fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
|
||||
pub(super) fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
|
||||
if comps.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::cycles::expansion_layers;
|
||||
use super::diff::diff_groups;
|
||||
use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map};
|
||||
use super::{
|
||||
@@ -23,26 +24,76 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
// Old layers (kept alive for their overrides), by (parent, source), by source alone (when only
|
||||
// one layer mirrors it) and, only when those don't match, by name path.
|
||||
let (mut old, by_source, by_src) = index_old_layers(doc, inst, spare);
|
||||
// Never a copy of itself inside it (components containing each other).
|
||||
let Some(src) = expansion_layers(doc, inst, component) else { return };
|
||||
let Matched { mut map, used, edited, fresh } = match_layers(doc, inst, component, &src, &mut old, &by_source, &by_src);
|
||||
for c in root.children.clone() {
|
||||
let _ = doc.remove(c);
|
||||
}
|
||||
for s in fresh {
|
||||
let nid = doc.alloc_id();
|
||||
map.insert(s, nid);
|
||||
}
|
||||
let Copies { nodes: new_nodes, nested, mut remeasure } = copy_layers(doc, inst, &src, &map, &old, &edited, eff_props);
|
||||
for n in new_nodes {
|
||||
let (id, parent) = (n.id, n.parent);
|
||||
if insert_linked(doc, n).is_err()
|
||||
&& let Some(p) = parent.and_then(|p| doc.node_mut(p))
|
||||
{
|
||||
p.children.retain(|c| *c != id);
|
||||
}
|
||||
}
|
||||
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
|
||||
if let Some(r) = doc.node_mut(inst) {
|
||||
*r = fresh;
|
||||
}
|
||||
// A resized instance: children follow their constraints (auto layout re-flows later).
|
||||
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
|
||||
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
|
||||
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
|
||||
remeasure = map.values().copied().collect();
|
||||
}
|
||||
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
|
||||
remeasure_text(doc, remeasure);
|
||||
rebuild_nested(doc, nested, &old, &used, depth);
|
||||
}
|
||||
|
||||
/// Which instance layer each main layer a rebuild copies becomes.
|
||||
struct Matched {
|
||||
/// Main layer → its copy's id (the matching old layer's, so a layer the main moved to another
|
||||
/// parent keeps its id and overrides); the main itself → the instance.
|
||||
map: BTreeMap<NodeId, NodeId>,
|
||||
/// The old layers reused.
|
||||
used: BTreeSet<NodeId>,
|
||||
/// Slots holding the instance's own content (their main layers' default content isn't copied).
|
||||
edited: BTreeSet<NodeId>,
|
||||
/// Main layers no old layer matches: their copies get new ids.
|
||||
fresh: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// Match the main layers `src` of instance `inst`'s main `component` to its old layers: by
|
||||
/// (parent, source), by source alone (when only one layer mirrors it) and, only when those don't
|
||||
/// match, by name path. Old layers matched across variants drop what the two variants differ in.
|
||||
fn match_layers(
|
||||
doc: &Document,
|
||||
inst: NodeId,
|
||||
component: NodeId,
|
||||
src: &[NodeId],
|
||||
old: &mut BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
by_source: &BTreeMap<(NodeId, NodeId), NodeId>,
|
||||
by_src: &BTreeMap<NodeId, Option<NodeId>>,
|
||||
) -> Matched {
|
||||
let mut by_path: Option<BTreeMap<String, NodeId>> = None;
|
||||
let mut main_paths: Option<BTreeMap<NodeId, String>> = None;
|
||||
// Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
|
||||
// parent keeps its id and overrides).
|
||||
let src: Vec<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
map.insert(component, inst);
|
||||
let mut used: BTreeSet<NodeId> = BTreeSet::new();
|
||||
// Slots holding the instance's own content (new ids), and main layers not copied: the
|
||||
// default content of those slots.
|
||||
let mut edited: BTreeSet<NodeId> = BTreeSet::new();
|
||||
let mut m = Matched { map: BTreeMap::from([(component, inst)]), used: BTreeSet::new(), edited: BTreeSet::new(), fresh: Vec::new() };
|
||||
let mut skipped: BTreeSet<NodeId> = BTreeSet::new();
|
||||
let mut fresh_ids = Vec::new();
|
||||
for s in &src {
|
||||
for s in src {
|
||||
let Some(sn) = doc.node(*s) else { continue };
|
||||
if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) {
|
||||
if sn.parent.is_some_and(|p| skipped.contains(&p) || m.map.get(&p).is_some_and(|pn| m.edited.contains(pn))) {
|
||||
skipped.insert(*s);
|
||||
continue;
|
||||
}
|
||||
let parent_new = sn.parent.and_then(|p| map.get(&p).copied());
|
||||
let parent_new = sn.parent.and_then(|p| m.map.get(&p).copied());
|
||||
let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten());
|
||||
if found.is_none() {
|
||||
let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect());
|
||||
@@ -50,37 +101,47 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
found = mains.get(s).and_then(|p| paths.get(p)).copied();
|
||||
}
|
||||
let nid = match found {
|
||||
Some(o) if used.insert(o) => o,
|
||||
Some(o) if m.used.insert(o) => o,
|
||||
_ => {
|
||||
fresh_ids.push(*s);
|
||||
m.fresh.push(*s);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
|
||||
old.insert(nid, std::sync::Arc::new(m));
|
||||
if let Some(kept) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
|
||||
old.insert(nid, std::sync::Arc::new(kept));
|
||||
}
|
||||
if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) {
|
||||
edited.insert(nid);
|
||||
m.edited.insert(nid);
|
||||
}
|
||||
map.insert(*s, nid);
|
||||
m.map.insert(*s, nid);
|
||||
}
|
||||
for c in root.children.clone() {
|
||||
let _ = doc.remove(c);
|
||||
}
|
||||
for s in fresh_ids {
|
||||
let nid = doc.alloc_id();
|
||||
map.insert(s, nid);
|
||||
}
|
||||
// Build the copies (pre-order: parents first).
|
||||
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
|
||||
enclosing.insert(inst, vec![inst]);
|
||||
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::new();
|
||||
inst_props.insert(inst, eff_props);
|
||||
let mut nested = Vec::new();
|
||||
let mut new_nodes = Vec::new();
|
||||
// Text layers showing other text or style than their main layer (to re-measure).
|
||||
let mut remeasure = Vec::new();
|
||||
for s in &src {
|
||||
m
|
||||
}
|
||||
|
||||
/// A rebuild's copies of the main's layers, pre-order (parents first).
|
||||
struct Copies {
|
||||
nodes: Vec<Node>,
|
||||
/// Nested instances showing another main than their main layer (swapped): rebuilt from it.
|
||||
nested: Vec<NodeId>,
|
||||
/// Text layers showing other text or style than their main layer (to re-measure).
|
||||
remeasure: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// Copy main layers `src` into instance `inst` as `map` names them, with the overrides of the
|
||||
/// `old` layers they replace and the component property values (`props`: the instance's).
|
||||
fn copy_layers(
|
||||
doc: &Document,
|
||||
inst: NodeId,
|
||||
src: &[NodeId],
|
||||
map: &BTreeMap<NodeId, NodeId>,
|
||||
old: &BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
edited: &BTreeSet<NodeId>,
|
||||
props: BTreeMap<String, Value>,
|
||||
) -> Copies {
|
||||
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::from([(inst, vec![inst])]);
|
||||
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::from([(inst, props)]);
|
||||
let mut out = Copies { nodes: Vec::new(), nested: Vec::new(), remeasure: Vec::new() };
|
||||
for s in src {
|
||||
let Some(orig) = doc.node(*s) else { continue };
|
||||
let Some(&nid) = map.get(s) else { continue };
|
||||
let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue };
|
||||
@@ -113,7 +174,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
e.push(nid);
|
||||
enclosing.insert(nid, e);
|
||||
if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) {
|
||||
nested.push(nid);
|
||||
out.nested.push(nid);
|
||||
}
|
||||
} else {
|
||||
enclosing.insert(nid, enc);
|
||||
@@ -121,31 +182,11 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind)
|
||||
&& a != b
|
||||
{
|
||||
remeasure.push(nid);
|
||||
out.remeasure.push(nid);
|
||||
}
|
||||
new_nodes.push(n);
|
||||
out.nodes.push(n);
|
||||
}
|
||||
for n in new_nodes {
|
||||
let (id, parent) = (n.id, n.parent);
|
||||
if insert_linked(doc, n).is_err()
|
||||
&& let Some(p) = parent.and_then(|p| doc.node_mut(p))
|
||||
{
|
||||
p.children.retain(|c| *c != id);
|
||||
}
|
||||
}
|
||||
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
|
||||
if let Some(r) = doc.node_mut(inst) {
|
||||
*r = fresh;
|
||||
}
|
||||
// A resized instance: children follow their constraints (auto layout re-flows later).
|
||||
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
|
||||
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
|
||||
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
|
||||
remeasure = map.values().copied().collect();
|
||||
}
|
||||
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
|
||||
remeasure_text(doc, remeasure);
|
||||
rebuild_nested(doc, nested, &old, &used, depth);
|
||||
out
|
||||
}
|
||||
|
||||
/// Geometry overrides of children win over constraints.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the
|
||||
//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -57,6 +57,7 @@ pub fn refuse_unknown_step_keys(step: usize, json: &Value) -> Result<()> {
|
||||
pub struct BatchScope {
|
||||
names: HashMap<String, usize>,
|
||||
results: Vec<Value>,
|
||||
failed: HashSet<usize>,
|
||||
}
|
||||
|
||||
impl BatchScope {
|
||||
@@ -69,10 +70,10 @@ impl BatchScope {
|
||||
return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name")));
|
||||
}
|
||||
}
|
||||
Ok(Self { names, results: Vec::with_capacity(steps.len()) })
|
||||
Ok(Self { names, results: Vec::with_capacity(steps.len()), failed: HashSet::new() })
|
||||
}
|
||||
|
||||
/// The results of the steps run so far, in order.
|
||||
/// The results of the steps run so far, in order (failed slots hold null).
|
||||
pub fn results(&self) -> &[Value] {
|
||||
&self.results
|
||||
}
|
||||
@@ -90,6 +91,12 @@ impl BatchScope {
|
||||
self.results.push(result);
|
||||
}
|
||||
|
||||
pub(crate) fn push_failed(&mut self) {
|
||||
// Keep positions even after a failure; a successful null result is not a failed step.
|
||||
self.failed.insert(self.next_step());
|
||||
self.results.push(Value::Null);
|
||||
}
|
||||
|
||||
/// `params` of the next step with every reference in it replaced.
|
||||
pub(crate) fn resolve(&self, params: &Value) -> Result<Value> {
|
||||
self.resolve_in(params, 0)
|
||||
@@ -137,6 +144,9 @@ impl BatchScope {
|
||||
}
|
||||
(n, format!("step {n} (\"{name}\")"))
|
||||
};
|
||||
if self.failed.contains(&n) {
|
||||
return Err(fail(format!("references {who}, which failed")));
|
||||
}
|
||||
let path = match o.get("field") {
|
||||
None => "id",
|
||||
Some(Value::String(f)) => f.as_str(),
|
||||
|
||||
@@ -344,6 +344,7 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
|
||||
let pages: Vec<Value> =
|
||||
doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect();
|
||||
json!({
|
||||
"id": ds.uid,
|
||||
"name": doc.name,
|
||||
"kind": doc.kind.id(),
|
||||
"path": ds.path,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! A batch that goes on after a failed step (`onError: "continue"`) reports the live events of the
|
||||
//! steps that stayed, not of the ones rolled back.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn a_continued_batch_reports_only_the_events_of_steps_that_stayed() {
|
||||
let mut s = Session::new();
|
||||
let before = s.events.latest();
|
||||
let r = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "comment.add", "params": {"x": 10, "y": 10, "text": "Stays"}},
|
||||
{"command": "command.batch", "params": {"commands": [
|
||||
{"command": "comment.add", "params": {"x": 20, "y": 20, "text": "Rolled back"}},
|
||||
{"command": "node.set", "params": {"ids": [999_999], "props": {"name": "x"}}}
|
||||
]}},
|
||||
{"command": "comment.add", "params": {"x": 30, "y": 30, "text": "Stays too"}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let ok: Vec<bool> = r.as_array().unwrap().iter().map(|o| o["ok"].as_bool().unwrap()).collect();
|
||||
assert_eq!(ok, [true, false, true]);
|
||||
let texts: Vec<String> = s.doc().unwrap().comments.iter().map(|c| c.first.text.clone()).collect();
|
||||
assert_eq!(texts, ["Stays", "Stays too"]);
|
||||
let events = s.events.poll(before, &["comments".into()], 10, Duration::ZERO).events;
|
||||
assert_eq!(events.iter().map(|e| e.kind.as_str()).collect::<Vec<_>>(), ["comment.added", "comment.added"]);
|
||||
}
|
||||
@@ -280,3 +280,375 @@ fn a_failing_command_names_its_step_and_rolls_back() {
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_can_undo_then_redo_between_steps() {
|
||||
for mode in ["abort", "continue"] {
|
||||
let mut s = Session::new();
|
||||
let rect = id(&s.execute("node.create", &json!({"type": "rectangle"})).unwrap());
|
||||
let before = s.doc().unwrap().clone();
|
||||
let results =
|
||||
s.execute("command.batch", &json!({"onError": mode, "commands": [{"command": "edit.undo"}, {"command": "edit.redo"}]})).unwrap();
|
||||
let undo = if mode == "continue" { &results[0]["result"] } else { &results[0] };
|
||||
let redo = if mode == "continue" { &results[1]["result"] } else { &results[1] };
|
||||
assert_eq!(undo["undone"], true, "{mode}: {results}");
|
||||
assert_eq!(redo["redone"], true, "{mode}: {results}");
|
||||
assert!(s.doc().unwrap().contains(rect));
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_can_open_a_document_after_closing_the_last_tab() {
|
||||
let mut s = Session::new();
|
||||
let uid = s.ds().unwrap().uid;
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "file.close", "params": {"force": true}},
|
||||
{"command": "file.new"},
|
||||
{"command": "page.rename", "params": {"name": "New page"}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(results.as_array().unwrap().iter().all(|r| r["ok"] == true), "{results}");
|
||||
assert_eq!(s.docs.len(), 1);
|
||||
assert_ne!(s.ds().unwrap().uid, uid);
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "New page");
|
||||
assert!(s.closed_in_group.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_repeats_the_last_successful_command_after_a_failed_step() {
|
||||
let mut s = Session::new();
|
||||
s.execute("node.create", &json!({"type": "rectangle"})).unwrap();
|
||||
let last = s.last_command.clone();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "command.batch", "params": {"commands": [
|
||||
{"command": "node.create", "params": {"type": "text", "text": "Rolled back"}},
|
||||
{"command": "no.such.command"}
|
||||
]}},
|
||||
{"command": "edit.repeat"}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(results[0]["ok"], false);
|
||||
assert_eq!(results[1]["ok"], true);
|
||||
let repeated = s.doc().unwrap().node(id(&results[1]["result"])).unwrap();
|
||||
assert!(matches!(repeated.kind, figcraft_doc::NodeKind::Rectangle), "{repeated:?}");
|
||||
assert_eq!(s.last_command, last);
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_keeps_successful_steps_as_one_undo_entry() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(&s.execute("node.create", &json!({"type": "frame", "width": 50, "height": 50})).unwrap());
|
||||
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
|
||||
assert_eq!((frame.0, text.0), (2, 3));
|
||||
let undo = s.undo_depth().0;
|
||||
let name = s.doc().unwrap().node(s.page().unwrap()).unwrap().name.clone();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"label": "Continue", "onError": "continue", "commands": [
|
||||
{"command": "text.set", "params": {"ids": [3], "text": "novo"}},
|
||||
{"command": "text.set", "params": {"ids": [2], "text": "erro"}},
|
||||
{"command": "page.rename", "params": {"name": "Depois do erro"}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(results.as_array().unwrap().len(), 3);
|
||||
assert_eq!(results[0], json!({"step": 0, "command": "text.set", "ok": true, "result": {"ok": true}}));
|
||||
assert_eq!(results[2], json!({"step": 2, "command": "page.rename", "ok": true, "result": {"ok": true}}));
|
||||
assert_eq!(results[1]["step"], 1);
|
||||
assert_eq!(results[1]["command"], "text.set");
|
||||
assert_eq!(results[1]["ok"], false);
|
||||
let error = results[1]["error"].as_str().unwrap();
|
||||
assert!(error.contains("step 1:") && error.contains("is not text"), "{error}");
|
||||
assert!(results[1].get("result").is_none());
|
||||
undo_and_redo_the_continued_batch(&mut s, frame, text, &name, undo);
|
||||
}
|
||||
|
||||
/// The successful steps of the batch above undo and redo as one step.
|
||||
fn undo_and_redo_the_continued_batch(s: &mut Session, frame: figcraft_doc::NodeId, text: figcraft_doc::NodeId, name: &str, undo: usize) {
|
||||
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo"));
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro");
|
||||
assert_eq!(s.undo_depth(), (undo + 1, 0));
|
||||
assert_eq!(s.undo_labels(1), ["Continue"]);
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"));
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
|
||||
assert!(s.doc().unwrap().contains(frame), "undo leaves earlier edits alone");
|
||||
assert_eq!(s.undo_depth(), (undo, 1));
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo"));
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro");
|
||||
assert_eq!(s.undo_depth(), (undo + 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_rolls_back_partial_steps_and_keeps_reference_positions() {
|
||||
let mut s = Session::new();
|
||||
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
|
||||
let frame = id(&s.execute("node.create", &json!({"type": "frame"})).unwrap());
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "text.set", "as": "bad", "params": {"ids": [text.0, frame.0], "text": "partial"}},
|
||||
{"command": "node.create", "as": "card", "params": {"type": "frame"}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "bad"}}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 1}}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}}},
|
||||
{"command": "node.set", "params": {"ids": [{"$step": 4}], "props": {"name": "Child"}}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(results.as_array().unwrap().len(), 7);
|
||||
for (i, result) in results.as_array().unwrap().iter().enumerate() {
|
||||
assert_eq!(result["step"], i);
|
||||
assert_eq!(result["ok"], ![0, 2, 3].contains(&i));
|
||||
}
|
||||
assert!(results[2]["error"].as_str().unwrap().contains("step 2: references step 0, which failed"));
|
||||
assert!(results[3]["error"].as_str().unwrap().contains("step 3: references step 0 (\"bad\"), which failed"));
|
||||
assert!(
|
||||
matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"),
|
||||
"the failed step's partial edit was rolled back"
|
||||
);
|
||||
let card = id(&results[1]["result"]);
|
||||
for i in [4, 5] {
|
||||
assert_eq!(s.doc().unwrap().node(id(&results[i]["result"])).unwrap().parent, Some(card), "references use raw results, not outcomes");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().node(id(&results[4]["result"])).unwrap().name, "Child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_batch_policies_run_nothing() {
|
||||
for mode in [json!("skip"), json!(""), json!(true), json!(3), json!(["continue"]), json!({})] {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let journal = s.journal.clone();
|
||||
let error = s
|
||||
.execute("command.batch", &json!({"onError": mode, "commands": [{"command": "page.rename", "params": {"name": "Changed"}}]}))
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, EngineError::BadParams { .. }));
|
||||
assert!(error.to_string().contains("`onError` must be \"abort\" or \"continue\""), "{error}");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.journal, journal);
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_batch_structure_runs_nothing_in_either_mode() {
|
||||
let rename = json!({"command": "page.rename", "params": {"name": "Changed"}});
|
||||
for mode in ["abort", "continue"] {
|
||||
for commands in [
|
||||
json!(null),
|
||||
json!({}),
|
||||
json!([rename, null]),
|
||||
json!([rename, {}]),
|
||||
json!([rename, {"command": 3}]),
|
||||
json!([rename, {"command": "page.rename", "as": ""}]),
|
||||
json!([rename, {"command": "page.rename", "as": 3}]),
|
||||
json!([{"command": "page.rename", "as": "same", "params": {"name": "Changed"}}, {"command": "select.none", "as": "same"}]),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let journal = s.journal.clone();
|
||||
let result = s.execute("command.batch", &json!({"onError": mode, "commands": commands}));
|
||||
assert!(matches!(result, Err(EngineError::BadParams { .. })), "{mode}: {result:?}");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.journal, journal);
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_abort_batch_keeps_the_default_results_and_errors() {
|
||||
let commands = json!([
|
||||
{"command": "node.create", "params": {"type": "frame"}},
|
||||
{"command": "node.create", "params": {"type": "text", "text": "orig"}}
|
||||
]);
|
||||
let mut default = Session::new();
|
||||
let mut abort = Session::new();
|
||||
let raw = default.execute("command.batch", &json!({"commands": commands})).unwrap();
|
||||
assert_eq!(abort.execute("command.batch", &json!({"onError": "abort", "commands": commands})).unwrap(), raw);
|
||||
assert!(raw[0].get("id").is_some() && raw[0].get("ok").is_none(), "still raw results");
|
||||
let commands = json!([
|
||||
{"command": "text.set", "params": {"ids": [3], "text": "novo"}},
|
||||
{"command": "text.set", "params": {"ids": [2], "text": "erro"}},
|
||||
{"command": "page.rename", "params": {"name": "Depois do erro"}}
|
||||
]);
|
||||
let before = abort.doc().unwrap().clone();
|
||||
let undo = abort.undo_depth();
|
||||
let error = default.execute("command.batch", &json!({"commands": commands})).unwrap_err();
|
||||
let explicit = abort.execute("command.batch", &json!({"onError": "abort", "commands": commands})).unwrap_err();
|
||||
assert_eq!(explicit.to_string(), error.to_string());
|
||||
assert!(matches!(explicit, EngineError::BadParams { .. }));
|
||||
assert_eq!(abort.doc().unwrap(), &before);
|
||||
assert_eq!(abort.undo_depth(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_batch_error_policies_are_local() {
|
||||
for outer in ["abort", "continue"] {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": outer, "commands": [
|
||||
{"command": "command.batch", "as": "inner", "params": {"onError": "continue", "commands": [
|
||||
{"command": "node.create", "as": "card", "params": {"type": "frame"}},
|
||||
{"command": "no.such.command"},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}}}
|
||||
]}},
|
||||
{"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "2/result/id"}], "props": {"name": "Inner child"}}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let inner = if outer == "continue" { &results[0]["result"] } else { &results[0] };
|
||||
assert_eq!(inner[1]["ok"], false);
|
||||
let child = s.doc().unwrap().node(id(&inner[2]["result"])).unwrap();
|
||||
assert_eq!(child.name, "Inner child");
|
||||
assert_eq!(child.parent, Some(id(&inner[0]["result"])));
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), 0);
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(id(&inner[2]["result"])));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outer_continue_batch_rolls_back_a_failed_inner_abort_batch() {
|
||||
for explicit in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let mut inner = json!({"commands": [
|
||||
{"command": "file.close", "params": {"force": true}},
|
||||
{"command": "file.new", "params": {"name": "Temporary"}},
|
||||
{"command": "node.create", "params": {"type": "frame"}},
|
||||
{"command": "no.such.command"}
|
||||
]});
|
||||
if explicit {
|
||||
inner["onError"] = json!("abort");
|
||||
}
|
||||
let uid = s.ds().unwrap().uid;
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "page.rename", "params": {"name": "Before"}},
|
||||
{"command": "command.batch", "params": inner},
|
||||
{"command": "page.rename", "params": {"name": "After"}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(results[1]["ok"], false);
|
||||
assert!(results[1]["error"].as_str().unwrap().contains("unknown command"));
|
||||
assert_eq!(results[2]["ok"], true);
|
||||
assert_eq!(s.docs.len(), 1);
|
||||
assert_eq!(s.ds().unwrap().uid, uid);
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "After");
|
||||
assert!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.is_empty());
|
||||
assert!(s.closed_in_group.is_empty());
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outer_abort_batch_rolls_back_successes_of_an_inner_continue_batch() {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({"commands": [
|
||||
{"command": "command.batch", "params": {"onError": "continue", "commands": [
|
||||
{"command": "node.create", "params": {"type": "frame"}},
|
||||
{"command": "no.such.command"}
|
||||
]}},
|
||||
{"command": "no.such.command"}
|
||||
]}),
|
||||
);
|
||||
assert!(matches!(result, Err(EngineError::UnknownCommand(_))));
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_all_failed_continue_batch_adds_no_undo_and_keeps_redo() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(&s.execute("node.create", &json!({"type": "frame"})).unwrap());
|
||||
s.execute("page.rename", &json!({"name": "Redo name"})).unwrap();
|
||||
assert!(s.undo().unwrap());
|
||||
let depth = s.undo_depth();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "node.set", "params": {"ids": [frame.0, 999999], "props": {"name": "Partial"}}},
|
||||
{"command": "no.such.command"},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(results.as_array().unwrap().iter().all(|r| r["ok"] == false));
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
assert!(s.redo().unwrap());
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Redo name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_continue_batch_returns_an_empty_array_without_undo() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute("command.batch", &json!({"onError": "continue", "commands": []})).unwrap(), json!([]));
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_null_batch_result_is_not_a_failed_slot() {
|
||||
let mut scope = crate::BatchScope::default();
|
||||
scope.push(serde_json::Value::Null);
|
||||
scope.push_failed();
|
||||
let null = scope.resolve(&json!({"$step": 0})).unwrap_err().to_string();
|
||||
assert!(null.contains("step 0's result has no `id`") && !null.contains("which failed"), "{null}");
|
||||
let failed = scope.resolve(&json!({"$step": 1})).unwrap_err().to_string();
|
||||
assert!(failed.contains("step 2: references step 1, which failed"), "{failed}");
|
||||
assert_eq!(scope.next_step(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_reports_runtime_errors_and_runs_later_steps() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "no.such.command"},
|
||||
{"command": "node.create", "params": {"type": "no-such-type"}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "missing"}}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 99}}},
|
||||
{"command": "node.create", "params": {"type": "frame"}},
|
||||
{"command": "node.set", "params": {"ids": [{"$step": 4, "field": "missing"}], "props": {"name": "No"}}},
|
||||
{"command": "page.rename", "params": {"name": "Done"}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(results.as_array().unwrap().len(), 7);
|
||||
for (i, needle) in [(0, "unknown command"), (1, "unknown type"), (2, "names no step"), (3, "earlier step"), (5, "has no `missing`")] {
|
||||
assert_eq!(results[i]["ok"], false);
|
||||
assert!(results[i]["error"].as_str().unwrap().contains(needle), "{}", results[i]);
|
||||
}
|
||||
assert_eq!(results[4]["ok"], true);
|
||||
assert_eq!(results[6]["ok"], true);
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Done");
|
||||
}
|
||||
|
||||
@@ -177,3 +177,76 @@ fn reattach_survives_junk() {
|
||||
assert!(matches!(node(&s, k).board, Some(BoardItem::Connector(_))));
|
||||
assert!(drawn(&s, k).iter().all(|p| p.x.is_finite() && p.y.is_finite()));
|
||||
}
|
||||
|
||||
/// The connector on the page, if any (the one being drawn shows in the document).
|
||||
fn the_connector(s: &Session) -> Option<figcraft_doc::Node> {
|
||||
s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_connector_being_drawn_is_the_one_made() {
|
||||
for style in ["straight", "elbow", "curved"] {
|
||||
for to in [Point::new(350.0, 250.0), Point::new(700.0, 600.0)] {
|
||||
let mut s = board();
|
||||
let mut shape = |x: f64, y: f64| id(&s.execute("board.shape", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(), "id");
|
||||
let (_, b) = (shape(0.0, 0.0), shape(300.0, 200.0));
|
||||
s.execute("tool.connector", &json!({"style": style})).unwrap();
|
||||
let undo = s.undo_depth().0;
|
||||
let ev = |k, p: Point| PointerEvent::at(k, p.x, p.y, 1.0);
|
||||
s.pointer(&ev(PointerKind::Down, Point::new(50.0, 50.0))).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, Point::new(200.0, 150.0))).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, to)).unwrap();
|
||||
let drawing = the_connector(&s).unwrap_or_else(|| panic!("{style}: the connector shows while dragged"));
|
||||
let ov = &s.tool.overlay;
|
||||
if to.x < 400.0 {
|
||||
// Over b: its four attach points, the one the end is at lit, and b outlined.
|
||||
assert_eq!(ov.drop_target, Some(b));
|
||||
let lit: Vec<[f64; 2]> = ov.attach.iter().filter(|a| a.1).map(|a| a.0).collect();
|
||||
let end = *drawn(&s, drawing.id).last().unwrap();
|
||||
assert_eq!((ov.attach.len(), lit), (4, vec![[end.x, end.y]]), "{style}");
|
||||
} else {
|
||||
assert!(ov.attach.is_empty() && ov.drop_target.is_none(), "a free end");
|
||||
}
|
||||
assert!(s.tool.overlay.rubber.is_none(), "no rubber band: the connector itself shows");
|
||||
s.pointer(&ev(PointerKind::Up, to)).unwrap();
|
||||
let made = the_connector(&s).unwrap();
|
||||
assert_eq!(made.kind, drawing.kind, "{style} to {to:?}: the same path");
|
||||
assert_eq!((made.transform, made.width, made.height), (drawing.transform, drawing.width, drawing.height));
|
||||
assert_eq!((&made.strokes, &made.stroke, &made.board), (&drawing.strokes, &drawing.stroke, &drawing.board), "line, caps and ends");
|
||||
assert_eq!(s.undo_depth().0, undo + 1, "one undo step");
|
||||
assert!(s.tool.overlay.attach.is_empty());
|
||||
}
|
||||
}
|
||||
// Escape mid-drag: the preview goes and nothing is made.
|
||||
let mut s = board();
|
||||
s.execute("tool.connector", &json!({})).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, 200.0, 0.0, 1.0)).unwrap();
|
||||
assert!(the_connector(&s).is_some());
|
||||
s.escape().unwrap();
|
||||
assert!(the_connector(&s).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() {
|
||||
let mut s = board();
|
||||
let mut shape = |x: f64, y: f64| id(&s.execute("board.shape", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(), "id");
|
||||
let (a, b) = (shape(0.0, 0.0), shape(300.0, 200.0));
|
||||
s.execute("tool.connector", &json!({"style": "straight"})).unwrap();
|
||||
let ev = |k, x, y| PointerEvent::at(k, x, y, 1.0);
|
||||
// Hovering: the object's attach points, lit only near one.
|
||||
s.pointer(&ev(PointerKind::Hover, 50.0, 50.0)).unwrap();
|
||||
assert_eq!(s.tool.overlay.attach.len(), 4);
|
||||
assert!(s.tool.overlay.attach.iter().all(|a| !a.1));
|
||||
s.pointer(&ev(PointerKind::Hover, 50.0, 3.0)).unwrap();
|
||||
assert_eq!(s.tool.overlay.attach.iter().filter(|a| a.1).map(|a| a.0).collect::<Vec<_>>(), vec![[50.0, 0.0]]);
|
||||
s.pointer(&ev(PointerKind::Hover, 900.0, 900.0)).unwrap();
|
||||
assert!(s.tool.overlay.attach.is_empty());
|
||||
// From near a's right middle to near b's top middle: those sides, kept.
|
||||
s.pointer(&ev(PointerKind::Down, 97.0, 50.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, 352.0, 204.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, 352.0, 204.0)).unwrap();
|
||||
let k = the_connector(&s).unwrap().id;
|
||||
assert_eq!((connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Right }, End::Node { node: b, side: Side::Top }));
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ fn the_shape_stamp_highlighter_and_connector_tools_and_double_clicks() {
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 60.0, 120.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 140.0, 1.0)).unwrap();
|
||||
assert!(s.tool.overlay.rubber.is_some(), "the rubber band shows while dragging");
|
||||
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))), "nothing previewed: none would be made");
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 140.0, 1.0)).unwrap();
|
||||
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))));
|
||||
// From the shape out to empty canvas: a connector to a free point.
|
||||
|
||||
@@ -48,11 +48,26 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => routing_of(cmd, &v)?,
|
||||
None => s.tool.board.routing,
|
||||
};
|
||||
let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None);
|
||||
let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow);
|
||||
let c = Connector { from, to, routing };
|
||||
let start = cap_of(cmd, p, "startCap")?.unwrap_or(CAPS.0);
|
||||
let end = cap_of(cmd, p, "endCap")?.unwrap_or(CAPS.1);
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
let id = make(doc, page, Connector { from, to, routing }, (start, end))?;
|
||||
let mut out = json!({"id": id});
|
||||
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
|
||||
out["label"] = json!(set_label(doc, id, &text)?);
|
||||
}
|
||||
s.set_selection(vec![id]);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A new connector's (start, end) caps.
|
||||
pub(super) const CAPS: (StrokeCap, StrokeCap) = (StrokeCap::None, StrokeCap::TriangleArrow);
|
||||
|
||||
/// Connector `c` added to `page` with its line, `caps` (start, end) and route: what
|
||||
/// `board.connector` makes, and the connector tool shows while it is dragged.
|
||||
pub(super) fn make(doc: &mut Document, page: NodeId, c: Connector, caps: (StrokeCap, StrokeCap)) -> Result<NodeId> {
|
||||
let cmd = "board.connector";
|
||||
let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?;
|
||||
if pts.windows(2).all(|w| w.first() == w.get(1)) {
|
||||
return Err(bad(cmd, "the ends are in the same place"));
|
||||
@@ -62,17 +77,11 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.strokes = vec![Paint::solid(LINE)];
|
||||
n.stroke.weight = LINE_WEIGHT;
|
||||
n.stroke.join = StrokeJoin::Round;
|
||||
n.stroke.cap_start = start;
|
||||
n.stroke.cap_end = end;
|
||||
(n.stroke.cap_start, n.stroke.cap_end) = caps;
|
||||
n.board = Some(BoardItem::Connector(c));
|
||||
})?;
|
||||
place_route(doc, id, &pts, routing);
|
||||
let mut out = json!({"id": id});
|
||||
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
|
||||
out["label"] = json!(set_label(doc, id, &text)?);
|
||||
}
|
||||
s.set_selection(vec![id]);
|
||||
Ok(out)
|
||||
place_route(doc, id, &pts, c.routing);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The connector `id` names: a connector, or the label on one.
|
||||
@@ -121,6 +130,14 @@ pub(super) fn end_json(e: End) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// The side a connector end names, as the commands take it (none for a free point).
|
||||
pub(super) fn side_json(e: End) -> Value {
|
||||
match e {
|
||||
End::Node { side, .. } => json!(side),
|
||||
End::Point { .. } => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of connector `id` that stays while its other one (`from`: its start) is dragged.
|
||||
pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option<End> {
|
||||
match &doc.node(id)?.board {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
use super::connectors::{end_json, other_end};
|
||||
use super::derived::ends_now;
|
||||
use super::connectors::{CAPS, end_json, make, other_end, side_json};
|
||||
use super::derived::{ends_now, route};
|
||||
use super::edit::label_of;
|
||||
use super::items::edit_text;
|
||||
use super::{
|
||||
BoardItem, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId, id_of,
|
||||
is_board, json,
|
||||
BoardItem, Connector, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId,
|
||||
id_of, is_board, json,
|
||||
};
|
||||
|
||||
/// How near (screen pixels) the pointer must be to the middle of an object's side for a connector
|
||||
/// end to attach there rather than to the side facing the other end.
|
||||
const ATTACH_PX: f64 = 10.0;
|
||||
/// The sides an object takes a connector end at (their middles are its attach points).
|
||||
const SIDES: [Side; 4] = [Side::Top, Side::Right, Side::Bottom, Side::Left];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The board tools.
|
||||
|
||||
@@ -51,7 +57,8 @@ impl Session {
|
||||
ToolId::Connector => {
|
||||
let from = self.end_at(ev);
|
||||
self.tool.gesture = Gesture::Connect { from, start: ev.p() };
|
||||
Ok(())
|
||||
// While it is dragged the connector shows as it will be made (a preview).
|
||||
self.begin_interaction("Connector")
|
||||
}
|
||||
ToolId::Eraser => {
|
||||
self.begin_interaction("Erase")?;
|
||||
@@ -69,47 +76,87 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a connector end at the pointer attaches to: the object there, or the point.
|
||||
/// What a connector end at the pointer attaches to: the object there (at the side whose middle
|
||||
/// is within [`ATTACH_PX`], else the side facing the other end), or the point.
|
||||
fn end_at(&self, ev: &PointerEvent) -> End {
|
||||
let zoom = ev.zoom.max(1e-6);
|
||||
let hit = (|| {
|
||||
let doc = self.doc().ok()?;
|
||||
let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?;
|
||||
let ds = self.ds().ok()?;
|
||||
// Under the connector being dragged out (previewed in the document), not on it.
|
||||
let doc = ds.interaction.as_ref().map_or(&*ds.doc, |it| &*it.doc);
|
||||
let leaf = self.hit(doc, ds.page, ev.p(), 2.0 / zoom)?;
|
||||
let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false);
|
||||
let n = doc.node(t)?;
|
||||
(!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t)
|
||||
if matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink)) {
|
||||
return None;
|
||||
}
|
||||
let b = doc.world_bounds(t);
|
||||
let far = |s: &Side| b.map(|b| (s.point(b) - ev.p()).hypot() * zoom).filter(|d| *d <= ATTACH_PX);
|
||||
let side = SIDES.iter().filter_map(|s| Some((*s, far(s)?))).min_by(|x, y| x.1.total_cmp(&y.1)).map_or(Side::Auto, |(s, _)| s);
|
||||
Some(End::Node { node: t, side })
|
||||
})();
|
||||
match hit {
|
||||
Some(node) => End::Node { node, side: Side::Auto },
|
||||
None => End::Point { x: ev.x, y: ev.y },
|
||||
}
|
||||
hit.unwrap_or(End::Point { x: ev.x, y: ev.y })
|
||||
}
|
||||
|
||||
pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) {
|
||||
let a = match from {
|
||||
End::Point { x, y } => Point::new(x, y),
|
||||
End::Node { .. } => start,
|
||||
/// Where a connector dragged from `from` (pressed at `start`) to the pointer ends: none for a
|
||||
/// click, or back on the object it started from (nothing is made).
|
||||
fn drag_end(&self, ev: &PointerEvent, from: End, start: Point) -> Option<End> {
|
||||
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
|
||||
return None;
|
||||
}
|
||||
let to = self.end_at(ev);
|
||||
(to.node().is_none() || to.node() != from.node()).then_some(to)
|
||||
}
|
||||
|
||||
/// The object `end` is on (if any) outlined, with its attach points; the one the end is at
|
||||
/// (`at`, else its named side's) lit.
|
||||
fn show_attach(&mut self, end: Option<End>, at: Option<Point>) {
|
||||
let attach = match end {
|
||||
Some(End::Node { node, side }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| {
|
||||
let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b)));
|
||||
SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec()
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]);
|
||||
self.tool.overlay.drop_target = from.node();
|
||||
self.tool.overlay.attach = attach.unwrap_or_default();
|
||||
self.tool.overlay.drop_target = end.and_then(|e| e.node());
|
||||
}
|
||||
|
||||
/// The pointer moving with the connector tool and no button down: the attach points under it.
|
||||
pub(crate) fn connector_hover(&mut self, ev: &PointerEvent) {
|
||||
let end = self.doc().is_ok_and(is_board).then(|| self.end_at(ev));
|
||||
self.show_attach(end, None);
|
||||
}
|
||||
|
||||
/// The connector being dragged out, drawn as it will be made (a preview) to the attach point
|
||||
/// or free point under the pointer.
|
||||
pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) {
|
||||
self.tool.gesture = Gesture::Connect { from, start };
|
||||
let c = self.drag_end(ev, from, start).map(|to| Connector { from, to, routing: self.tool.board.routing });
|
||||
if let Ok(page) = self.page() {
|
||||
// One that can't be placed shows nothing, as none would be made.
|
||||
let _ = self.preview(|d, _| {
|
||||
if let Some(c) = c {
|
||||
let _ = make(d, page, c, CAPS);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let at = c.and_then(|c| route(self.doc().ok()?, &c)).and_then(|pts| pts.last().copied());
|
||||
self.show_attach(c.map(|c| c.to), at);
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> {
|
||||
self.tool.overlay.rubber = None;
|
||||
self.tool.overlay.drop_target = None;
|
||||
// A click, not a drag: nothing to join.
|
||||
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let to = self.end_at(ev);
|
||||
if to.node().is_some() && to.node() == from.node() {
|
||||
return Ok(());
|
||||
}
|
||||
self.show_attach(None, None);
|
||||
// The preview goes: the command makes the connector, one undo step.
|
||||
self.cancel_interaction()?;
|
||||
let Some(to) = self.drag_end(ev, from, start) else { return Ok(()) };
|
||||
let style = self.tool.board.routing.id();
|
||||
self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?;
|
||||
let p = json!({"from": end_json(from), "fromSide": side_json(from), "to": end_json(to), "toSide": side_json(to), "style": style});
|
||||
self.execute("board.connector", &p)?;
|
||||
self.board_placed();
|
||||
Ok(())
|
||||
}
|
||||
@@ -122,7 +169,8 @@ impl Session {
|
||||
let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a });
|
||||
let other = other_end(doc, id, from).and_then(|e| e.node());
|
||||
self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]);
|
||||
self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other);
|
||||
let target = Some(self.end_at(ev)).filter(|e| e.node().is_none() || e.node() != other);
|
||||
self.show_attach(target, None);
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
}
|
||||
@@ -132,6 +180,7 @@ impl Session {
|
||||
/// A click, or a drop on the object the other end is on, changes nothing.
|
||||
pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> {
|
||||
self.tool.overlay.rubber = None;
|
||||
self.show_attach(None, None);
|
||||
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -140,7 +189,8 @@ impl Session {
|
||||
if target.node().is_some() && target.node() == other {
|
||||
return Ok(());
|
||||
}
|
||||
self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?;
|
||||
let end = if from { "from" } else { "to" };
|
||||
self.execute("board.reattach", &json!({"id": id, "end": end, "target": end_json(target), "side": side_json(target)}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -189,6 +189,24 @@ impl Targets {
|
||||
self.boxes.push(r);
|
||||
}
|
||||
|
||||
/// Take back what [`Targets::add_box`] added for `r`.
|
||||
pub fn remove_box(&mut self, r: Rect) {
|
||||
let take = |v: &mut Vec<Span>, s: Span| {
|
||||
if let Some(i) = v.iter().rposition(|x| *x == s) {
|
||||
v.remove(i);
|
||||
}
|
||||
};
|
||||
for x in [r.x0, r.center().x, r.x1] {
|
||||
take(&mut self.xs, (x, r.y0, r.y1));
|
||||
}
|
||||
for y in [r.y0, r.center().y, r.y1] {
|
||||
take(&mut self.ys, (y, r.x0, r.x1));
|
||||
}
|
||||
if let Some(i) = self.boxes.iter().rposition(|b| *b == r) {
|
||||
self.boxes.remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes,
|
||||
/// and a marker when a moving point lands on it.
|
||||
pub fn add_point(&mut self, p: Point) {
|
||||
|
||||
@@ -336,6 +336,9 @@ pub struct Overlay {
|
||||
pub rubber_handle: Option<[f64; 2]>,
|
||||
/// Frame/section the pointer is drawing or dropping into (highlighted).
|
||||
pub drop_target: Option<NodeId>,
|
||||
/// Connector tool: the attach points (side middles, page x, y) of the object under the pointer,
|
||||
/// and whether the connector end is at each.
|
||||
pub attach: Vec<([f64; 2], bool)>,
|
||||
/// Auto layout insertion indicator (x0, y0, x1, y1).
|
||||
pub insert_line: Option<[f64; 4]>,
|
||||
/// The instance an Alt-drop of a component from the Assets panel would swap (highlighted).
|
||||
|
||||
@@ -178,7 +178,20 @@ impl Session {
|
||||
}
|
||||
|
||||
pub(super) fn drag(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
let g = std::mem::take(&mut self.tool.gesture);
|
||||
let mut g = std::mem::take(&mut self.tool.gesture);
|
||||
// Alt makes a move a copy whenever it goes down, before the drag or during it, and letting
|
||||
// go of it moves the original again; a copy snaps to the layers it came from too.
|
||||
if let Gesture::Move { targets, duplicate, .. } = &mut g
|
||||
&& *duplicate != ev.alt
|
||||
{
|
||||
*duplicate = ev.alt;
|
||||
if let Some(b) = targets.moving {
|
||||
if ev.alt { targets.add_box(b) } else { targets.remove_box(b) }
|
||||
}
|
||||
if let Some(it) = self.ds_mut().ok().and_then(|ds| ds.interaction.as_mut()) {
|
||||
it.label = if ev.alt { "Duplicate" } else { "Move" }.into();
|
||||
}
|
||||
}
|
||||
let res = match &g {
|
||||
Gesture::None => return self.hover(ev),
|
||||
Gesture::Marquee { start, additive, base, within } => {
|
||||
@@ -533,15 +546,8 @@ impl Session {
|
||||
}
|
||||
let Some(data) = doc.images.get(&img.image) else { continue };
|
||||
let (iw, ih) = (f64::from(data.width), f64::from(data.height));
|
||||
let crop = if img.scale_mode == ImageScaleMode::Crop {
|
||||
img.effective_crop(iw, ih, w, h)
|
||||
} else {
|
||||
let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0));
|
||||
let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) };
|
||||
[w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)]
|
||||
};
|
||||
// A degenerate (zero-scale) layer has no inverse: its image keeps its placement.
|
||||
let crop = (Affine::new(crop) * to_old).as_coeffs();
|
||||
let crop = (Affine::new(img.placement_crop(iw, ih, w, h)) * to_old).as_coeffs();
|
||||
if !crop.iter().all(|c| c.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,10 @@ impl Session {
|
||||
}
|
||||
|
||||
pub(super) fn up(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
// Alt went down or up since the last move: the drop is a copy or a move as the release has it.
|
||||
if matches!(&self.tool.gesture, Gesture::Move { moved: true, duplicate, .. } if *duplicate != ev.alt) {
|
||||
self.drag(ev)?;
|
||||
}
|
||||
let g = std::mem::take(&mut self.tool.gesture);
|
||||
self.tool.overlay.marquee = None;
|
||||
self.tool.overlay.guides.clear();
|
||||
|
||||
@@ -257,6 +257,9 @@ impl Session {
|
||||
}
|
||||
}
|
||||
self.tool.overlay.guides = guides;
|
||||
if tool == ToolId::Connector {
|
||||
self.connector_hover(ev);
|
||||
}
|
||||
let ds = self.ds_mut()?;
|
||||
if ds.hover != hover {
|
||||
ds.hover = hover;
|
||||
|
||||
@@ -151,6 +151,8 @@ pub static MENU: &[MenuItem] = &[
|
||||
m!("Object", "Use as mask", "object.mask"),
|
||||
m!("Object", "Release mask", "object.releaseMask"),
|
||||
m!("Object", "Set as thumbnail", "object.setThumbnail"),
|
||||
// FigCraft's own (in `OWN`): the way into crop mode that the Design panel also offers.
|
||||
m!("Object", "Crop image", "image.crop"),
|
||||
m!("Object", SEP, ""),
|
||||
m!("Object", "Bring to front", "arrange.front"),
|
||||
m!("Object", "Bring forward", "arrange.forward"),
|
||||
@@ -290,7 +292,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
|
||||
}
|
||||
|
||||
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
|
||||
const OWN: [&str; 4] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts"];
|
||||
const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"];
|
||||
|
||||
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
|
||||
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//! Comments: add, reply, edit, react, resolve, read state, delete, find and reveal comment threads.
|
||||
//!
|
||||
//! Messages are addressed by `reply` (an index into the replies; omitted: the thread's first
|
||||
//! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only
|
||||
//! a message's author edits or deletes it, and only the thread's author deletes the thread.
|
||||
//! message). `author` is who is acting (default: the AI agent the command runs for, else the name
|
||||
//! from Preferences, `pref.userName`): only a message's author edits or deletes it, and only the
|
||||
//! thread's author deletes the thread.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom};
|
||||
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom, mentions};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
@@ -16,6 +17,12 @@ use crate::{Session, bad, spec};
|
||||
/// The name comments are signed with until the user sets theirs.
|
||||
pub const DEFAULT_AUTHOR: &str = "You";
|
||||
|
||||
/// The name of an AI agent that gave none.
|
||||
pub const DEFAULT_AGENT: &str = "AI agent";
|
||||
|
||||
/// Most characters of an agent's own name in its comments.
|
||||
const MAX_AGENT_NAME: usize = 64;
|
||||
|
||||
/// Links to a thread: `figcraft://comment/<id>`.
|
||||
const LINK_PREFIX: &str = "figcraft://comment/";
|
||||
|
||||
@@ -260,9 +267,27 @@ pub fn user_name(s: &Session) -> String {
|
||||
if n.is_empty() { DEFAULT_AUTHOR.into() } else { n.to_string() }
|
||||
}
|
||||
|
||||
/// Who is acting: `author` from the params, else the user.
|
||||
/// How an AI agent's comments are signed: its client's name, marked so it never reads as a
|
||||
/// person's ("Claude" → "Claude (agent)"), or [`DEFAULT_AGENT`] when it gave none.
|
||||
pub fn agent_author(name: &str) -> String {
|
||||
let n: String = name.chars().filter(|c| !c.is_control()).collect();
|
||||
let n: String = n.trim().chars().take(MAX_AGENT_NAME).collect();
|
||||
if n.is_empty() { DEFAULT_AGENT.into() } else { format!("{n} (agent)") }
|
||||
}
|
||||
|
||||
/// Who acts when no `author` is given: the AI agent the command runs for, else the user.
|
||||
pub fn acting_name(s: &Session) -> String {
|
||||
s.agent.clone().unwrap_or_else(|| user_name(s))
|
||||
}
|
||||
|
||||
/// Who is acting: `author` from the params, else [`acting_name`].
|
||||
fn author(s: &Session, p: &Value) -> String {
|
||||
string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| user_name(s))
|
||||
string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| acting_name(s))
|
||||
}
|
||||
|
||||
/// A new message by `who`, marked as an agent's when the command runs for one.
|
||||
fn new_message(s: &Session, who: String, text: String) -> Message {
|
||||
Message { agent: s.agent.is_some(), ..Message::new(who, text, now_secs()) }
|
||||
}
|
||||
|
||||
fn text(cmd: &str, p: &Value) -> crate::Result<String> {
|
||||
@@ -301,25 +326,42 @@ fn local(doc: &Document, anchor: Option<NodeId>, x: f64, y: f64) -> (f64, f64) {
|
||||
}
|
||||
}
|
||||
|
||||
fn message_json(m: &Message) -> Value {
|
||||
/// The names an @mention resolves to: the file's people (`Document::comment_people`) and `user`,
|
||||
/// as the thread view highlights them.
|
||||
fn mentionable<'a>(people: &'a [String], user: &'a str) -> Vec<&'a str> {
|
||||
people.iter().map(String::as_str).chain([user]).collect()
|
||||
}
|
||||
|
||||
/// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against
|
||||
/// `people` (`mentionable`); `known: false` is a name nobody in the file has.
|
||||
fn message_json(m: &Message, people: &[&str], user: &str) -> Value {
|
||||
let mut named: Vec<Value> = Vec::new();
|
||||
for (r, who) in mentions(&m.text, people) {
|
||||
let name = who.or_else(|| m.text.get(r.start + 1..r.end)).unwrap_or_default();
|
||||
let v = json!({"name": name, "known": who.is_some(), "you": who.is_some_and(|w| w.eq_ignore_ascii_case(user))});
|
||||
if !named.contains(&v) {
|
||||
named.push(v);
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"author": m.author, "text": m.text, "created": m.created, "edited": m.edited,
|
||||
"author": m.author, "agent": m.agent, "text": m.text, "created": m.created, "edited": m.edited,
|
||||
"reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::<Vec<_>>(),
|
||||
"mentions": named,
|
||||
})
|
||||
}
|
||||
|
||||
/// JSON view of a comment as `user` sees it: its page position, frame, read state and link, the
|
||||
/// first message's fields, and the replies.
|
||||
pub fn comment_json(doc: &Document, c: &Comment, user: &str) -> Value {
|
||||
/// first message's fields, and the replies; mentions resolve against `people` (`mentionable`).
|
||||
pub fn comment_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) -> Value {
|
||||
let (x, y) = c.position(doc);
|
||||
let frame = frame_of(doc, c);
|
||||
let mut v = message_json(&c.first);
|
||||
let mut v = message_json(&c.first, people, user);
|
||||
let extra = json!({
|
||||
"id": c.id, "page": c.page, "pageName": doc.node(c.page).map(|n| n.name.as_str()),
|
||||
"x": x, "y": y, "anchor": c.anchor,
|
||||
"frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| n.name.as_str()),
|
||||
"resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id),
|
||||
"replies": c.replies.iter().map(message_json).collect::<Vec<_>>(),
|
||||
"replies": c.replies.iter().map(|m| message_json(m, people, user)).collect::<Vec<_>>(),
|
||||
});
|
||||
if let (Some(m), Value::Object(e)) = (v.as_object_mut(), extra) {
|
||||
m.extend(e);
|
||||
@@ -338,7 +380,7 @@ fn comment_brief_json(doc: &Document, c: &Comment, user: &str) -> Value {
|
||||
"x": x, "y": y, "anchor": c.anchor,
|
||||
"frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| clip_chars(&n.name, 80)),
|
||||
"resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id),
|
||||
"author": clip_chars(&c.first.author, 80), "preview": clip_chars(&c.first.text, 160),
|
||||
"author": clip_chars(&c.first.author, 80), "agent": c.first.agent, "preview": clip_chars(&c.first.text, 160),
|
||||
"replies": c.replies.len(),
|
||||
})
|
||||
}
|
||||
@@ -385,7 +427,8 @@ fn open(s: &mut Session, id: u64, how: &str, who: &str) -> crate::Result<Value>
|
||||
s.request_ui("comment.open", json!({"id": id}));
|
||||
let doc = s.doc()?;
|
||||
let c = doc.comment(id).ok_or_else(|| bad("comment.open", "no comment"))?;
|
||||
Ok(comment_json(doc, c, who))
|
||||
let people = doc.comment_people();
|
||||
Ok(comment_json(doc, c, who, &mentionable(&people, who)))
|
||||
}
|
||||
|
||||
/// A list's page (None: every page), filter, sort and search text from params.
|
||||
@@ -565,7 +608,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
x: lx,
|
||||
y: ly,
|
||||
anchor,
|
||||
first: Message::new(who.clone(), text, now_secs()),
|
||||
first: new_message(s, who.clone(), text),
|
||||
resolved: false,
|
||||
replies: Vec::new(),
|
||||
read_by: None,
|
||||
@@ -579,7 +622,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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());
|
||||
let r = new_message(s, who.clone(), text("comment.reply", p)?);
|
||||
let c = s.doc_mut()?.comment_mut(id).ok_or_else(|| bad("comment.reply", "no comment"))?;
|
||||
c.replies.push(r);
|
||||
c.resolved = false;
|
||||
@@ -758,7 +801,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let id = req_comment("comment.link", s, p)?;
|
||||
Ok(json!({"link": link(id)}))
|
||||
}),
|
||||
spec!("comment.people", "People in comments", "{}", false, |s, _| { Ok(json!({"people": s.doc()?.comment_people(), "you": user_name(s)})) }),
|
||||
spec!("comment.people", "People in comments", "{}", false, |s, _| {
|
||||
Ok(json!({"people": s.doc()?.comment_people(), "you": acting_name(s)}))
|
||||
}),
|
||||
spec!(
|
||||
"comment.list",
|
||||
"List comments",
|
||||
@@ -769,7 +814,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (page, filter, sort, text) = list_params("comment.list", s, p)?;
|
||||
let who = author(s, p);
|
||||
let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text };
|
||||
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who)).collect();
|
||||
let people = doc.comment_people();
|
||||
let people = mentionable(&people, &who);
|
||||
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who, &people)).collect();
|
||||
Ok(json!({"comments": list}))
|
||||
}
|
||||
),
|
||||
@@ -817,7 +864,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Some(_) => return Err(bad("comment.prompt", "`ids` must be an array of comment ids")),
|
||||
};
|
||||
let who = user_name(s);
|
||||
let who = acting_name(s);
|
||||
let selected: Vec<&Comment> = if let Some(ids) = ids {
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
|
||||
@@ -292,8 +292,8 @@ pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = s.doc_mut()?;
|
||||
let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?;
|
||||
let mut slot = crate::drop::slot(doc, page, pt);
|
||||
// Never inside its own main component (or a copy of it).
|
||||
if doc.is_ancestor(c, slot.parent) || slot.parent == c {
|
||||
// Never inside its own main component, or one it contains (directly or through others).
|
||||
if crate::components::would_contain_itself(doc, c, slot.parent) {
|
||||
slot = crate::drop::DropSlot { parent: page, index: None, line: None };
|
||||
}
|
||||
let (w, h) = doc.node(c).map(|n| (n.width, n.height)).unwrap_or((0.0, 0.0));
|
||||
|
||||
@@ -376,8 +376,8 @@ pub(super) fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Make instance `i` show main component `c`, keeping the values of properties with the same names
|
||||
/// (the layers' overrides follow by name when the instance is rebuilt).
|
||||
pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> {
|
||||
if doc.is_ancestor(c, i) {
|
||||
return Err("an instance can't show the component it is inside".into());
|
||||
if crate::components::would_contain_itself(doc, c, i) {
|
||||
return Err(crate::components::contains_itself_error(doc, c));
|
||||
}
|
||||
let defaults = doc.node(c).map(default_props).unwrap_or_default();
|
||||
match doc.get_mut(i).map(|n| &mut n.kind) {
|
||||
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"edit.duplicate",
|
||||
"Duplicate",
|
||||
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
|
||||
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place), map? (true: also return `map`, each original layer's id, descendants included, → its copy's id)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
|
||||
true,
|
||||
duplicate
|
||||
),
|
||||
@@ -460,7 +460,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| {
|
||||
spec!("command.batch", "Run several commands", "{label?, onError?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| {
|
||||
// `continue` rolls back only the failed steps and returns one outcome per step.
|
||||
let continue_on_error = match get(p, "onError") {
|
||||
None => false,
|
||||
Some(Value::String(mode)) if mode == "abort" => false,
|
||||
Some(Value::String(mode)) if mode == "continue" => true,
|
||||
Some(_) => return Err(bad("command.batch", "`onError` must be \"abort\" or \"continue\"")),
|
||||
};
|
||||
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
|
||||
let cmds: Vec<BatchStep> = match get(p, "commands") {
|
||||
Some(Value::Array(a)) => {
|
||||
@@ -476,7 +483,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
_ => return Err(bad("command.batch", "`commands` must be an array")),
|
||||
};
|
||||
Ok(Value::Array(s.batch(&label, &cmds)?))
|
||||
Ok(Value::Array(if continue_on_error { s.batch_continue(&label, &cmds)? } else { s.batch(&label, &cmds)? }))
|
||||
}),
|
||||
spec!("edit.repeat", "Repeat last command", "{}", true, |s, _| {
|
||||
let Some((id, p)) = s.last_command.clone().filter(|(id, _)| id != "edit.repeat") else {
|
||||
@@ -625,6 +632,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false);
|
||||
s.set_selection(out.clone());
|
||||
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
|
||||
if boolean(p, "map") == Some(true) {
|
||||
// JSON object keys are strings: each original layer's id → its copy's.
|
||||
let map: Map<String, Value> = map.into_iter().map(|(o, c)| (o.to_string(), json!(c))).collect();
|
||||
return Ok(json!({"ids": out, "map": map}));
|
||||
}
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,9 @@ pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if props.is_empty() {
|
||||
return Err(bad("node.set", "no properties given"));
|
||||
}
|
||||
for (k, v) in &props {
|
||||
crate::cmd::props::variable_prop("node.set", k, v)?;
|
||||
}
|
||||
// A new rotation for the selection turns it about its moved rotation origin: each layer's
|
||||
// point under the origin stays put.
|
||||
let ds = s.ds()?;
|
||||
|
||||
@@ -26,9 +26,10 @@ use export::add_member;
|
||||
pub(super) use export::export_setting;
|
||||
pub use export::{
|
||||
ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes,
|
||||
frames_pdf, jobs_for, safe_file_name,
|
||||
frames_pdf, jobs_for, penpot_bytes, safe_file_name,
|
||||
};
|
||||
use export::{export, export_frames_pdf, export_layers, pdf_bytes};
|
||||
use load::font_report;
|
||||
#[cfg(test)]
|
||||
pub(crate) use load::{installed_family, sketch_line};
|
||||
pub use load::{load, load_bytes, load_path};
|
||||
@@ -75,7 +76,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), 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`)",
|
||||
"{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), .penpot, .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig, .penpot 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
|
||||
),
|
||||
@@ -86,6 +87,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
false,
|
||||
open_recent
|
||||
),
|
||||
spec!(
|
||||
"file.fontReport",
|
||||
"Inspect document fonts",
|
||||
"{} list fonts used by text layers, which families are available, and sample layers; does not substitute or change the original family names",
|
||||
false,
|
||||
|s, _| Ok(font_report(s.doc()?))
|
||||
),
|
||||
spec!(
|
||||
"file.save",
|
||||
"Save",
|
||||
@@ -110,7 +118,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), 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))}",
|
||||
"{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; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), 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
|
||||
),
|
||||
@@ -144,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.import",
|
||||
"Import",
|
||||
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON, .fig and .sketch pages are appended with their variables and flows (REST, .fig and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
|
||||
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, .penpot, REST API file JSON, .fig and .sketch pages are appended with their variables and flows (REST, .fig, .penpot and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
|
||||
true,
|
||||
import
|
||||
),
|
||||
@@ -310,15 +318,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("doc.nextTab", "Next tab", "{}", false, |s, _| cycle_doc(s, 1)),
|
||||
spec!("doc.previousTab", "Previous tab", "{}", false, |s, _| cycle_doc(s, -1)),
|
||||
spec!("doc.list", "List documents", "{}", false, |s, _| {
|
||||
Ok(Value::Array(
|
||||
spec!(
|
||||
"doc.list",
|
||||
"List documents",
|
||||
"{} → [{index, id, name, path, dirty, active}]: `id` names the file in a control or MCP call's `document`",
|
||||
false,
|
||||
|s, _| {
|
||||
Ok(Value::Array(
|
||||
s.docs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
|
||||
.map(|(i, d)| json!({"index": i, "id": d.uid, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
|
||||
.collect(),
|
||||
))
|
||||
}),
|
||||
}
|
||||
),
|
||||
spec!("doc.home", "Home", "{}", false, |s, _| {
|
||||
s.request_ui("home", json!({}));
|
||||
ok()
|
||||
|
||||
@@ -75,12 +75,14 @@ pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option<figcraft_render::Pixma
|
||||
|
||||
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.export";
|
||||
let fig = match string(p, "format") {
|
||||
Some(f) => f.eq_ignore_ascii_case("fig"),
|
||||
None => string(p, "path").is_some_and(|pp| ext(&pp) == "fig"),
|
||||
let whole_file = match string(p, "format") {
|
||||
Some(f) => f.to_ascii_lowercase(),
|
||||
None => string(p, "path").map(|pp| ext(&pp)).unwrap_or_default(),
|
||||
};
|
||||
if fig {
|
||||
return export_fig(s, p);
|
||||
match whole_file.as_str() {
|
||||
"fig" => return export_fig(s, p),
|
||||
"penpot" => return export_penpot(s, p),
|
||||
_ => {}
|
||||
}
|
||||
let doc = s.doc()?;
|
||||
let mut ids = targets(s, p);
|
||||
@@ -127,6 +129,21 @@ pub(super) fn export_fig(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
fn export_penpot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, warnings) = penpot_bytes(s.doc()?)?;
|
||||
match string(p, "path") {
|
||||
Some(path) => {
|
||||
write(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "format": "penpot", "warnings": warnings}))
|
||||
}
|
||||
None => Ok(json!({"format": "penpot", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "warnings": warnings})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn penpot_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
figcraft_format::penpot::export(doc).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// `doc` as a `.fig` file (with a thumbnail) and the warnings about what was left out.
|
||||
pub fn fig_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string()))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Result, StyleKind, TextStyle, Value, bad, get, json};
|
||||
|
||||
/// The recovery copy at `path` as a document with unsaved changes and no path.
|
||||
@@ -68,11 +70,24 @@ pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_form
|
||||
/// Load from bytes. Returns the document with its notes (repairs of a native file, or import
|
||||
/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig`
|
||||
/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON.
|
||||
/// Components containing each other's instances are broken apart, with a note.
|
||||
pub(super) fn load_with(
|
||||
bytes: &[u8],
|
||||
name_hint: &str,
|
||||
variables: Option<&Value>,
|
||||
images: figcraft_format::rest::ImageResolver,
|
||||
) -> Result<(figcraft_format::Loaded, bool)> {
|
||||
let (mut loaded, rest) = load_any(bytes, name_hint, variables, images)?;
|
||||
let notes = crate::components::break_component_cycles(&mut loaded.doc);
|
||||
loaded.issues.extend(notes);
|
||||
Ok((loaded, rest))
|
||||
}
|
||||
|
||||
fn load_any(
|
||||
bytes: &[u8],
|
||||
name_hint: &str,
|
||||
variables: Option<&Value>,
|
||||
images: figcraft_format::rest::ImageResolver,
|
||||
) -> Result<(figcraft_format::Loaded, bool)> {
|
||||
match ext(name_hint).as_str() {
|
||||
"svg" => {
|
||||
@@ -83,6 +98,19 @@ pub(super) fn load_with(
|
||||
.notes;
|
||||
Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false))
|
||||
}
|
||||
"penpot" => {
|
||||
let (mut doc, mut notes) = figcraft_format::penpot::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
expand_penpot_raw_svgs(&mut doc, &mut notes);
|
||||
// Penpot imports stay offline: referenced fonts must be installed locally.
|
||||
// The format reader stays platform-agnostic. Font availability is checked here,
|
||||
// where FigCraft's system/embedded font registry is accessible. Never replace
|
||||
// the original family in the document: it can resolve after installation.
|
||||
figcraft_text::scan_system_fonts();
|
||||
let families = figcraft_text::family_names();
|
||||
let mut font_notes = missing_penpot_font_warnings(&doc, &families);
|
||||
font_notes.append(&mut notes); // Show missing-font details first in the UI toast.
|
||||
Ok((figcraft_format::Loaded { issues: font_notes, ..doc.into() }, true))
|
||||
}
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => {
|
||||
let mut doc = Document::new(file_stem(name_hint));
|
||||
let page = doc.first_page().ok_or(EngineError::NoDocument)?;
|
||||
@@ -351,6 +379,249 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma
|
||||
}))
|
||||
}
|
||||
|
||||
/// Turn Penpot raw SVG placeholders into editable FigCraft SVG content, using the
|
||||
/// normal SVG importer. The parser (figcraft-format) only stores bounded XML so it
|
||||
/// does not need to depend on the SVG crate; the engine does this second pass.
|
||||
/// Any failure leaves the original placeholder and XML data intact for later use.
|
||||
fn expand_penpot_raw_svgs(doc: &mut Document, notes: &mut Vec<String>) {
|
||||
let layers = doc
|
||||
.nodes()
|
||||
.filter_map(|node| {
|
||||
let xml = node.plugin_data.get("penpot.svgRawXml")?;
|
||||
(node.plugin_data.get("penpot.svgRawStatus").map(String::as_str) == Some("pending"))
|
||||
.then(|| (node.id, node.name.clone(), node.width, node.height, xml.clone(), node.children.is_empty()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut ok_count = 0usize;
|
||||
for (id, name, width, height, xml, empty) in layers {
|
||||
if !empty {
|
||||
notes.push(format!("Penpot SVG '{name}': has separate child layers; the original tree was preserved to prevent duplicate rendering"));
|
||||
continue;
|
||||
}
|
||||
match figcraft_svg::import_svg_with_notes(doc, xml.as_bytes(), id, 0.0, 0.0, &name) {
|
||||
Ok(imported) => {
|
||||
// An SVG can be syntactically valid yet contain no supported drawable
|
||||
// elements. Do not claim success or leave an empty extra frame.
|
||||
if doc.node(imported.frame).is_none_or(|n| n.children.is_empty()) {
|
||||
let _ = doc.remove(imported.frame);
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.plugin_data.insert("penpot.svgRawStatus".into(), "not-expanded".into());
|
||||
}
|
||||
if notes.len() < 100 {
|
||||
notes.push(format!("Penpot SVG '{name}' has no supported drawable elements; the original markup is preserved"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let size = doc.node(imported.frame).map(|n| (n.width, n.height));
|
||||
if let Some((svg_w, svg_h)) = size
|
||||
&& svg_w.is_finite()
|
||||
&& svg_h.is_finite()
|
||||
&& svg_w > 0.0
|
||||
&& svg_h > 0.0
|
||||
{
|
||||
let sx = (width / svg_w).clamp(0.0001, 10_000.0);
|
||||
let sy = (height / svg_h).clamp(0.0001, 10_000.0);
|
||||
if let Some(n) = doc.node_mut(imported.frame) {
|
||||
n.transform = figcraft_geom::Affine::scale_non_uniform(sx, sy);
|
||||
}
|
||||
}
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.plugin_data.insert("penpot.svgRawStatus".into(), "expanded".into());
|
||||
}
|
||||
for issue in imported.notes {
|
||||
if notes.len() < 100 {
|
||||
notes.push(format!("Penpot SVG '{name}': {issue}"));
|
||||
}
|
||||
}
|
||||
ok_count += 1;
|
||||
}
|
||||
Err(err) => {
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.plugin_data.insert("penpot.svgRawStatus".into(), "not-expanded".into());
|
||||
}
|
||||
if notes.len() < 100 {
|
||||
notes.push(format!("Penpot SVG '{name}' could not be imported: {err}; its original markup is preserved"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok_count > 0 && notes.len() < 100 {
|
||||
notes.push(format!("Penpot: {ok_count} raw SVG layer(s) converted to editable SVG content"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-destructive font diagnostic for the action palette, CLI and control API.
|
||||
/// Includes styled runs, not just the default typeface of each text layer.
|
||||
pub(super) fn font_report(doc: &Document) -> Value {
|
||||
figcraft_text::scan_system_fonts();
|
||||
font_report_with_families(doc, &figcraft_text::family_names())
|
||||
}
|
||||
|
||||
fn font_report_with_families(doc: &Document, installed: &[String]) -> Value {
|
||||
let known: BTreeSet<String> = installed.iter().map(|s| s.trim().to_lowercase()).collect();
|
||||
let mut families: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||||
for node in doc.nodes() {
|
||||
let NodeKind::Text(text) = &node.kind else { continue };
|
||||
if text.characters.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut covered = vec![false; text.characters.chars().count().min(100_000)];
|
||||
let mut used = BTreeSet::new();
|
||||
for run in &text.runs {
|
||||
let start = run.start.min(covered.len());
|
||||
let end = run.end.min(covered.len());
|
||||
if start < end {
|
||||
if !run.style.font_family.trim().is_empty() {
|
||||
used.insert(run.style.font_family.clone());
|
||||
}
|
||||
for entry in covered.iter_mut().take(end).skip(start) {
|
||||
*entry = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if covered.iter().any(|entry| !entry) {
|
||||
used.insert(text.style.font_family.clone());
|
||||
}
|
||||
for family in used {
|
||||
let family = family.trim();
|
||||
if family.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if families.len() >= 256 && !families.contains_key(family) {
|
||||
continue;
|
||||
}
|
||||
let layer_name = node.name.chars().filter(|c| !c.is_control()).take(120).collect::<String>();
|
||||
let layers = families.entry(family.chars().filter(|c| !c.is_control()).take(160).collect()).or_default();
|
||||
if layers.len() < 25 {
|
||||
layers.insert(layer_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Explain WHERE a Penpot family comes from. `custom-*` references are
|
||||
// stored in Penpot's font service, not necessarily in its design archive.
|
||||
// Keep the id for diagnostics; never treat it as a download URL.
|
||||
let mut external: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
|
||||
for node in doc.nodes() {
|
||||
for key in ["penpot.googleFontRefs", "penpot.customFontRefs", "penpot.customLibraryFontRefs"] {
|
||||
let Some(raw) = node.plugin_data.get(key) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(Value::Array(items)) = serde_json::from_str::<Value>(raw) else {
|
||||
continue;
|
||||
};
|
||||
for item in items.iter().take(128) {
|
||||
let Some(id) = item.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(family) = item.get("family").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if id.len() > 128 || family.len() > 128 || external.len() >= 256 {
|
||||
continue;
|
||||
}
|
||||
let key = (id.to_owned(), family.to_owned());
|
||||
external.entry(key).or_default().insert(node.name.chars().take(80).collect());
|
||||
}
|
||||
}
|
||||
}
|
||||
let external_fonts: Vec<Value> = external
|
||||
.into_iter()
|
||||
.map(|((id, family), layers)| {
|
||||
json!({
|
||||
"source": if id.starts_with("custom-") { "penpot-custom" } else { "google-fonts" },
|
||||
"referenceKind": if id.starts_with("custom-") { "custom-original-unverified" } else { "google-fonts-reference" },
|
||||
"originNote": if id.starts_with("custom-") {
|
||||
"Penpot-managed font: a matching installed/downloaded family is not proof of the original font file"
|
||||
} else {
|
||||
"Google Fonts provider reference; availability is reported separately"
|
||||
},
|
||||
"id": id, "family": family, "layers": layers.into_iter().collect::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let entries = families
|
||||
.into_iter()
|
||||
.map(|(family, layers)| {
|
||||
let lower = family.to_lowercase();
|
||||
let generic = matches!(lower.as_str(), "sans-serif" | "serif" | "monospace" | "system-ui");
|
||||
// No registered families can mean an uninitialised/no-font runtime; it is
|
||||
// not evidence that all names are absent from the user's computer.
|
||||
let available = if generic {
|
||||
Some(true)
|
||||
} else if known.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(known.contains(&lower))
|
||||
};
|
||||
json!({"family":family,"available":available,"layers":layers.into_iter().collect::<Vec<_>>()})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let missing = entries.iter().filter(|v| v.get("available") == Some(&Value::Bool(false))).count();
|
||||
json!({"total": entries.len(), "missing": missing, "embeddedFontFiles": 0,
|
||||
"fontLicenseNotices": 0, "fonts": entries,
|
||||
"externalPenpotFontRefs": external_fonts})
|
||||
}
|
||||
|
||||
/// Describe fonts unavailable on this machine, while keeping their original family names
|
||||
/// editable in the document. The detected families are provided separately for testability
|
||||
/// and to avoid a dependency from the format parser to the font renderer.
|
||||
fn missing_penpot_font_warnings(doc: &Document, families: &[String]) -> Vec<String> {
|
||||
// An uninitialised font registry cannot prove a family is unavailable.
|
||||
if families.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let available: BTreeSet<String> = families.iter().map(|s| s.trim().to_lowercase()).collect();
|
||||
let mut missing: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||||
for node in doc.nodes() {
|
||||
let NodeKind::Text(text) = &node.kind else { continue };
|
||||
let text_len = text.characters.chars().count();
|
||||
if text_len == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut covered = vec![false; text_len.min(100_000)];
|
||||
let mut used = BTreeSet::new();
|
||||
for run in &text.runs {
|
||||
let start = run.start.min(covered.len());
|
||||
let end = run.end.min(covered.len());
|
||||
if start < end {
|
||||
used.insert(run.style.font_family.clone());
|
||||
for flag in covered.iter_mut().take(end).skip(start) {
|
||||
*flag = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if covered.iter().any(|present| !present) {
|
||||
used.insert(text.style.font_family.clone());
|
||||
}
|
||||
for family in used {
|
||||
let family = family.trim();
|
||||
let lower = family.to_lowercase();
|
||||
if family.is_empty() || matches!(lower.as_str(), "sans-serif" | "serif" | "monospace" | "system-ui") || available.contains(&lower) {
|
||||
continue;
|
||||
}
|
||||
let display_family: String = family.chars().filter(|c| !c.is_control()).take(160).collect();
|
||||
let display_layer: String = node.name.chars().filter(|c| !c.is_control()).take(80).collect();
|
||||
missing.entry(display_family).or_default().insert(display_layer);
|
||||
}
|
||||
}
|
||||
// A single warning remains readable even for documents with hundreds of
|
||||
// type layers and does not push out more urgent Penpot import diagnostics.
|
||||
if missing.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let count = missing.len();
|
||||
let names = missing
|
||||
.into_iter()
|
||||
.take(8)
|
||||
.map(|(family, layers)| format!("'{family}' (e.g. '{}')", layers.iter().next().map(String::as_str).unwrap_or("text")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let extra = if count > 8 { format!(" and {} more", count - 8) } else { String::new() };
|
||||
vec![format!(
|
||||
"Penpot: {count} font family/families are not installed or embedded: {names}{extra}. FigCraft uses fallback fonts for display, but preserves the original family names; install the licensed fonts and reopen the file to restore text metrics."
|
||||
)]
|
||||
}
|
||||
|
||||
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
|
||||
/// innermost first so outer instances settle last.
|
||||
fn link_instances(doc: &mut Document) {
|
||||
@@ -430,3 +701,114 @@ pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result<Option<Value>> {
|
||||
}
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod penpot_font_warning_tests {
|
||||
use super::*;
|
||||
use figcraft_doc::{StyleRun, TextData};
|
||||
|
||||
#[test]
|
||||
fn font_report_explains_custom_and_google_provenance() {
|
||||
let mut doc = Document::new("External font refs");
|
||||
let page = doc.first_page().unwrap();
|
||||
let text = TextData::new("Playball");
|
||||
let id = doc.create(page, NodeKind::Text(text), "Custom caption", 0., 0., 120., 30.).unwrap();
|
||||
doc.node_mut(id).unwrap().plugin_data.insert(
|
||||
"penpot.customFontRefs".into(),
|
||||
r#"[{"id":"custom-123abc","family":"Playball","variants":[{"weight":400,"italic":false}]}]"#.into(),
|
||||
);
|
||||
let report = font_report_with_families(&doc, &[]);
|
||||
assert_eq!(report["externalPenpotFontRefs"][0]["source"], "penpot-custom");
|
||||
assert_eq!(report["externalPenpotFontRefs"][0]["family"], "Playball");
|
||||
assert_eq!(report["externalPenpotFontRefs"][0]["referenceKind"], "custom-original-unverified");
|
||||
assert!(report["externalPenpotFontRefs"][0]["originNote"].as_str().unwrap().contains("not proof"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_only_missing_families_without_modifying_original_text() {
|
||||
let mut doc = Document::new("Penpot fonts");
|
||||
let page = doc.first_page().unwrap();
|
||||
let mut text = TextData::new("hello world");
|
||||
text.style.font_family = "Rare Licensed Family".into();
|
||||
doc.create(page, NodeKind::Text(text), "Headline", 0.0, 0.0, 200.0, 30.0).unwrap();
|
||||
let warnings = missing_penpot_font_warnings(&doc, &["Inter".into(), "Source Sans Pro".into()]);
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains("Rare Licensed Family"));
|
||||
assert!(warnings[0].contains("Headline"));
|
||||
assert_eq!(
|
||||
doc.nodes().find(|n| n.name == "Headline").and_then(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.style.font_family.as_str()),
|
||||
_ => None,
|
||||
}),
|
||||
Some("Rare Licensed Family")
|
||||
);
|
||||
assert!(missing_penpot_font_warnings(&doc, &["RARE LICENSED FAMILY".into()]).is_empty());
|
||||
assert!(missing_penpot_font_warnings(&doc, &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completely_styled_text_does_not_report_an_unused_base_font() {
|
||||
let mut doc = Document::new("Run fonts");
|
||||
let page = doc.first_page().unwrap();
|
||||
let mut t = TextData::new("AB");
|
||||
t.style.font_family = "Unused Base".into();
|
||||
let mut run = t.style.clone();
|
||||
run.font_family = "Available Font".into();
|
||||
t.runs.push(StyleRun { start: 0, end: 2, style: run });
|
||||
doc.create(page, NodeKind::Text(t), "Full run", 0.0, 0.0, 100.0, 30.0).unwrap();
|
||||
assert!(missing_penpot_font_warnings(&doc, &["Available Font".into()]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod penpot_svg_v10_tests {
|
||||
use super::*;
|
||||
use figcraft_doc::TextData;
|
||||
|
||||
#[test]
|
||||
fn expands_inline_svg_into_editable_shapes_without_discarding_source() {
|
||||
let mut doc = Document::new("SVG fixture");
|
||||
let page = doc.first_page().unwrap();
|
||||
let id = doc.create(page, NodeKind::Frame, "SVG ornament", 10.0, 20.0, 100.0, 50.0).unwrap();
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="20" height="20" fill="red"/></svg>"#;
|
||||
let layer = doc.node_mut(id).unwrap();
|
||||
layer.plugin_data.insert("penpot.svgRawXml".into(), svg.into());
|
||||
layer.plugin_data.insert("penpot.svgRawStatus".into(), "pending".into());
|
||||
let mut warnings = Vec::new();
|
||||
expand_penpot_raw_svgs(&mut doc, &mut warnings);
|
||||
let layer = doc.node(id).unwrap();
|
||||
assert_eq!(layer.plugin_data.get("penpot.svgRawStatus").map(String::as_str), Some("expanded"));
|
||||
assert!(!layer.children.is_empty());
|
||||
assert!(doc.nodes().any(|node| matches!(&node.kind, NodeKind::Rectangle | NodeKind::Vector { .. })));
|
||||
assert_eq!(layer.plugin_data.get("penpot.svgRawXml").map(String::as_str), Some(svg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_raw_svg_keeps_placeholder_and_markup() {
|
||||
let mut doc = Document::new("Invalid SVG fixture");
|
||||
let page = doc.first_page().unwrap();
|
||||
let id = doc.create(page, NodeKind::Frame, "Broken", 0.0, 0.0, 80.0, 30.0).unwrap();
|
||||
let layer = doc.node_mut(id).unwrap();
|
||||
layer.plugin_data.insert("penpot.svgRawXml".into(), "<svg><broken".into());
|
||||
layer.plugin_data.insert("penpot.svgRawStatus".into(), "pending".into());
|
||||
let mut warnings = Vec::new();
|
||||
expand_penpot_raw_svgs(&mut doc, &mut warnings);
|
||||
let layer = doc.node(id).unwrap();
|
||||
assert!(layer.children.is_empty());
|
||||
assert_eq!(layer.plugin_data.get("penpot.svgRawStatus").map(String::as_str), Some("not-expanded"));
|
||||
assert!(!warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_inventory_reports_missing_families_without_rewriting_text() {
|
||||
let mut doc = Document::new("Font inventory");
|
||||
let page = doc.first_page().unwrap();
|
||||
let mut text = TextData::new("Example");
|
||||
text.style.font_family = "Definitely Never Installed V10 Font Q7".into();
|
||||
doc.create(page, NodeKind::Text(text), "Caption", 0.0, 0.0, 100.0, 24.0).unwrap();
|
||||
let report = font_report_with_families(&doc, &["Inter".into()]);
|
||||
assert_eq!(report["missing"], 1);
|
||||
assert_eq!(report["fonts"][0]["family"], "Definitely Never Installed V10 Font Q7");
|
||||
assert_eq!(report["fonts"][0]["layers"][0], "Caption");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,9 +88,9 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (loaded, rest) = load_path(&path, variables.as_ref())?;
|
||||
remember(s, &path);
|
||||
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
|
||||
let warnings = rest || ext(&path) == "svg";
|
||||
let warnings = rest || matches!(ext(&path).as_str(), "svg" | "penpot");
|
||||
// A native file that lost something (a damaged image, say): the app says so, not only the result.
|
||||
if native && !loaded.issues.is_empty() {
|
||||
if (native || warnings) && !loaded.issues.is_empty() {
|
||||
s.message = Some(match loaded.issues.as_slice() {
|
||||
[one] => format!("{}: {one}", file_stem(&path)),
|
||||
more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]),
|
||||
@@ -137,7 +137,7 @@ fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>)
|
||||
let data = data.split_once(',').map_or(data, |(_, b)| b);
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?;
|
||||
let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?;
|
||||
let warnings = rest || ext(&name) == "svg";
|
||||
let warnings = rest || matches!(ext(&name).as_str(), "svg" | "penpot");
|
||||
let summary = import_summary(&loaded.doc);
|
||||
s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
s.active = s.docs.len() - 1;
|
||||
@@ -183,7 +183,7 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
"figcraft" | "json" | "fig" | "sketch" => {
|
||||
"figcraft" | "json" | "fig" | "sketch" | "penpot" => {
|
||||
let resolver = path.as_deref().map(rest_images);
|
||||
let images: figcraft_format::rest::ImageResolver = match &resolver {
|
||||
Some(r) => r,
|
||||
@@ -193,8 +193,14 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?;
|
||||
let summary = import_summary(&other.doc);
|
||||
let pages = append_pages(s.doc_mut()?, &other.doc)?;
|
||||
if !other.issues.is_empty() {
|
||||
s.message = Some(match other.issues.as_slice() {
|
||||
[one] => format!("{}: {one}", file_stem(&name)),
|
||||
more => format!("{}: {} import warnings; first: {}", file_stem(&name), more.len(), more[0]),
|
||||
});
|
||||
}
|
||||
let mut out = json!({"pages": pages, "summary": summary});
|
||||
if rest {
|
||||
if rest || !other.issues.is_empty() {
|
||||
out["warnings"] = json!(other.issues);
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Image paints: adjustment presets, built in and saved in the file.
|
||||
//! Image paints: adjustment presets, built in and saved in the file, and Crop image.
|
||||
|
||||
use figcraft_color::{ImageFilters, PaintKind};
|
||||
use figcraft_doc::{Document, ImagePreset};
|
||||
use figcraft_color::{ImageFilters, ImageScaleMode, PaintKind};
|
||||
use figcraft_doc::{Document, ImagePreset, Node};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -34,6 +34,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
"image.crop",
|
||||
"Crop image",
|
||||
"{ids? (one layer), index? (an image fill; default its top visible one)} → {id, index}: the image fill switches to Crop, keeping the picture where it was, and the canvas enters crop mode on it",
|
||||
true,
|
||||
crop
|
||||
),
|
||||
spec!("image.presets", "Image presets", "{} → {builtIn: [{id, label, filters}], saved: [{name, filters}]}", false, |s, _| {
|
||||
let built_in: Vec<Value> = ImageFilters::PRESETS.iter().map(|(id, label, f)| json!({"id": id, "label": label, "filters": f})).collect();
|
||||
Ok(json!({"builtIn": built_in, "saved": s.doc()?.image_presets}))
|
||||
@@ -135,6 +142,41 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"changed": changed}))
|
||||
}
|
||||
|
||||
/// The index of a layer's top visible image fill (fills paint bottom to top).
|
||||
pub fn top_image_fill(n: &Node) -> Option<usize> {
|
||||
n.fills.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
|
||||
}
|
||||
|
||||
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "image.crop";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
// A selection with nothing to crop is refused as unavailable, not as bad input.
|
||||
let refuse = |why: &str| EngineError::Disabled(cmd.into(), why.into());
|
||||
let [id] = ids[..] else { return Err(refuse("select one layer to crop its image")) };
|
||||
let doc = s.doc()?;
|
||||
let n = doc.get(id)?;
|
||||
let index = match get(p, "index") {
|
||||
None | Some(Value::Null) => top_image_fill(n).ok_or_else(|| refuse("the layer has no image fill to crop"))?,
|
||||
Some(_) => req_index(cmd, p, "index")?,
|
||||
};
|
||||
let mut paint = n.fills.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?;
|
||||
let PaintKind::Image(img) = &mut paint.kind else { return Err(bad(cmd, "that fill is not an image")) };
|
||||
// An image already in Crop mode only enters crop mode: no edit, no undo step.
|
||||
if img.scale_mode != ImageScaleMode::Crop {
|
||||
let data = doc.images.get(&img.image).ok_or_else(|| bad(cmd, "the image's data is missing"))?;
|
||||
img.crop = Some(img.placement_crop(f64::from(data.width), f64::from(data.height), n.width, n.height));
|
||||
img.scale_mode = ImageScaleMode::Crop;
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if let Some(f) = n.fills.get_mut(index) {
|
||||
*f = paint;
|
||||
}
|
||||
// Like any paint edit, the layer leaves its paint style.
|
||||
n.styles.fill = None;
|
||||
}
|
||||
s.request_ui(cmd, json!({"id": id, "index": index}));
|
||||
Ok(json!({"id": id, "index": index}))
|
||||
}
|
||||
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "image.savePreset";
|
||||
let name: String = req_string(cmd, p, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
|
||||
|
||||
@@ -17,7 +17,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
|
||||
|
||||
/// Keys `node.set` understands (documented for agents).
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1 or \"50%\"), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, {$var: variable id or name}, or \"none\"/null: no fill), strokes, stroke (colour, {$var: variable id or name}, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
|
||||
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
|
||||
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
|
||||
@@ -387,11 +387,32 @@ fn paint_none(v: &Value) -> bool {
|
||||
v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))
|
||||
}
|
||||
|
||||
/// A top-level variable wrapper. Validate before `node.set` skips type-specific properties too,
|
||||
/// so an unsupported wrapper is always an error, even on a mixed selection.
|
||||
pub(crate) fn variable_prop<'a>(cmd: &str, key: &str, value: &'a Value) -> Result<Option<&'a str>> {
|
||||
let Some(o) = value.as_object().filter(|o| o.contains_key("$var")) else { return Ok(None) };
|
||||
if !matches!(key, "fill" | "stroke") {
|
||||
return Err(bad(cmd, format!("`{key}`: only `fill` and `stroke` take variables in props; use `variable.bind` for other fields")));
|
||||
}
|
||||
if o.len() != 1 {
|
||||
return Err(bad(cmd, format!("`{key}`: a `$var` wrapper takes only `$var`, with no extra keys")));
|
||||
}
|
||||
let name = o
|
||||
.get("$var")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}`: `$var` must be a variable id or name (a non-empty string)")))?;
|
||||
Ok(Some(name))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// bindings last so paints set in the same call don't detach them. `$var` wrappers also win over style links.
|
||||
fn apply_order(k: &str, v: &Value) -> u8 {
|
||||
if v.get("$var").is_some() {
|
||||
return 7;
|
||||
}
|
||||
match k {
|
||||
"path" | "width" | "height" | "w" | "h" => 0,
|
||||
"layout" | "layoutMode" => 1,
|
||||
@@ -1007,17 +1028,36 @@ fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// A `{$var}` wrapper on `fill` or `stroke`: the first paint binds to that variable.
|
||||
fn apply_variable(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result<bool> {
|
||||
let Some(name) = variable_prop(cmd, k, v)? else { return Ok(false) };
|
||||
let vid = super::variables::resolve_strict(cmd, doc, name)?;
|
||||
// All aliases of the first paint name one slot; other paint slots stay bound.
|
||||
super::styles::unbind_slot(doc.get_mut(id)?, k == "fill", 0);
|
||||
super::styles::bind_variable(cmd, doc, &[id], k, Some(&vid))?;
|
||||
// A paint bound through props follows the variable, not its former style. Keep this
|
||||
// local to `$var`, including when the style and variable happen to give the same colour.
|
||||
let n = doc.get_mut(id)?;
|
||||
if k == "fill" {
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 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));
|
||||
keys.sort_by_key(|(k, v)| apply_order(k, v));
|
||||
// Where a new `path` had its own box, for `x`/`y` in the same call (as `node.create` places it).
|
||||
let mut path_offset = (0.0, 0.0);
|
||||
for (k, v) in keys {
|
||||
let k = k.as_str();
|
||||
if apply_geometry(cmd, doc, id, props, k, v, &mut path_offset)?
|
||||
if apply_variable(cmd, doc, id, k, v)?
|
||||
|| apply_geometry(cmd, doc, id, props, k, v, &mut path_offset)?
|
||||
|| apply_look(cmd, doc, id, k, v)?
|
||||
|| apply_corners(cmd, doc, id, k, v)?
|
||||
|| apply_layout(cmd, doc, id, k, v)?
|
||||
|
||||
@@ -775,13 +775,20 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => Some(doc.variables.get(v).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such variable"))?),
|
||||
None => None,
|
||||
};
|
||||
bind_variable(cmd, doc, &ids, &field, vid.as_deref())?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Bind an already resolved variable id, or unbind, inside the caller's transaction. Props and
|
||||
/// `variable.bind` share type checks and paint alpha handling; normalisation applies the value.
|
||||
pub(crate) fn bind_variable(cmd: &str, doc: &mut Document, ids: &[NodeId], field: &str, vid: Option<&str>) -> Result<()> {
|
||||
// An instance's component property takes a variable of its type; other fields the types their
|
||||
// scopes belong to.
|
||||
if let Some(v) = vid.as_ref().and_then(|v| doc.variables.get(v)) {
|
||||
for i in &ids {
|
||||
if let Some(v) = vid.and_then(|v| doc.variables.get(v)) {
|
||||
for i in ids {
|
||||
match field.strip_prefix(crate::components::PROP_BINDING) {
|
||||
Some(name) => super::component::check_prop_binding(doc, *i, name, v.kind).map_err(|e| bad(cmd, e))?,
|
||||
None if !super::variables::field_takes(doc, *i, &field, v.kind) => {
|
||||
None if !super::variables::field_takes(doc, *i, field, v.kind) => {
|
||||
return Err(bad(cmd, format!("`{field}` doesn't take a {} variable", v.kind.id())));
|
||||
}
|
||||
None => {}
|
||||
@@ -789,22 +796,32 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
match &vid {
|
||||
let n = doc.get_mut(*i)?;
|
||||
match vid {
|
||||
Some(v) => {
|
||||
if n.bound.get(&field) != Some(v) {
|
||||
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true);
|
||||
if n.bound.get(field).map(String::as_str) != Some(v) {
|
||||
rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true);
|
||||
}
|
||||
n.bound.insert(field.clone(), v.clone());
|
||||
n.bound.insert(field.to_string(), v.to_string());
|
||||
}
|
||||
None => {
|
||||
if n.bound.remove(&field).is_some() {
|
||||
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false);
|
||||
if n.bound.remove(field).is_some() {
|
||||
rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove every binding key of the slot (`fill` and `fills/0/color` name the same one); true when
|
||||
/// one was there.
|
||||
pub(crate) fn unbind_slot(n: &mut figcraft_doc::Node, fills: bool, index: usize) -> bool {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let before = n.bound.len();
|
||||
n.bound.retain(|key, _| list_binding(key).is_none_or(|(l, i, field)| !(l == list && i == index && field == "color")));
|
||||
n.bound.len() != before
|
||||
}
|
||||
|
||||
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the
|
||||
|
||||
@@ -46,6 +46,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Props resolve an exact id first, then an unambiguous exact name or `Collection/name`. Compare
|
||||
/// whole names: both collection and variable names can contain `/`. Other commands keep their
|
||||
/// existing first-match lookup.
|
||||
pub(crate) fn resolve_strict(cmd: &str, doc: &Document, name: &str) -> Result<VariableId> {
|
||||
let vars = &doc.variables;
|
||||
if let Some(v) = vars.variables.iter().find(|v| v.id == name) {
|
||||
return Ok(v.id.clone());
|
||||
}
|
||||
let collections: std::collections::BTreeMap<_, _> = vars.collections.iter().map(|c| (c.id.as_str(), c.name.as_str())).collect();
|
||||
let mut candidates = std::collections::BTreeMap::new();
|
||||
for v in &vars.variables {
|
||||
let collection = collections.get(v.collection.as_str()).copied().unwrap_or(v.collection.as_str());
|
||||
let qualified = format!("{collection}/{}", v.name);
|
||||
if v.name == name || qualified == name {
|
||||
candidates.insert(v.id.as_str(), qualified);
|
||||
}
|
||||
}
|
||||
match candidates.keys().next().copied() {
|
||||
None => Err(bad(cmd, format!("no such variable `{name}`"))),
|
||||
Some(id) if candidates.len() == 1 => Ok(id.to_string()),
|
||||
Some(_) => {
|
||||
let mut names: Vec<_> = candidates.into_values().collect();
|
||||
names.sort();
|
||||
Err(bad(cmd, format!("ambiguous variable `{name}`: {} (use a variable id to choose)", names.join(", "))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first of `base`, `base 2`, `base 3`… that no collection is named.
|
||||
pub(crate) fn free_collection_name(doc: &Document, base: &str) -> String {
|
||||
(1..1000)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Comments from an AI agent (#1414): a command run as an agent (`Session::execute_as`, which the
|
||||
//! control channel's and MCP's `engine.execute {agent}` use) signs comments with the agent's own
|
||||
//! name and marks them as an agent's; the user's own comments stay as they were.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::comment::agent_author;
|
||||
use crate::live::initials;
|
||||
|
||||
fn as_agent(s: &mut Session, agent: &str, cmd: &str, p: Value) -> Value {
|
||||
s.execute_as(Some(agent), cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn thread(s: &mut Session, agent: Option<&str>) -> Value {
|
||||
s.execute_as(agent, "comment.list", &json!({"filter": "all"})).unwrap()["comments"][0].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_comments_carry_its_name_and_the_agent_marker() {
|
||||
let mut s = Session::new();
|
||||
let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "@Claude make this bigger"})).unwrap()["id"].as_u64().unwrap();
|
||||
as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "Done"}));
|
||||
let t = thread(&mut s, None);
|
||||
assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("You"), Some(false)), "the user's own comment is unchanged: {t}");
|
||||
let r = &t["replies"][0];
|
||||
assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Claude (agent)"), Some(true)), "{r}");
|
||||
let doc = s.doc().unwrap();
|
||||
let m = &doc.comment(c).unwrap().replies[0];
|
||||
assert!(m.agent && m.author == "Claude (agent)");
|
||||
// The agent acts as itself again later: no `author` on any call.
|
||||
as_agent(&mut s, "Claude", "comment.react", json!({"id": c, "emoji": "👍"}));
|
||||
assert_eq!(s.doc().unwrap().comment(c).unwrap().first.reactions[0].users, ["Claude (agent)"]);
|
||||
assert_eq!(as_agent(&mut s, "Claude", "comment.people", json!({}))["you"], "Claude (agent)");
|
||||
assert_eq!(s.execute("comment.people", &json!({})).unwrap()["you"], "You", "the user's name never changes");
|
||||
assert_eq!(s.agent, None, "the agent acts only for its own call");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_read_state_is_its_own() {
|
||||
let mut s = Session::new();
|
||||
let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "Look"})).unwrap()["id"].as_u64().unwrap();
|
||||
as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "On it"}));
|
||||
assert_eq!(thread(&mut s, None)["unread"], true, "the agent's reply is unread for the user");
|
||||
as_agent(&mut s, "Claude", "comment.markRead", json!({}));
|
||||
assert_eq!(thread(&mut s, None)["unread"], true, "the agent reading it doesn't read it for the user");
|
||||
assert_eq!(thread(&mut s, Some("Claude"))["unread"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_without_a_name_is_still_not_the_user() {
|
||||
let mut s = Session::new();
|
||||
as_agent(&mut s, "", "comment.add", json!({"x": 0, "y": 0, "text": "Hi"}));
|
||||
let t = thread(&mut s, None);
|
||||
assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("AI agent"), Some(true)), "{t}");
|
||||
// An explicit `author` still wins, and the message is still an agent's.
|
||||
as_agent(&mut s, "Claude", "comment.reply", json!({"id": t["id"], "text": "x", "author": "Helper"}));
|
||||
let r = &thread(&mut s, None)["replies"][0];
|
||||
assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Helper"), Some(true)), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_names_are_cleaned() {
|
||||
assert_eq!(agent_author(" Claude "), "Claude (agent)");
|
||||
assert_eq!(agent_author("\u{7}\n "), "AI agent");
|
||||
assert_eq!(agent_author("a\u{0}b"), "ab (agent)");
|
||||
let long = "x".repeat(500);
|
||||
assert_eq!(agent_author(&long).chars().count(), 64 + " (agent)".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_avatar_shows_its_names_initial() {
|
||||
assert_eq!(initials("Claude (agent)"), "C");
|
||||
assert_eq!(initials("AI agent"), "AA");
|
||||
assert_eq!(initials("Ada Lovelace"), "AL");
|
||||
assert_eq!(initials("(x) [y]"), "([", "no word starts with a letter: as before");
|
||||
assert_eq!(initials(" "), "?");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! `comment.list` reports the names each message @mentions, by the rule the thread view highlights
|
||||
//! them with: resolved against the file's people where it can be, and whether the name is you.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn mentions(s: &mut Session, p: Value) -> Vec<Value> {
|
||||
let l = s.execute("comment.list", &p).unwrap();
|
||||
let c = &l["comments"][0];
|
||||
[&c["mentions"], &c["replies"][0]["mentions"]].into_iter().cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_lists_the_names_it_mentions_and_not_an_email_address() {
|
||||
let mut s = Session::new();
|
||||
let id = s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Hi", "author": "Ada Lovelace"})).unwrap()["id"].as_u64().unwrap();
|
||||
let text = "@ada lovelace and @You: please check, or ask @Bob (mail a@b.com). Thanks @Ada Lovelace";
|
||||
s.execute("comment.reply", &json!({"id": id, "text": text, "author": "Sam"})).unwrap();
|
||||
let [first, reply] = <[Value; 2]>::try_from(mentions(&mut s, json!({}))).unwrap();
|
||||
assert_eq!(first, json!([]));
|
||||
assert_eq!(
|
||||
reply,
|
||||
json!([
|
||||
{"name": "Ada Lovelace", "known": true, "you": false},
|
||||
{"name": "You", "known": true, "you": true},
|
||||
{"name": "Bob", "known": false, "you": false},
|
||||
]),
|
||||
"two people the file knows (once each, as the file spells them), one name it doesn't, no email"
|
||||
);
|
||||
// `author` says whose list it is: then Ada is "you".
|
||||
let reply = mentions(&mut s, json!({"author": "Ada Lovelace"})).remove(1);
|
||||
assert_eq!(reply[0], json!({"name": "Ada Lovelace", "known": true, "you": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mention_rule_matches_the_highlighting_rule() {
|
||||
let people = ["Sam", "Sam Lee"];
|
||||
let found = figcraft_doc::mentions("@Sam Lee, @sam, x@Sam, @Zoe!", &people);
|
||||
let names: Vec<_> = found.iter().map(|(r, who)| (r.clone(), *who)).collect();
|
||||
assert_eq!(names, vec![(0..8, Some("Sam Lee")), (10..14, Some("Sam")), (23..27, None)]);
|
||||
assert_eq!(figcraft_doc::mention_ranges("@Sam Lee, @sam, x@Sam, @Zoe!", &people), vec![0..8, 10..14]);
|
||||
assert!(figcraft_doc::mentions("@ and a@b.com", &people).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Components can't contain their own instances, directly, through other components or through
|
||||
//! the variants of a set (#1084); files holding such a loop open with it broken.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine;
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::component_cycle;
|
||||
use crate::components::tests::{copy_of, ex, idv, kids, node};
|
||||
|
||||
/// A 100×50 component named `name` at (x, 0), holding a rectangle; returns its id.
|
||||
fn component(s: &mut Session, name: &str, x: f64) -> NodeId {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 50, "name": name})));
|
||||
ex(s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f}));
|
||||
idv(&ex(s, "component.create", json!({"ids": [f]})))
|
||||
}
|
||||
|
||||
fn instance(s: &mut Session, component: NodeId, parent: NodeId) -> NodeId {
|
||||
idv(&ex(s, "instance.create", json!({"component": component, "x": 20, "y": 20, "parent": parent})))
|
||||
}
|
||||
|
||||
/// `cmd` is refused with the loop explained, and the document is left as it was.
|
||||
fn refused(s: &mut Session, cmd: &str, p: Value) {
|
||||
let before = s.doc().unwrap().clone();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
let err = s.execute(cmd, &p).expect_err(&format!("{cmd} {p} should be refused")).to_string();
|
||||
assert!(err.contains("can't contain"), "{cmd}: {err}");
|
||||
assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "{cmd} left no undo step");
|
||||
}
|
||||
|
||||
/// Card holds a Badge instance; Badge holds an Icon instance. Returns (card, badge, icon, the
|
||||
/// Icon instance in Badge).
|
||||
fn chain(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
let icon = component(s, "Icon", 0.0);
|
||||
let badge = component(s, "Badge", 200.0);
|
||||
let card = component(s, "Card", 400.0);
|
||||
let in_badge = instance(s, icon, badge);
|
||||
instance(s, badge, card);
|
||||
(card, badge, icon, in_badge)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_and_indirect_loops_are_refused() {
|
||||
let mut s = Session::new();
|
||||
let (card, badge, _, in_badge) = chain(&mut s);
|
||||
// Directly, and through Badge: Card already shows Badge.
|
||||
refused(&mut s, "instance.create", json!({"component": card, "parent": card}));
|
||||
refused(&mut s, "instance.create", json!({"component": card, "parent": badge}));
|
||||
refused(&mut s, "instance.swap", json!({"ids": [in_badge], "component": card}));
|
||||
// A Card instance moved or pasted into Badge.
|
||||
let page = s.page().unwrap();
|
||||
let loose = instance(&mut s, card, page);
|
||||
refused(&mut s, "node.reparent", json!({"ids": [loose], "parent": badge}));
|
||||
ex(&mut s, "edit.copy", json!({"ids": [loose]}));
|
||||
refused(&mut s, "edit.paste", json!({"parent": badge}));
|
||||
// A copied main pastes as an instance of it.
|
||||
ex(&mut s, "edit.copy", json!({"ids": [card]}));
|
||||
refused(&mut s, "edit.paste", json!({"parent": badge}));
|
||||
assert!(component_cycle(s.doc().unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_an_instance_where_it_would_contain_itself_places_it_on_the_page() {
|
||||
let mut s = Session::new();
|
||||
let (card, badge, _, _) = chain(&mut s);
|
||||
let r = ex(&mut s, "instance.place", json!({"component": card, "x": 250, "y": 25}));
|
||||
assert_eq!(r["parent"], json!(s.page().unwrap()), "not into Badge: {r}");
|
||||
assert!(!s.doc().unwrap().is_ancestor(badge, idv(&r)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_swap_property_can_not_close_a_loop() {
|
||||
let mut s = Session::new();
|
||||
let (card, badge, icon, in_badge) = chain(&mut s);
|
||||
ex(&mut s, "component.addProperty", json!({"id": badge, "name": "Icon", "type": "instanceSwap", "default": icon, "layer": in_badge}));
|
||||
let in_card = kids(&s, card).into_iter().find(|k| matches!(node(&s, *k).kind, NodeKind::Instance { .. })).unwrap();
|
||||
refused(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": card}}));
|
||||
// Another component is fine.
|
||||
let star = component(&mut s, "Star", 600.0);
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": star}}));
|
||||
}
|
||||
|
||||
/// A set of two variants, "State=On" holding an instance of "State=Off". Returns (on, off, the Off
|
||||
/// instance in On).
|
||||
fn nested_variants(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||||
let on = component(s, "State=On", 0.0);
|
||||
let off = component(s, "State=Off", 200.0);
|
||||
ex(s, "component.combineVariants", json!({"ids": [on, off]}));
|
||||
let held = instance(s, off, on);
|
||||
(on, off, held)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variant_holds_a_sibling_but_no_loop_back_through_it() {
|
||||
let mut s = Session::new();
|
||||
let (on, off, held) = nested_variants(&mut s);
|
||||
assert!(component_cycle(s.doc().unwrap()).is_none(), "a sibling variant's instance is no loop");
|
||||
// Off leading back to On, directly or through another component, is one.
|
||||
refused(&mut s, "instance.create", json!({"component": on, "parent": off}));
|
||||
let row = component(&mut s, "Row", 600.0);
|
||||
instance(&mut s, on, row);
|
||||
refused(&mut s, "instance.create", json!({"component": row, "parent": off}));
|
||||
// So is switching the held instance to the variant holding it.
|
||||
refused(&mut s, "instance.setProperties", json!({"ids": [held], "properties": {"State": "On"}}));
|
||||
// Combining a component with one holding its instance is fine too.
|
||||
let a = component(&mut s, "A", 0.0);
|
||||
let b = component(&mut s, "B", 200.0);
|
||||
instance(&mut s, a, b);
|
||||
ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}));
|
||||
assert!(component_cycle(s.doc().unwrap()).is_none());
|
||||
}
|
||||
|
||||
/// `doc` saved and opened again: the session and what opening it reported.
|
||||
fn reopened(doc: &Document) -> (Session, Value) {
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(figcraft_format::save_bytes(doc, None).unwrap());
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "file.open", json!({"data": data, "name": "variants.figcraft"}));
|
||||
(s, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_nested_sibling_variants_opens_unchanged_and_a_loop_through_them_is_repaired() {
|
||||
let mut s = Session::new();
|
||||
let (on, off, held) = nested_variants(&mut s);
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let (t, r) = reopened(&doc);
|
||||
assert_eq!(r["repairs"], json!([]), "{r}");
|
||||
assert_eq!(t.doc().unwrap().nodes().count(), doc.nodes().count());
|
||||
assert!(matches!(node(&t, held).kind, NodeKind::Instance { component, .. } if component == off));
|
||||
// On → Off → On: an On instance in Off, written directly as an older file could have it.
|
||||
let spare = component(&mut s, "Spare", 600.0);
|
||||
let back = instance(&mut s, spare, off);
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
if let NodeKind::Instance { component, .. } = &mut doc.get_mut(back).unwrap().kind {
|
||||
*component = on;
|
||||
}
|
||||
assert!(component_cycle(&doc).is_some());
|
||||
let (t, r) = reopened(&doc);
|
||||
assert!(r["repairs"].as_array().is_some_and(|n| n.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached")))), "{r}");
|
||||
assert!(component_cycle(t.doc().unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_around_instances_and_instances_in_slots_stay_allowed() {
|
||||
let mut s = Session::new();
|
||||
let (card, badge, ..) = chain(&mut s);
|
||||
// A new component around a Card instance.
|
||||
let page = s.page().unwrap();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100, "parent": page})));
|
||||
instance(&mut s, card, f);
|
||||
let holder = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
instance(&mut s, badge, holder);
|
||||
// An instance in a slot of an instance of its own component: the slot holds that instance's
|
||||
// own content, outside its main.
|
||||
let slot = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": card, "name": "Slot"})));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": slot}));
|
||||
let outer = instance(&mut s, card, page);
|
||||
let outer_slot = copy_of(&s, outer, slot);
|
||||
instance(&mut s, card, outer_slot);
|
||||
assert!(component_cycle(s.doc().unwrap()).is_none());
|
||||
}
|
||||
|
||||
/// A document where Card holds a Badge instance and Badge a Card instance, made by editing the
|
||||
/// layers directly (as an older file or a merge could have it). Returns it with (card, badge).
|
||||
fn looped() -> (Document, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let (card, badge, _, in_badge) = chain(&mut s);
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
if let NodeKind::Instance { component, .. } = &mut doc.get_mut(in_badge).unwrap().kind {
|
||||
*component = card;
|
||||
}
|
||||
crate::components::sync_instance(&mut doc, in_badge);
|
||||
assert!(component_cycle(&doc).is_some());
|
||||
(doc, card, badge)
|
||||
}
|
||||
|
||||
/// Edit mains `a` and `b` `n` times, every instance rebuilt in full each time; the layer count
|
||||
/// after each round.
|
||||
fn edit_mains(s: &mut Session, a: NodeId, b: NodeId, n: usize) -> Vec<usize> {
|
||||
crate::components::FULL_REBUILD.with(|f| f.set(true));
|
||||
let sizes = (0..n)
|
||||
.map(|i| {
|
||||
ex(s, "node.set", json!({"id": a, "props": {"fill": format!("#{:02x}0000", i * 20)}}));
|
||||
ex(s, "node.set", json!({"id": b, "props": {"fill": format!("#00{:02x}00", i * 20)}}));
|
||||
s.doc().unwrap().nodes().count()
|
||||
})
|
||||
.collect();
|
||||
crate::components::FULL_REBUILD.with(|f| f.set(false));
|
||||
sizes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_a_loop_opens_with_it_broken_and_a_note() {
|
||||
let (doc, card, badge) = looped();
|
||||
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "file.open", json!({"data": data, "name": "looped.figcraft"}));
|
||||
let notes = r["repairs"].as_array().cloned().unwrap_or_default();
|
||||
assert!(notes.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached"))), "{r}");
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(component_cycle(doc).is_none());
|
||||
// The detached instance kept its layers; the other side is still an instance.
|
||||
assert!(doc.contains(card) && doc.contains(badge));
|
||||
let count = doc.nodes().count();
|
||||
// Editing the mains no longer grows the file.
|
||||
assert_eq!(edit_mains(&mut s, card, badge, 10), vec![count; 10]);
|
||||
// Importing it into another file breaks it the same way.
|
||||
let mut t = Session::new();
|
||||
let r = ex(&mut t, "file.import", json!({"data": data, "name": "looped.figcraft"}));
|
||||
assert!(r["warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
|
||||
assert!(component_cycle(t.doc().unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_merged_in_stops_expanding_and_the_document_stays_editable() {
|
||||
let (doc, card, badge) = looped();
|
||||
let mut s = Session::new();
|
||||
let uid = s.ds().unwrap().uid;
|
||||
s.apply_remote(uid, Arc::new(doc));
|
||||
assert!(s.message.as_deref().is_some_and(|m| m.contains("contain each other")), "{:?}", s.message);
|
||||
let sizes = edit_mains(&mut s, card, badge, 12);
|
||||
// A couple of rebuilds settle the copies; after that nothing grows.
|
||||
assert!(sizes.windows(2).skip(2).all(|w| w[0] == w[1]), "{sizes:?}");
|
||||
// Editing goes on: layers added to a main reach its instances.
|
||||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4, "parent": card}));
|
||||
let page = s.page().unwrap();
|
||||
let loose = instance(&mut s, badge, page);
|
||||
assert!(!kids(&s, loose).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! `edit.duplicate` with `map: true` (#995): every original layer, descendants included, paired
|
||||
//! with its copy, so a caller can edit inside a copy without inspecting it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn idv(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// A "Screen" frame holding an instance of a "Card" component (with a "Label" sublayer), a group
|
||||
/// of two rectangles and a nested frame with a text; returns (screen, card).
|
||||
fn screen(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let card = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 80, "height": 40, "name": "Card"})));
|
||||
ex(s, "text.create", json!({"text": "Hi", "x": 4, "y": 4, "parent": card, "props": {"name": "Label"}}));
|
||||
let card = idv(&ex(s, "component.create", json!({"ids": [card]})));
|
||||
let scr = idv(&ex(s, "frame.create", json!({"x": 200, "y": 0, "width": 300, "height": 300, "name": "Screen"})));
|
||||
ex(s, "instance.create", json!({"component": card, "x": 210, "y": 10, "parent": scr}));
|
||||
let r = |s: &mut Session, y: f64| idv(&ex(s, "shape.rectangle", json!({"x": 210, "y": y, "width": 20, "height": 20, "parent": scr})));
|
||||
let (a, b) = (r(s, 100.0), r(s, 130.0));
|
||||
ex(s, "object.group", json!({"ids": [a, b]}));
|
||||
let inner = idv(&ex(s, "frame.create", json!({"x": 250, "y": 100, "width": 100, "height": 100, "parent": scr, "name": "Inner"})));
|
||||
ex(s, "text.create", json!({"text": "Body", "x": 260, "y": 110, "parent": inner}));
|
||||
(scr, card)
|
||||
}
|
||||
|
||||
/// The child indexes from `root` down to `id`.
|
||||
fn path(doc: &Document, root: NodeId, id: NodeId) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
let mut at = id;
|
||||
while at != root {
|
||||
out.push(doc.index_in_parent(at).unwrap());
|
||||
at = doc.node(at).unwrap().parent.unwrap();
|
||||
}
|
||||
out.reverse();
|
||||
out
|
||||
}
|
||||
|
||||
fn pairs(out: &Value) -> Vec<(NodeId, NodeId)> {
|
||||
out["map"].as_object().unwrap().iter().map(|(k, v)| (NodeId(k.parse().unwrap()), id_of(v).unwrap())).collect()
|
||||
}
|
||||
|
||||
/// The map pairs every layer of each original's subtree with the layer at the same place in its
|
||||
/// copy, of the same type and name.
|
||||
fn assert_complete(s: &Session, roots: &[(NodeId, NodeId)], out: &Value) {
|
||||
let doc = s.doc().unwrap();
|
||||
let map = pairs(out);
|
||||
let all: usize = roots.iter().map(|(o, _)| doc.subtree(*o).len()).sum();
|
||||
assert_eq!(map.len(), all, "every original layer, descendants included, is in the map: {out}");
|
||||
for (o, c) in map {
|
||||
let (root_o, root_c) = roots.iter().find(|(r, _)| doc.subtree(*r).contains(&o)).copied().unwrap();
|
||||
assert!(doc.subtree(root_c).contains(&c), "{c} is inside the copy of {root_o}");
|
||||
assert_eq!(path(doc, root_o, o), path(doc, root_c, c), "{c} sits where {o} does");
|
||||
let (a, b) = (doc.node(o).unwrap(), doc.node(c).unwrap());
|
||||
assert_eq!(a.name, b.name, "{o} → {c}");
|
||||
// A main component's copy is an instance of it (the tests check the roots' types).
|
||||
if o != root_o {
|
||||
assert_eq!(a.kind.type_name(), b.kind.type_name(), "{o} → {c}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_pairs_nested_frames_groups_and_instances_with_their_copies() {
|
||||
let mut s = Session::new();
|
||||
let (scr, _) = screen(&mut s);
|
||||
let out = ex(&mut s, "edit.duplicate", json!({"ids": [scr], "map": true}));
|
||||
let copy = id_of(&out["ids"][0]).unwrap();
|
||||
assert_eq!(out["map"][scr.to_string()], json!(copy), "the root maps to the copy in `ids`");
|
||||
assert_complete(&s, &[(scr, copy)], &out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_covers_several_roots_and_a_main_component_copied_as_an_instance() {
|
||||
let mut s = Session::new();
|
||||
let (scr, card) = screen(&mut s);
|
||||
let out = ex(&mut s, "edit.duplicate", json!({"ids": [card, scr], "map": true}));
|
||||
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().map(|v| id_of(v).unwrap()).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
let (card_copy, scr_copy) = (out["map"][card.to_string()].clone(), out["map"][scr.to_string()].clone());
|
||||
assert!(ids.contains(&id_of(&card_copy).unwrap()) && ids.contains(&id_of(&scr_copy).unwrap()), "{out}");
|
||||
let doc = s.doc().unwrap();
|
||||
let card_copy = id_of(&card_copy).unwrap();
|
||||
assert_eq!(doc.node(card_copy).unwrap().kind.type_name(), "INSTANCE", "a main component's copy is an instance");
|
||||
assert_complete(&s, &[(card, card_copy), (scr, id_of(&scr_copy).unwrap())], &out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_map_the_result_is_just_the_ids() {
|
||||
let mut s = Session::new();
|
||||
let (scr, _) = screen(&mut s);
|
||||
for p in [json!({"ids": [scr]}), json!({"ids": [scr], "map": false})] {
|
||||
let out = ex(&mut s, "edit.duplicate", p);
|
||||
let keys: Vec<&String> = out.as_object().unwrap().keys().collect();
|
||||
assert_eq!(keys, ["ids"], "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_edits_inside_the_copy_through_the_map() {
|
||||
let mut s = Session::new();
|
||||
let (scr, _) = screen(&mut s);
|
||||
let doc = s.doc().unwrap();
|
||||
let inner = *doc.node(scr).unwrap().children.last().unwrap();
|
||||
let body = doc.node(inner).unwrap().children[0];
|
||||
let field = format!("map/{body}");
|
||||
ex(
|
||||
&mut s,
|
||||
"command.batch",
|
||||
json!({"commands": [
|
||||
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [scr], "map": true}},
|
||||
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": field}], "props": {"text": "Copy"}}},
|
||||
]}),
|
||||
);
|
||||
let doc = s.doc().unwrap();
|
||||
let text = |id: NodeId| match &doc.node(id).unwrap().kind {
|
||||
figcraft_doc::NodeKind::Text(t) => t.characters.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
assert_eq!(text(body), "Body", "the original is untouched");
|
||||
let copy = s.selection()[0];
|
||||
let copy_body = doc.subtree(copy).into_iter().find(|i| path(doc, copy, *i) == path(doc, scr, body)).unwrap();
|
||||
assert_eq!(text(copy_body), "Copy", "the edit landed in the copy");
|
||||
}
|
||||
@@ -169,6 +169,58 @@ fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) {
|
||||
s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap();
|
||||
}
|
||||
|
||||
/// Drag from `from` to `to` in four steps, Alt held at step `k` when `alt(k)` (0: the press, 1–4:
|
||||
/// the moves, 5: the release).
|
||||
fn drag_alt_when(s: &mut Session, from: (f64, f64), to: (f64, f64), alt: impl Fn(u8) -> bool) {
|
||||
let ev = |k: u8, kind, (x, y): (f64, f64)| PointerEvent { alt: alt(k), ..PointerEvent::at(kind, x, y, 1.0) };
|
||||
s.pointer(&ev(0, PointerKind::Down, from)).unwrap();
|
||||
for k in 1..=4 {
|
||||
let t = f64::from(k) / 4.0;
|
||||
s.pointer(&ev(k, PointerKind::Move, (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t))).unwrap();
|
||||
}
|
||||
s.pointer(&ev(5, PointerKind::Up, to)).unwrap();
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> usize {
|
||||
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len()
|
||||
}
|
||||
|
||||
fn last_step(s: &Session) -> String {
|
||||
s.ds().unwrap().history.undo.last().map(|e| e.label.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_pressed_during_a_drag_turns_the_move_into_a_copy() {
|
||||
// #1385: as in other design apps, Alt counts whenever it goes down, not only at the press.
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
// Alt from the third step: the original goes back, a copy lands where the drag ends and snaps
|
||||
// to the original (2 px below its top edge).
|
||||
drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k >= 3);
|
||||
let copy = s.selection()[0];
|
||||
assert_ne!(copy, a);
|
||||
assert_eq!((at(&s, a), at(&s, copy)), ((0.0, 0.0), (200.0, 0.0)));
|
||||
assert_eq!((layers(&s), last_step(&s).as_str()), (2, "Duplicate"));
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!((layers(&s), at(&s, a)), (1, (0.0, 0.0)), "one undo step");
|
||||
// Let go of Alt during the drag: no copy, the original moves (and no longer snaps to where it
|
||||
// started, as only a copy does).
|
||||
drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k < 3);
|
||||
assert_eq!((layers(&s), s.selection(), at(&s, a)), (1, vec![a], (200.0, 2.0)));
|
||||
assert_eq!(last_step(&s), "Move");
|
||||
// Down and up again during one drag: a copy, as Alt is held at the end.
|
||||
s.undo().unwrap();
|
||||
drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k == 1 || k >= 4);
|
||||
assert_eq!((layers(&s), at(&s, a)), (2, (0.0, 0.0)));
|
||||
// Alt changed only by the release (no move in between): the release decides.
|
||||
s.undo().unwrap();
|
||||
drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k == 5);
|
||||
assert_eq!((layers(&s), at(&s, a), last_step(&s).as_str()), (2, (0.0, 0.0), "Duplicate"), "Alt down at the release: a copy");
|
||||
s.undo().unwrap();
|
||||
drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k < 5);
|
||||
assert_eq!((layers(&s), at(&s, a), last_step(&s).as_str()), (1, (200.0, 2.0), "Move"), "Alt up at the release: a move");
|
||||
}
|
||||
|
||||
/// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests
|
||||
/// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done,
|
||||
/// and the step is measured from where the copy ended up, whichever way it got there.
|
||||
|
||||
@@ -187,6 +187,23 @@ fn contents_only_leaves_out_overlapping_layers() {
|
||||
assert_eq!(pixels(&data).get_pixel(75, 75).0, [0, 0, 255, 255], "a layer's own setting is honoured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn penpot_v20_export_limits_shadows_in_all_directions() {
|
||||
let mut session = Session::new();
|
||||
for (index, offset, size) in
|
||||
[((0.0, 0.0), (-12.0, -7.0), (112, 107)), ((200.0, 0.0), (13.0, -9.0), (113, 109)), ((400.0, 0.0), (-3.0, 14.0), (103, 114))]
|
||||
{
|
||||
let r = rect(&mut session, index.0, index.1, 100.0, 100.0, "#FF0000", None);
|
||||
shadow(&mut session, r, offset, 0.0, 0.0);
|
||||
assert_eq!(png_size(&export(&mut session, json!({"ids":[r]}))), size, "a sharp offset shadow must extend only the touched export edges");
|
||||
assert_eq!(
|
||||
png_size(&export(&mut session, json!({"ids":[r], "includeBoundingBox": true}))),
|
||||
(100, 100),
|
||||
"explicit bounding-box exports must not grow due to a shadow"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_an_export_covers() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -19,6 +19,8 @@ pub struct Missing {
|
||||
pub family: String,
|
||||
/// Text layers that use it (0: only a text style or a variable names it).
|
||||
pub layers: usize,
|
||||
/// The font store source it can be downloaded from (`Google Fonts`, `Noto`; desktop only).
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
/// The families `doc` names that the font database doesn't have, by name. On the desktop the
|
||||
@@ -61,9 +63,45 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
|
||||
}
|
||||
}
|
||||
}
|
||||
used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect()
|
||||
used.into_values()
|
||||
.filter(|(family, _)| !figcraft_text::has_family(family))
|
||||
.map(|(family, layers)| Missing { source: source(&family), family, layers })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A family's download ([`downloads`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Download {
|
||||
/// Bytes downloaded of all its files.
|
||||
Running { done: u64, total: u64 },
|
||||
/// Installed and scanned: the next `font.download` of it fits the open document's text.
|
||||
Installed,
|
||||
/// Why it failed (nothing was installed).
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use download::{allow_downloads, downloads, downloads_allowed, source};
|
||||
|
||||
/// The browser keeps its built-in fonts: nothing is downloaded there.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod no_download {
|
||||
pub fn source(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
pub fn downloads() -> Vec<(String, super::Download)> {
|
||||
Vec::new()
|
||||
}
|
||||
pub fn downloads_allowed() -> bool {
|
||||
false
|
||||
}
|
||||
pub fn allow_downloads() -> std::result::Result<(), String> {
|
||||
Err("fonts aren't downloaded in the browser".into())
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use no_download::{allow_downloads, downloads, downloads_allowed, source};
|
||||
|
||||
/// Every string value of variable `id` in any mode, following aliases (each variable once).
|
||||
fn string_values(vars: &Variables, id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
@@ -134,18 +172,19 @@ fn refit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
|
||||
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers, "source": m.source})).collect();
|
||||
s.request_ui("dialog.missingFonts", json!({}));
|
||||
Ok(json!({"families": families}))
|
||||
}
|
||||
|
||||
/// The font commands: `text.missingFonts` and `text.refit`.
|
||||
/// The font commands: `text.missingFonts`, `text.refit`, and on the desktop [`download`]'s.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
#[allow(unused_mut)]
|
||||
let mut list = vec![
|
||||
spec!(
|
||||
"text.missingFonts",
|
||||
"Missing fonts",
|
||||
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
|
||||
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers, source (the font store source font.download gets it from, or null)}]}",
|
||||
false,
|
||||
missing_fonts
|
||||
),
|
||||
@@ -156,8 +195,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true,
|
||||
refit_text
|
||||
),
|
||||
]
|
||||
];
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
list.extend(download::specs());
|
||||
list
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod download;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Downloading a missing font (#754) through the suite's font store, `craft-font-store`: its
|
||||
//! pinned Google Fonts and Noto indexes (compiled in) tell which families can be had, and it
|
||||
//! downloads their files, checks each one's size and sha256 and installs them into the one folder
|
||||
//! every Craft app's font scan reads. Desktop only.
|
||||
//!
|
||||
//! Downloading is off until the user turns it on, in the app's own window ([`allow_downloads`],
|
||||
//! which is not a command). The commands drive the store as its agent, as the store's rules
|
||||
//! have it: they install while the user has downloading on, are refused while it is off, and can
|
||||
//! never turn it on.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
use craft_font_catalog::{FontEnv, font_store_dir};
|
||||
use craft_font_store::{Caller, Cancel, FontStore, Index, Network};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::Download;
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::req_string;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// Lower-case family → (spelling, download). Installs are the machine's, so the jobs are the
|
||||
/// process's, whichever document asked.
|
||||
static JOBS: Mutex<BTreeMap<String, (String, Download)>> = Mutex::new(BTreeMap::new());
|
||||
/// Set by [`use_store_dir`].
|
||||
static STORE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
fn jobs() -> std::sync::MutexGuard<'static, BTreeMap<String, (String, Download)>> {
|
||||
JOBS.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn set(family: &str, state: Download) {
|
||||
if let Some(job) = jobs().get_mut(&family.to_lowercase()) {
|
||||
job.1 = state;
|
||||
}
|
||||
}
|
||||
|
||||
/// Use `dir` as the font store instead of the suite's folder, for this process (tests: they never
|
||||
/// read the user's setting). The first call wins.
|
||||
pub fn use_store_dir(dir: PathBuf) {
|
||||
let _ = STORE_DIR.set(dir);
|
||||
}
|
||||
|
||||
fn store_dir() -> Option<PathBuf> {
|
||||
if let Some(dir) = STORE_DIR.get() {
|
||||
return Some(dir.clone());
|
||||
}
|
||||
// This crate's tests never touch the user's store.
|
||||
if cfg!(test) {
|
||||
return Some(std::env::temp_dir().join(format!("figcraft-font-store-tests-{}", std::process::id())));
|
||||
}
|
||||
font_store_dir(&FontEnv::from_process())
|
||||
}
|
||||
|
||||
fn store(caller: Caller) -> Option<FontStore> {
|
||||
Some(FontStore::new(store_dir()?, caller))
|
||||
}
|
||||
|
||||
/// The indexes, read once: the built-in ones compiled in and the sources the user added as last
|
||||
/// fetched. No request.
|
||||
fn index() -> &'static Index {
|
||||
static INDEX: OnceLock<Index> = OnceLock::new();
|
||||
INDEX.get_or_init(|| store(Caller::Agent).map(|s| s.offline_indexes()).unwrap_or_else(|| Index::bundled().unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// The name of the source `family` can be downloaded from (`Google Fonts`, `Noto`), if any.
|
||||
pub fn source(family: &str) -> Option<String> {
|
||||
let index = index();
|
||||
index.family(family.trim()).and_then(|f| index.source_of(f)).map(|s| s.name.clone())
|
||||
}
|
||||
|
||||
/// Whether the user turned downloading fonts on (the setting is the store's, shared by every
|
||||
/// Craft app).
|
||||
pub fn downloads_allowed() -> bool {
|
||||
store(Caller::Agent).is_some_and(|s| s.network() == Network::On)
|
||||
}
|
||||
|
||||
/// Turn downloading fonts on. Only from the user's own action in the app's interface (a click in
|
||||
/// the Missing fonts window), never from a command: agents can't.
|
||||
pub fn allow_downloads() -> std::result::Result<(), String> {
|
||||
store(Caller::User).ok_or_else(|| "this system has no font store folder".to_string())?.enable_network().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The downloads of this process: (family, state).
|
||||
pub fn downloads() -> Vec<(String, Download)> {
|
||||
jobs().values().cloned().collect()
|
||||
}
|
||||
|
||||
fn state_json(family: &str, state: &Download) -> Value {
|
||||
match state {
|
||||
Download::Running { done, total } => json!({"family": family, "state": "downloading", "done": done, "total": total}),
|
||||
Download::Installed => json!({"family": family, "state": "installed"}),
|
||||
Download::Failed(error) => json!({"family": family, "state": "failed", "error": error}),
|
||||
}
|
||||
}
|
||||
|
||||
/// `font.download`: start downloading `family`'s files on a thread of its own, or report the
|
||||
/// download under way; a family that is there now fits the open document's text to it instead.
|
||||
fn download(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "font.download";
|
||||
let family = req_string(CMD, p, "family")?.trim().to_string();
|
||||
let key = family.to_lowercase();
|
||||
let state = jobs().get(&key).map(|(_, st)| st.clone());
|
||||
if let Some(running @ Download::Running { .. }) = &state {
|
||||
return Ok(state_json(&family, running));
|
||||
}
|
||||
// Installed: even when no scan sees it (system fonts opted out), it isn't downloaded again.
|
||||
if state == Some(Download::Installed) || figcraft_text::has_family(&family) {
|
||||
jobs().remove(&key);
|
||||
let refitted = crate::fonts::refit_open(s, None, Some(std::slice::from_ref(&family)));
|
||||
return Ok(json!({"family": family, "state": "installed", "refitted": refitted}));
|
||||
}
|
||||
let index = index();
|
||||
let found = index.family(&family).ok_or_else(|| bad(CMD, format!("{family} is not in the font store's index")))?;
|
||||
let store = store(Caller::Agent).ok_or_else(|| EngineError::Other("this system has no font store folder".into()))?;
|
||||
if store.network() != Network::On {
|
||||
return Err(EngineError::Disabled(CMD.into(), "downloading fonts is turned off; the user turns it on in Text ▸ Missing fonts".into()));
|
||||
}
|
||||
let total: u64 = found.files.iter().map(|f| f.size).sum();
|
||||
let source = index.source_of(found).map(|src| src.name.clone());
|
||||
jobs().insert(key, (found.family.clone(), Download::Running { done: 0, total }));
|
||||
let name = found.family.clone();
|
||||
let spawned = std::thread::Builder::new().name("figcraft-font-download".into()).spawn(move || {
|
||||
let result = store.install_family(index, found, &Cancel::new(), &mut |p| set(&name, Download::Running { done: p.done, total: p.total }));
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// The new files are scanned here, off the interface's thread.
|
||||
figcraft_text::rescan_system_fonts();
|
||||
set(&name, Download::Installed);
|
||||
}
|
||||
Err(e) => set(&name, Download::Failed(e.to_string())),
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
jobs().remove(&family.to_lowercase());
|
||||
return Err(EngineError::Other(format!("couldn't start the download: {e}")));
|
||||
}
|
||||
Ok(json!({"family": found.family, "source": source, "state": "downloading", "done": 0, "total": total, "files": found.files.len()}))
|
||||
}
|
||||
|
||||
/// The font commands that download: `font.download` and `font.downloads`.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"font.download",
|
||||
"Download font",
|
||||
"{family} downloads a missing font family from the font store (Google Fonts, Noto: pinned, checksum-verified files installed into the Craft apps' shared font folder) on a thread of its own; refused while the user has downloading turned off (only the user turns it on, in Text ▸ Missing fonts). Call again to see its progress; once it is installed, the call fits the open document's text to it (text.refit) → {family, state: downloading|installed, done?, total?, refitted?}",
|
||||
true,
|
||||
download
|
||||
),
|
||||
spec!(
|
||||
"font.downloads",
|
||||
"Font downloads",
|
||||
"{} this session's font downloads and whether downloading fonts is turned on → {allowed, downloads: [{family, state: downloading|installed|failed, done?, total?, error?}]}",
|
||||
false,
|
||||
|_, _| {
|
||||
let list: Vec<Value> = downloads().iter().map(|(f, st)| state_json(f, st)).collect();
|
||||
Ok(json!({"allowed": downloads_allowed(), "downloads": list}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "download_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `font.download` without the network: this crate's tests use a store folder of their own, where
|
||||
//! downloading was never turned on.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{Download, downloads, downloads_allowed, jobs, set, source};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
#[test]
|
||||
fn the_store_index_says_which_missing_families_can_be_downloaded() {
|
||||
assert_eq!(source("Lato").as_deref(), Some("Google Fonts"));
|
||||
assert_eq!(source(" lato ").as_deref(), Some("Google Fonts"), "as the document spells it");
|
||||
assert_eq!(source("FigCraft Not A Store Font"), None);
|
||||
|
||||
let mut s = Session::new();
|
||||
for family in ["FigCraft Not A Store Font", "Lato"] {
|
||||
s.execute("text.create", &json!({"text": "Hamburgefonstiv", "props": {"fontFamily": family}})).unwrap();
|
||||
}
|
||||
let listed = s.execute("text.missingFonts", &json!({})).unwrap();
|
||||
let ours = |name: &str| listed["families"].as_array().unwrap().iter().find(|f| f["family"] == name).cloned();
|
||||
assert_eq!(ours("FigCraft Not A Store Font").unwrap()["source"], Value::Null);
|
||||
if !figcraft_text::has_family("Lato") {
|
||||
assert_eq!(ours("Lato").unwrap()["source"], "Google Fonts");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_downloads_until_the_user_turns_downloading_on() {
|
||||
let mut s = Session::new();
|
||||
assert!(!downloads_allowed(), "off by default");
|
||||
let Some(family) = ["Lato", "Merriweather", "Bungee Shade"].into_iter().find(|f| !figcraft_text::has_family(f)) else { return };
|
||||
let refused = s.execute("font.download", &json!({"family": family})).unwrap_err();
|
||||
assert!(matches!(&refused, EngineError::Disabled(cmd, why) if cmd == "font.download" && why.contains("turned off")), "{refused}");
|
||||
assert!(!downloads().iter().any(|(f, _)| f == family), "no download started");
|
||||
assert_eq!(s.execute("font.downloads", &json!({})).unwrap()["allowed"], false);
|
||||
|
||||
assert!(matches!(s.execute("font.download", &json!({})), Err(EngineError::BadParams { .. })));
|
||||
let unknown = s.execute("font.download", &json!({"family": "FigCraft Not A Store Font"})).unwrap_err();
|
||||
assert!(unknown.to_string().contains("not in the font store's index"), "{unknown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_download_reports_its_progress_then_fits_the_text_once_installed() {
|
||||
let family = "FigCraft Download Progress";
|
||||
let mut s = Session::new();
|
||||
jobs().insert(family.to_lowercase(), (family.into(), Download::Running { done: 50, total: 200 }));
|
||||
let r = s.execute("font.download", &json!({"family": family})).unwrap();
|
||||
assert_eq!(r, json!({"family": family, "state": "downloading", "done": 50, "total": 200}), "no second download");
|
||||
let listed = s.execute("font.downloads", &json!({})).unwrap();
|
||||
assert!(listed["downloads"].as_array().unwrap().contains(&json!({"family": family, "state": "downloading", "done": 50, "total": 200})));
|
||||
|
||||
set(family, Download::Failed("downloading x failed".into()));
|
||||
let listed = s.execute("font.downloads", &json!({})).unwrap();
|
||||
assert!(listed["downloads"].as_array().unwrap().contains(&json!({"family": family, "state": "failed", "error": "downloading x failed"})));
|
||||
|
||||
// Installed (and scanned by the download's thread): the next call fits the text, once.
|
||||
set(family, Download::Installed);
|
||||
let r = s.execute("font.download", &json!({"family": family})).unwrap();
|
||||
assert_eq!(r, json!({"family": family, "state": "installed", "refitted": []}));
|
||||
assert!(!downloads().iter().any(|(f, _)| f == family));
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! `image.crop` (Object ▸ Crop image, the right-click menu and the image fill row): the layer's
|
||||
//! top visible image fill (or the one `index` names) switches to Crop with the picture left where
|
||||
//! its old mode drew it, as one undo step, and the canvas is asked to enter crop mode. A layer
|
||||
//! without an image fill, or several layers, are refused with a reason and nothing changes.
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_color::{ImageScaleMode, Paint, PaintKind};
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// A 200×100 synthetic image layer (every pixel differs), resized to 200×150 so Fill and Fit
|
||||
/// place the picture differently.
|
||||
fn image_layer(s: &mut Session) -> NodeId {
|
||||
let image = image::RgbaImage::from_fn(200, 100, |x, y| image::Rgba([x as u8, y as u8, 120, 255]));
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes.into_inner());
|
||||
let id = id_of(&ex(s, "file.import", json!({"data": data, "name": "crop.png", "x": 0, "y": 0}))["id"]).unwrap();
|
||||
ex(s, "node.set", json!({"ids": [id], "props": {"height": 150}}));
|
||||
id
|
||||
}
|
||||
|
||||
fn fills(s: &Session, id: NodeId) -> Vec<Paint> {
|
||||
s.doc().unwrap().node(id).unwrap().fills.clone()
|
||||
}
|
||||
|
||||
fn set_mode(s: &mut Session, id: NodeId, mode: ImageScaleMode) {
|
||||
let mut f = fills(s, id);
|
||||
let PaintKind::Image(img) = &mut f[0].kind else { panic!("image fill") };
|
||||
img.scale_mode = mode;
|
||||
ex(s, "node.set", json!({"ids": [id], "props": {"fills": f}}));
|
||||
}
|
||||
|
||||
fn pixels(s: &Session, id: NodeId) -> Vec<u8> {
|
||||
figcraft_render::render_node(s.doc().unwrap(), id, 1.0).unwrap().data().to_vec()
|
||||
}
|
||||
|
||||
fn undo_steps(s: &Session) -> usize {
|
||||
s.ds().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_switches_to_crop_keeping_the_picture_as_one_undo_step() {
|
||||
for mode in [ImageScaleMode::Fill, ImageScaleMode::Fit, ImageScaleMode::Crop] {
|
||||
let mut s = Session::new();
|
||||
let layer = image_layer(&mut s);
|
||||
set_mode(&mut s, layer, mode);
|
||||
let (before, doc_before, steps) = (pixels(&s, layer), s.doc().unwrap().clone(), undo_steps(&s));
|
||||
s.ui_requests.clear();
|
||||
let r = ex(&mut s, "image.crop", json!({}));
|
||||
assert_eq!(r, json!({"id": layer, "index": 0}), "{mode:?}");
|
||||
let PaintKind::Image(img) = &fills(&s, layer)[0].kind else { panic!("image fill") };
|
||||
assert_eq!(img.scale_mode, ImageScaleMode::Crop, "{mode:?}");
|
||||
assert_eq!(pixels(&s, layer), before, "{mode:?}: the picture stays where it was");
|
||||
let asked: Vec<_> = s.ui_requests.iter().filter(|u| u.kind == "image.crop").map(|u| u.data.clone()).collect();
|
||||
assert_eq!(asked, vec![json!({"id": layer, "index": 0})], "{mode:?}: the canvas enters crop mode");
|
||||
if mode == ImageScaleMode::Crop {
|
||||
assert_eq!(undo_steps(&s), steps, "already cropped: nothing to undo");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(undo_steps(&s), steps + 1, "{mode:?}: one undo step");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap(), &doc_before, "{mode:?}: undo restores the fill");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_takes_the_top_visible_image_fill_or_the_one_named() {
|
||||
let mut s = Session::new();
|
||||
let layer = image_layer(&mut s);
|
||||
let mut f = fills(&s, layer);
|
||||
let image = f[0].clone();
|
||||
let mut hidden = image.clone();
|
||||
hidden.visible = false;
|
||||
// Bottom to top: image, image, solid, hidden image.
|
||||
f = vec![image.clone(), image, Paint::solid(figcraft_color::Color::BLACK), hidden];
|
||||
ex(&mut s, "node.set", json!({"ids": [layer], "props": {"fills": f}}));
|
||||
assert_eq!(ex(&mut s, "image.crop", json!({"ids": [layer]}))["index"], 1, "the top visible image fill");
|
||||
let modes =
|
||||
|s: &Session| fills(s, layer).iter().map(|p| if let PaintKind::Image(i) = &p.kind { Some(i.scale_mode) } else { None }).collect::<Vec<_>>();
|
||||
assert_eq!(modes(&s), [Some(ImageScaleMode::Fill), Some(ImageScaleMode::Crop), None, Some(ImageScaleMode::Fill)]);
|
||||
assert_eq!(ex(&mut s, "image.crop", json!({"ids": [layer], "index": 0}))["index"], 0, "an image fill by index");
|
||||
assert_eq!(modes(&s)[0], Some(ImageScaleMode::Crop));
|
||||
let err = s.execute("image.crop", &json!({"ids": [layer], "index": 2})).unwrap_err().to_string();
|
||||
assert!(err.contains("not an image"), "{err}");
|
||||
let err = s.execute("image.crop", &json!({"ids": [layer], "index": 9})).unwrap_err().to_string();
|
||||
assert!(err.contains("no fill at that index"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_is_refused_without_one_image_layer() {
|
||||
let mut s = Session::new();
|
||||
let rect = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 300, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
|
||||
let layer = image_layer(&mut s);
|
||||
let doc = s.doc().unwrap().clone();
|
||||
s.ui_requests.clear();
|
||||
for (ids, why) in [(json!([rect]), "no image fill"), (json!([rect, layer]), "select one layer")] {
|
||||
let err = s.execute("image.crop", &json!({"ids": ids})).unwrap_err();
|
||||
assert!(matches!(&err, crate::EngineError::Disabled(..)) && err.to_string().contains(why), "{err}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap(), &doc, "nothing changes");
|
||||
assert!(s.ui_requests.is_empty(), "no crop mode");
|
||||
}
|
||||
@@ -108,6 +108,7 @@ pub use derived::{changed_nodes, normalize};
|
||||
pub use doc_state::{DocState, History, HistoryEntry, Interaction, PREVIEW_READ_ONLY, Preedit, TextEdit, VersionPreview};
|
||||
pub use error::{EngineError, Result, bad};
|
||||
pub use prefs::{Clipboard, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UiRequest, clamp_ui_scale};
|
||||
pub use session::Forward;
|
||||
pub use view_state::ViewState;
|
||||
|
||||
/// The editing session: open documents, the current tool, clipboard and prefs.
|
||||
@@ -148,6 +149,9 @@ pub struct Session {
|
||||
pub events: Arc<events::EventLog>,
|
||||
/// Events recorded inside a group: (kind, document, path, data).
|
||||
pending_events: Vec<(String, String, Option<String>, Value)>,
|
||||
/// The AI agent the running command is for ([`Session::execute_as`]), as comments are signed
|
||||
/// (`"Claude (agent)"`); `None` for the user.
|
||||
pub agent: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -179,6 +183,8 @@ mod arc_handles_tests;
|
||||
#[cfg(test)]
|
||||
mod autosave_tests;
|
||||
#[cfg(test)]
|
||||
mod batch_continue_events_tests;
|
||||
#[cfg(test)]
|
||||
mod batch_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod batch_step_ref_tests;
|
||||
@@ -217,10 +223,16 @@ mod collab_tests;
|
||||
#[cfg(test)]
|
||||
mod collab_undo_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_agent_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_mentions_tests;
|
||||
#[cfg(test)]
|
||||
mod comments_polish_tests;
|
||||
#[cfg(test)]
|
||||
mod component_codegen_tests;
|
||||
#[cfg(test)]
|
||||
mod component_cycle_tests;
|
||||
#[cfg(test)]
|
||||
mod components_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod components_scale_tests;
|
||||
@@ -255,6 +267,8 @@ mod double_click_tests;
|
||||
#[cfg(test)]
|
||||
mod draw_tools_tests;
|
||||
#[cfg(test)]
|
||||
mod duplicate_map_tests;
|
||||
#[cfg(test)]
|
||||
mod duplicate_spacing_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_tests;
|
||||
@@ -307,6 +321,8 @@ mod huge_stroke_tests;
|
||||
#[cfg(test)]
|
||||
mod id_params_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod image_crop_tests;
|
||||
#[cfg(test)]
|
||||
mod images_left_in_file_tests;
|
||||
#[cfg(test)]
|
||||
mod import_export_tests;
|
||||
@@ -385,6 +401,8 @@ mod pen_end_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_overlay_tests;
|
||||
#[cfg(test)]
|
||||
mod penpot_export_tests;
|
||||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod placed_image_size_tests;
|
||||
@@ -503,6 +521,8 @@ mod transform_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod unknown_params_tests;
|
||||
#[cfg(test)]
|
||||
mod variable_props_tests;
|
||||
#[cfg(test)]
|
||||
mod variable_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_behaviour_tests;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn id(v: &Value) -> u64 {
|
||||
v["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-penpot-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
type Layer = (String, [f64; 4]);
|
||||
|
||||
fn outline(doc: &Document) -> Vec<Layer> {
|
||||
doc.pages
|
||||
.iter()
|
||||
.flat_map(|p| doc.subtree(*p))
|
||||
.filter_map(|i| doc.node(i))
|
||||
.map(|n| {
|
||||
let t = doc.world_transform(n.id).translation();
|
||||
let text = n.kind.text().map(|t| t.characters.clone()).unwrap_or_default();
|
||||
let inside = doc.ancestors(n.id).iter().any(|a| matches!(doc.node(*a).map(|x| &x.kind), Some(NodeKind::Instance { .. })));
|
||||
let label = format!(
|
||||
"{} {:?} {text:?} fills {} strokes {} effects {} children {} linked {} interactions {}",
|
||||
n.kind.type_name(),
|
||||
n.name,
|
||||
n.fills.len(),
|
||||
n.strokes.len(),
|
||||
n.effects.len(),
|
||||
n.children.len(),
|
||||
!inside || n.source.is_some(),
|
||||
n.interactions.len(),
|
||||
);
|
||||
(label, [t.x, t.y, n.width, n.height])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn same(a: &[Layer], b: &[Layer]) -> Option<(Layer, Layer)> {
|
||||
if a.len() != b.len() {
|
||||
return a.first().cloned().zip(b.first().cloned());
|
||||
}
|
||||
a.iter().zip(b).find(|(x, y)| x.0 != y.0 || x.1.iter().zip(y.1).any(|(p, q)| (p - q).abs() > 0.01)).map(|(x, y)| (x.clone(), y.clone()))
|
||||
}
|
||||
|
||||
fn build(s: &mut Session) {
|
||||
let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
|
||||
s.execute("text.create", &json!({"text": "Hello 😀", "x": 10, "y": 10, "parent": frame})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 40, "width": 50, "height": 20, "parent": frame})).unwrap();
|
||||
s.execute("layout.add", &json!({"ids": [frame], "direction": "vertical"})).unwrap();
|
||||
let card = id(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 120, "height": 60})).unwrap());
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": card})).unwrap();
|
||||
let comp = id(&s.execute("component.create", &json!({"ids": [card]})).unwrap());
|
||||
let inst = id(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 200})).unwrap());
|
||||
s.execute("proto.interaction.add", &json!({"id": frame, "dest": inst})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_penpot_files_open_with_the_same_layers() {
|
||||
let mut s = Session::new();
|
||||
build(&mut s);
|
||||
let path = temp("roundtrip.penpot");
|
||||
let r = s.execute("file.export", &json!({"format": "penpot", "path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["format"], "penpot");
|
||||
assert!(r["warnings"].is_array());
|
||||
let before = outline(s.doc().unwrap());
|
||||
s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(same(&outline(doc), &before), None);
|
||||
let inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap();
|
||||
assert!(inst.children.iter().all(|c| doc.node(*c).is_some_and(|c| c.source.is_some())));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn penpot_export_without_a_path_returns_the_file() {
|
||||
let mut s = Session::new();
|
||||
build(&mut s);
|
||||
let r = s.execute("file.export", &json!({"format": "PENPOT"})).unwrap();
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap();
|
||||
let (doc, _) = crate::cmd::file::load_bytes(&bytes, "x.penpot").unwrap();
|
||||
assert_eq!(same(&outline(&doc), &outline(s.doc().unwrap())), None);
|
||||
let path = temp("by-extension.penpot");
|
||||
let r = s.execute("file.export", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["format"], "penpot");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Provider references remain diagnostic metadata; open/import never retrieve font bytes.
|
||||
#[test]
|
||||
fn missing_provider_fonts_stay_offline_and_text_stays_editable() {
|
||||
let mut source = Document::new("Offline font");
|
||||
let page = source.first_page().unwrap();
|
||||
let mut text = figcraft_doc::TextData::new("Offline text");
|
||||
text.style.font_family = "FigCraft Missing Provider Q7".into();
|
||||
source.create(page, NodeKind::Text(text), "Label", 0.0, 0.0, 200.0, 30.0).unwrap();
|
||||
let bytes = figcraft_format::penpot::export(&source).unwrap().0;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
for command in ["file.open", "file.import"] {
|
||||
let mut s = Session::new();
|
||||
let result = s.execute(command, &json!({"name": "offline.penpot", "data": data})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let label = doc.nodes().find(|n| n.name == "Label").unwrap();
|
||||
assert_eq!(label.kind.text().unwrap().style.font_family, "FigCraft Missing Provider Q7");
|
||||
assert_eq!(label.kind.text().unwrap().characters, "Offline text");
|
||||
assert!(label.plugin_data.contains_key("penpot.googleFontRefs"));
|
||||
assert!(result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("Missing"))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn penpot_export_survives_junk_params() {
|
||||
let junk = [
|
||||
json!({"format": "penpot", "path": [5]}),
|
||||
json!({"format": "penpot", "path": ""}),
|
||||
json!({"format": "penpot", "path": "\u{0}/x.penpot"}),
|
||||
json!({"format": "penpot", "ids": ["x"], "scale": "NaN"}),
|
||||
json!({"format": ["penpot"]}),
|
||||
json!({"path": "nowhere/../\u{0}.penpot"}),
|
||||
];
|
||||
for j in &junk {
|
||||
let mut s = Session::new();
|
||||
build(&mut s);
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute("file.export", j) {
|
||||
panic!("{cmd} panicked on {j}: {msg}");
|
||||
}
|
||||
}
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("file.export", &json!({"format": "penpot"})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_penpot_samples_export_and_open_again() {
|
||||
let Some(dir) = std::env::var_os("FIGCRAFT_PENPOT_SAMPLES") else { return };
|
||||
let Ok(dir) = std::fs::read_dir(dir) else { return };
|
||||
for p in dir.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "penpot")) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||
let before = outline(s.doc().unwrap());
|
||||
let out = temp(&format!("again-{}", p.file_name().unwrap().to_string_lossy()));
|
||||
s.execute("file.export", &json!({"format": "penpot", "path": out.to_string_lossy()})).unwrap();
|
||||
s.execute("file.open", &json!({"path": out.to_string_lossy()})).unwrap();
|
||||
let after = outline(s.doc().unwrap());
|
||||
assert_eq!(same(&before, &after), None, "{}: {} → {} layers", p.display(), before.len(), after.len());
|
||||
let _ = std::fs::remove_file(&out);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
|
||||
use crate::cmd::styles::rebind_paint_alpha;
|
||||
use crate::cmd::styles::{rebind_paint_alpha, unbind_slot};
|
||||
use crate::params::{get, paint_of, parse, req_string, targets};
|
||||
use crate::{CommandSpec, Result, Session, bad, spec};
|
||||
|
||||
@@ -238,15 +238,6 @@ fn list_field(doc: &Document, target: &PaintTarget) -> Option<String> {
|
||||
Some(format!("{}/{}/color", list.prefix(), target.index))
|
||||
}
|
||||
|
||||
/// Remove every binding key of the slot (`fill` and `fills/0/color` name the same one); true when
|
||||
/// one was there.
|
||||
fn unbind_slot(n: &mut figcraft_doc::Node, fills: bool, index: usize) -> bool {
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let before = n.bound.len();
|
||||
n.bound.retain(|key, _| list_binding(key).is_none_or(|(l, i, field)| !(l == list && i == index && field == "color")));
|
||||
n.bound.len() != before
|
||||
}
|
||||
|
||||
/// Why `variable` can't be applied to `targets` (`selection.bindColorVariable`), if it can't: a
|
||||
/// variable that isn't a color, or a gradient. Scopes don't refuse, as with `variable.bind`; they
|
||||
/// only filter what the menu offers ([`in_scope`]).
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::{
|
||||
};
|
||||
|
||||
mod access;
|
||||
pub use access::Forward;
|
||||
mod exec;
|
||||
mod new;
|
||||
mod undo;
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
use super::{DocState, Document, EngineError, NodeId, Result, Session, Value, json};
|
||||
|
||||
/// The file [`Session::bring_forward`] put in front for one request, and the tab to bring back.
|
||||
#[must_use = "pass it to `Session::put_back`"]
|
||||
pub struct Forward {
|
||||
target: u64,
|
||||
shown: Option<u64>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The uid of open file `target`: its `id` (from `doc.list` or `document.inspect`) or its path.
|
||||
pub fn document_uid(&self, target: &Value) -> Result<u64> {
|
||||
let found = match target {
|
||||
Value::Number(n) => n.as_u64().and_then(|id| self.docs.iter().find(|d| d.uid == id)),
|
||||
Value::String(p) => self.docs.iter().find(|d| d.path.as_deref() == Some(p.as_str())),
|
||||
_ => None,
|
||||
};
|
||||
found
|
||||
.map(|d| d.uid)
|
||||
.ok_or_else(|| EngineError::Other(format!("no open document {target} (`document` takes an `id` from doc.list, or an open file's path)")))
|
||||
}
|
||||
|
||||
/// Put open file `target` in front for one request (a control call naming its `document`),
|
||||
/// without the user's tab changing on screen: run the request, then [`Session::put_back`].
|
||||
pub fn bring_forward(&mut self, target: &Value) -> Result<Forward> {
|
||||
let uid = self.document_uid(target)?;
|
||||
let shown = self.ds().ok().map(|d| d.uid);
|
||||
if let Some(i) = self.docs.iter().position(|d| d.uid == uid) {
|
||||
self.active = i;
|
||||
}
|
||||
Ok(Forward { target: uid, shown })
|
||||
}
|
||||
|
||||
/// Bring back the tab that was in front before [`Session::bring_forward`], unless the request
|
||||
/// itself moved the front to another open file (`file.open`, `doc.switch`…), as it would have.
|
||||
pub fn put_back(&mut self, f: Forward) {
|
||||
let moved = self.docs.iter().any(|d| d.uid == f.target) && self.ds().ok().map(|d| d.uid) != Some(f.target);
|
||||
if let Some(i) = f.shown.and_then(|u| self.docs.iter().position(|d| d.uid == u)).filter(|_| !moved) {
|
||||
self.active = i;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ds(&self) -> Result<&DocState> {
|
||||
self.docs.get(self.active).ok_or(EngineError::NoDocument)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,16 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Session::execute`] for an AI agent named `agent` (the client's name; empty when it gave
|
||||
/// none), or for the user (`None`): the comments it makes are signed with the agent's name and
|
||||
/// marked as an agent's.
|
||||
pub fn execute_as(&mut self, agent: Option<&str>, id: &str, params: &Value) -> Result<Value> {
|
||||
let was = std::mem::replace(&mut self.agent, agent.map(cmd::comment::agent_author));
|
||||
let r = self.execute(id, params);
|
||||
self.agent = was;
|
||||
r
|
||||
}
|
||||
|
||||
/// Run a command by id. Mutations become one undo step; a failure or panic leaves the document
|
||||
/// as it was.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
@@ -183,7 +193,8 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Instances can't gain, lose or reorder layers, nor move, resize or re-constrain the layers in
|
||||
/// them, and library copies are read-only: refuse such an edit (it is rolled back).
|
||||
/// them, library copies are read-only and components can't contain their own instances: refuse
|
||||
/// such an edit (it is rolled back).
|
||||
pub(crate) fn check_instance_structure(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
|
||||
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
|
||||
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
|
||||
@@ -198,6 +209,7 @@ impl Session {
|
||||
library::check_read_only(effective, bdoc, &ds.doc)
|
||||
.and_then(|()| components::check_structure_edit(bdoc, &ds.doc))
|
||||
.and_then(|()| components::check_override_edit(bdoc, &ds.doc))
|
||||
.and_then(|()| components::check_component_cycles(bdoc, &ds.doc))
|
||||
.map_err(|m| EngineError::Disabled(id.to_string(), m))
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ impl Session {
|
||||
background: background::Background::default(),
|
||||
events: Arc::new(events::EventLog::default()),
|
||||
pending_events: Vec::new(),
|
||||
agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
use super::{
|
||||
Arc, BatchScope, BatchStep, DocState, Document, EngineError, HistoryEntry, Interaction, NodeId, PREVIEW_READ_ONLY, Result, Session, UiRequest,
|
||||
Value, cmd, figcraft_collab, normalize, textpreview,
|
||||
Value, cmd, figcraft_collab, json, normalize, textpreview,
|
||||
};
|
||||
|
||||
struct GroupDocCheckpoint {
|
||||
uid: u64,
|
||||
doc: Arc<Document>,
|
||||
selection: Vec<NodeId>,
|
||||
page: NodeId,
|
||||
undo_len: usize,
|
||||
/// An undo inside pops undo steps; a failed group has to bring them back.
|
||||
undo: Vec<HistoryEntry>,
|
||||
redo: Vec<HistoryEntry>,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
struct GroupCheckpoint {
|
||||
docs: Vec<GroupDocCheckpoint>,
|
||||
active: usize,
|
||||
journal_len: usize,
|
||||
/// Live events buffered before the group: a failed one drops only its own.
|
||||
pending_events: usize,
|
||||
last_command: Option<(String, Value)>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// How many undo and redo steps the active document has (the user's own in a live session).
|
||||
pub fn undo_depth(&self) -> (usize, usize) {
|
||||
@@ -179,18 +200,45 @@ impl Session {
|
||||
})
|
||||
}
|
||||
|
||||
/// Run every step, rolling back failed steps and returning each outcome, still as one undo step.
|
||||
pub(crate) fn batch_continue(&mut self, label: &str, steps: &[BatchStep]) -> Result<Vec<Value>> {
|
||||
let mut scope = BatchScope::new(steps)?;
|
||||
self.grouped(label, |s| {
|
||||
let mut outcomes = Vec::with_capacity(steps.len());
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let outcome = match s.checkpointed(None, |s| s.execute_batch_step(&scope, &step.command, &step.params)) {
|
||||
Ok(v) => {
|
||||
scope.push(v.clone());
|
||||
json!({"step": i, "command": step.command, "ok": true, "result": v})
|
||||
}
|
||||
Err(EngineError::Waiting) => return Err(EngineError::Waiting),
|
||||
Err(e) => {
|
||||
scope.push_failed();
|
||||
json!({"step": i, "command": step.command, "ok": false, "error": e.to_string()})
|
||||
}
|
||||
};
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
Ok(outcomes)
|
||||
})
|
||||
}
|
||||
|
||||
/// The next step of a batch: the references in `params` resolve against `scope` (the steps
|
||||
/// before it), the command runs and its result joins `scope`. [`Session::batch`] runs a list
|
||||
/// of these as one undo step; a caller with its own list — the CLI's script runner — uses this
|
||||
/// so its steps resolve references the same way, inside its own [`Session::grouped`].
|
||||
pub fn batch_step(&mut self, scope: &mut BatchScope, cmd: &str, params: &Value) -> Result<()> {
|
||||
let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? };
|
||||
// Say which step failed; a nested batch names its own steps.
|
||||
let v = self.execute(cmd, &p).map_err(|e| if cmd == "command.batch" { e } else { e.at_step(scope.next_step()) })?;
|
||||
let v = self.execute_batch_step(scope, cmd, params)?;
|
||||
scope.push(v);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn execute_batch_step(&mut self, scope: &BatchScope, cmd: &str, params: &Value) -> Result<Value> {
|
||||
let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? };
|
||||
// Say which step failed; a nested batch names its own steps.
|
||||
self.execute(cmd, &p).map_err(|e| if cmd == "command.batch" { e } else { e.at_step(scope.next_step()) })
|
||||
}
|
||||
|
||||
/// Take the tabs `pred` matches out of `docs`; inside a group they are kept so that a failed
|
||||
/// group can reopen them.
|
||||
pub(crate) fn remove_tabs(&mut self, mut pred: impl FnMut(&DocState) -> bool) {
|
||||
@@ -205,80 +253,106 @@ impl Session {
|
||||
/// own, and when it fails the documents stay at their group-start state.
|
||||
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
self.ds()?;
|
||||
let pending_mark = self.pending_events.len();
|
||||
let before: Vec<_> =
|
||||
self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect();
|
||||
// An undo inside pops the undo steps; a failed group has to bring them back.
|
||||
let undo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.undo.clone()).collect();
|
||||
// An edit inside clears the redo steps; a failed group has to bring them back.
|
||||
let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect();
|
||||
let active = self.active;
|
||||
let journal_len = self.journal.len();
|
||||
self.checkpointed(Some(label), f)
|
||||
}
|
||||
|
||||
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
|
||||
for ds in &mut self.docs {
|
||||
ds.history.limit = usize::MAX;
|
||||
}
|
||||
for (uid, _, _, _, _, _) in &before {
|
||||
self.live_hold(*uid, true);
|
||||
}
|
||||
self.group_depth += 1;
|
||||
/// Without a label, only roll back failures: leave successful steps' history to the outer group.
|
||||
/// A checkpoint also works between closing the last tab and opening another one.
|
||||
fn checkpointed<T>(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
let before = self.group_checkpoint();
|
||||
let result = f(self);
|
||||
self.group_depth -= 1;
|
||||
let at_root = self.group_depth == 0;
|
||||
for (uid, _, _, _, _, _) in &before {
|
||||
self.live_hold(*uid, false);
|
||||
for ds in &before.docs {
|
||||
self.live_hold(ds.uid, false);
|
||||
}
|
||||
|
||||
if result.is_err() {
|
||||
self.pending_events.truncate(pending_mark);
|
||||
// Tabs the group closed come back from the stash, and the group-start order returns.
|
||||
let mut current = std::mem::take(&mut self.docs);
|
||||
for (uid, ..) in &before {
|
||||
let tab = match current.iter().position(|d| d.uid == *uid) {
|
||||
Some(i) => Some(current.swap_remove(i)),
|
||||
None => self.closed_in_group.iter().position(|d| d.uid == *uid).map(|i| self.closed_in_group.swap_remove(i)),
|
||||
};
|
||||
self.docs.extend(tab);
|
||||
}
|
||||
for (((uid, doc, selection, page, _, limit), undo), redo) in before.iter().zip(undo_before).zip(redo_before) {
|
||||
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
|
||||
ds.history.redo = redo;
|
||||
ds.doc = doc.clone();
|
||||
ds.selection = selection.clone();
|
||||
ds.page = *page;
|
||||
ds.history.undo = undo;
|
||||
ds.history.limit = *limit;
|
||||
self.restore_group_checkpoint(before, label.is_none());
|
||||
} else {
|
||||
let mut changed = Vec::new();
|
||||
for saved in &before.docs {
|
||||
let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == saved.uid) else { continue };
|
||||
ds.history.limit = saved.limit;
|
||||
if label.is_some() {
|
||||
if !Arc::ptr_eq(&saved.doc, &ds.doc) && ds.interaction.is_none() {
|
||||
changed.push((saved.uid, saved.doc.clone(), saved.selection.clone(), saved.page));
|
||||
}
|
||||
ds.history.undo.truncate(saved.undo_len);
|
||||
}
|
||||
}
|
||||
self.active = active;
|
||||
self.journal.truncate(journal_len);
|
||||
if self.group_depth == 0 {
|
||||
self.closed_in_group.clear();
|
||||
if let Some(label) = label {
|
||||
for (uid, doc, selection, page) in changed {
|
||||
self.record(uid, Some(label), doc, selection, page);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if at_root {
|
||||
self.closed_in_group.clear();
|
||||
self.flush_pending_events();
|
||||
}
|
||||
|
||||
let changed: Vec<_> = before
|
||||
.iter()
|
||||
.filter_map(|(uid, doc, selection, page, _, _)| {
|
||||
let ds = self.docs.iter().find(|d| d.uid == *uid)?;
|
||||
(!Arc::ptr_eq(doc, &ds.doc) && ds.interaction.is_none()).then(|| (*uid, doc.clone(), selection.clone(), *page))
|
||||
})
|
||||
.collect();
|
||||
for ds in &mut self.docs {
|
||||
if let Some((_, _, _, _, undo_len, limit)) = before.iter().find(|before| before.0 == ds.uid) {
|
||||
ds.history.limit = *limit;
|
||||
ds.history.undo.truncate(*undo_len);
|
||||
}
|
||||
}
|
||||
for (uid, doc, selection, page) in changed {
|
||||
self.record(uid, Some(label), doc, selection, page);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn group_checkpoint(&mut self) -> GroupCheckpoint {
|
||||
let docs = self
|
||||
.docs
|
||||
.iter()
|
||||
.map(|ds| GroupDocCheckpoint {
|
||||
uid: ds.uid,
|
||||
doc: ds.doc.clone(),
|
||||
selection: ds.selection.clone(),
|
||||
page: ds.page,
|
||||
undo_len: ds.history.undo.len(),
|
||||
undo: ds.history.undo.clone(),
|
||||
// An edit inside clears redo; a failed group has to bring it back.
|
||||
redo: ds.history.redo.clone(),
|
||||
limit: ds.history.limit,
|
||||
})
|
||||
.collect();
|
||||
let before = GroupCheckpoint {
|
||||
docs,
|
||||
active: self.active,
|
||||
journal_len: self.journal.len(),
|
||||
pending_events: self.pending_events.len(),
|
||||
last_command: self.last_command.clone(),
|
||||
};
|
||||
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
|
||||
for ds in &mut self.docs {
|
||||
ds.history.limit = usize::MAX;
|
||||
}
|
||||
for ds in &before.docs {
|
||||
self.live_hold(ds.uid, true);
|
||||
}
|
||||
self.group_depth += 1;
|
||||
before
|
||||
}
|
||||
|
||||
fn restore_group_checkpoint(&mut self, before: GroupCheckpoint, restore_last_command: bool) {
|
||||
// Tabs the group closed come back from the stash, and the group-start order returns.
|
||||
let mut current = std::mem::take(&mut self.docs);
|
||||
for saved in &before.docs {
|
||||
let tab = match current.iter().position(|d| d.uid == saved.uid) {
|
||||
Some(i) => Some(current.swap_remove(i)),
|
||||
None => self.closed_in_group.iter().position(|d| d.uid == saved.uid).map(|i| self.closed_in_group.swap_remove(i)),
|
||||
};
|
||||
self.docs.extend(tab);
|
||||
}
|
||||
for saved in before.docs {
|
||||
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == saved.uid) {
|
||||
ds.history.redo = saved.redo;
|
||||
ds.doc = saved.doc;
|
||||
ds.selection = saved.selection;
|
||||
ds.page = saved.page;
|
||||
ds.history.undo = saved.undo;
|
||||
ds.history.limit = saved.limit;
|
||||
}
|
||||
}
|
||||
self.active = before.active;
|
||||
self.journal.truncate(before.journal_len);
|
||||
self.pending_events.truncate(before.pending_events);
|
||||
if restore_last_command {
|
||||
self.last_command = before.last_command;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,35 @@ fn published() -> Vec<u8> {
|
||||
archive(s.doc().unwrap().published.as_ref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_waits_instead_of_skipping_background_work() {
|
||||
for nested in [false, true] {
|
||||
let (mut s, wire) = session(true);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let request = json!({"command": "plugin.directory.list", "params": {"server": SERVER}});
|
||||
let step = if nested { json!({"command": "command.batch", "params": {"onError": "continue", "commands": [request]}}) } else { request };
|
||||
let params = json!({"onError": "continue", "commands": [
|
||||
{"command": "page.rename", "params": {"name": "Before request"}},
|
||||
step,
|
||||
{"command": "page.rename", "params": {"name": "After request"}}
|
||||
]});
|
||||
assert_eq!(s.start_background("command.batch", params).unwrap(), None);
|
||||
assert!(s.waiting_for("command.batch"));
|
||||
assert_eq!(s.doc().unwrap(), &before, "waiting rolls back the whole batch");
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
assert_eq!(answer(&mut s, &wire), 1);
|
||||
let (command, result) = s.take_background_done().unwrap();
|
||||
assert_eq!(command, "command.batch");
|
||||
let result = result.unwrap();
|
||||
assert!(result.as_array().unwrap().iter().all(|r| r["ok"] == true));
|
||||
let directory = if nested { &result[1]["result"][0]["result"] } else { &result[1]["result"] };
|
||||
assert_eq!(directory["plugins"][0]["id"], ID);
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "After request");
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
assert!(!s.waiting_for("command.batch"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browsing_and_installing_wait_for_nothing_and_scripts_still_wait() {
|
||||
let (mut s, wire) = session(true);
|
||||
|
||||
@@ -343,9 +343,16 @@ pub fn display_name(prefs: &crate::Prefs) -> String {
|
||||
if n == crate::cmd::comment::DEFAULT_AUTHOR { String::new() } else { n.chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() }
|
||||
}
|
||||
|
||||
/// Initials for an avatar ("Ada Lovelace" → "AL"; "?" for none).
|
||||
/// Initials for an avatar ("Ada Lovelace" → "AL"; "Claude (agent)" → "C": words that start with a
|
||||
/// letter or digit, when there are any; "?" for none).
|
||||
pub fn initials(name: &str) -> String {
|
||||
let mut out: String = name.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect();
|
||||
let firsts = |alnum: bool| -> String {
|
||||
name.split_whitespace().filter_map(|w| w.chars().next()).filter(|c| !alnum || c.is_alphanumeric()).take(2).collect()
|
||||
};
|
||||
let mut out = firsts(true);
|
||||
if out.is_empty() {
|
||||
out = firsts(false);
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push('?');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ use super::{
|
||||
ToolId, VIEW_ONLY, editing_hint, editing_of, sanitize,
|
||||
};
|
||||
|
||||
/// What a live session's document holding components that contain each other's instances shows
|
||||
/// (`names`: the loop): those instances stop expanding where they would show themselves again.
|
||||
fn loop_notice(names: &str) -> String {
|
||||
format!("Components contain each other's instances ({names}): those instances stop where they would show themselves")
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The active document (`None`: no document is open).
|
||||
pub(super) fn tab(&self) -> Option<u64> {
|
||||
@@ -37,6 +43,12 @@ impl Session {
|
||||
/// Install a document that came from the session (others' edits): the selection, page and view
|
||||
/// stay where they can, and the undo history isn't touched.
|
||||
pub fn apply_remote(&mut self, uid: u64, doc: Arc<Document>) {
|
||||
let Some(old) = self.docs.iter().find(|d| d.uid == uid).map(|d| d.doc.clone()) else { return };
|
||||
// Edits merged from others can close a loop of components containing each other.
|
||||
let changed = crate::derived::changed_nodes(&doc, &old);
|
||||
if let Some(names) = crate::components::new_cycle(&old, &doc, changed.iter()) {
|
||||
self.message = Some(loop_notice(&names));
|
||||
}
|
||||
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
|
||||
ds.doc = doc;
|
||||
if !ds.doc.pages.contains(&ds.page) {
|
||||
@@ -355,7 +367,10 @@ impl Session {
|
||||
(Some(Role::Guest), None) => {
|
||||
// The session's file gets a tab of its own, in front.
|
||||
let ds = DocState::new(client.document().clone(), None);
|
||||
self.message = Some(format!("Joined {}", ds.doc.name));
|
||||
self.message = Some(match crate::components::cycle_text(&ds.doc) {
|
||||
Some(names) => format!("Joined {}. {}", ds.doc.name, loop_notice(&names)),
|
||||
None => format!("Joined {}", ds.doc.name),
|
||||
});
|
||||
let opened = (ds.uid, Some(ds.doc.clone()));
|
||||
self.active = self.docs.len();
|
||||
self.docs.push(ds);
|
||||
|
||||
@@ -404,6 +404,11 @@ fn every_command_survives_junk_params() {
|
||||
json!({"variations": {"wght": 1e308, "": "x", "slnt": null}, "props": {"fontVariations": [1], "variationSettings": {"wdth": "-1e9"}}, "text": 7, "cursor": -3}),
|
||||
json!({"style": 3, "props": {"listStyle": 5, "indentation": 1e308, "listSpacing": "NaN", "hangingList": "yes", "start": -5, "end": 1e12,
|
||||
"textDecorationThickness": {"unit": "pixels", "value": "x"}, "textDecorationOffset": -1e308, "textDecorationColor": [1]}}),
|
||||
// Paint variable props: malformed wrappers, unsupported fields and mixed references.
|
||||
json!({"id": 2, "type": "rectangle", "props": {"fill": {"$var": null}, "stroke": {"$var": 7}}}),
|
||||
json!({"id": 2, "type": "rectangle", "props": {"fill": {"$var": ""}, "stroke": {"$var": []}}}),
|
||||
json!({"id": 2, "type": "rectangle", "props": {"fill": {"$var": "x", "extra": true}, "width": {"$var": "x"}}}),
|
||||
json!({"id": 2, "type": "rectangle", "props": {"fill": {"$var": {"$ref": "x"}}, "stroke": {"$var": "x", "$ref": "x"}}}),
|
||||
// Templates and the Getting started tips as junk.
|
||||
json!({"template": {"id": 1}, "name": [1e308], "show": "yes"}),
|
||||
// Links (dev resources) and indexes as junk.
|
||||
@@ -462,6 +467,10 @@ fn every_command_survives_junk_params() {
|
||||
json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [
|
||||
{"$ref": "\u{0}", "field": "ids/18446744073709551616/x"}, {"$ref": {"x": 1}}, {"$step": 0, "field": "//"}, {"$ref": "\u{0}", "field": "/"}],
|
||||
"props": {"name": {"$ref": "nope", "field": 5}}}}]}),
|
||||
// Font downloads: family names as junk and as paths (a family is only ever looked up in
|
||||
// the font index, never used as a path).
|
||||
json!({"family": "../../\u{0}Lato"}),
|
||||
json!({"family": ["Lato"], "style": 1e308}),
|
||||
];
|
||||
for spec in all_commands() {
|
||||
if matches!(
|
||||
|
||||
@@ -15,6 +15,58 @@ fn of_own_line_height(n: &Node) -> Option<Node> {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// The chars that start a wrapped line (not those after a hard break) in `t` laid out `w` wide.
|
||||
fn soft_starts(t: &figcraft_doc::TextData, w: f64) -> Vec<usize> {
|
||||
let chars: Vec<char> = t.characters.chars().collect();
|
||||
let l = figcraft_text::layout(t, Some(w), None);
|
||||
l.lines
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|ln| ln.char_start)
|
||||
.filter(|s| !s.checked_sub(1).and_then(|i| chars.get(i)).is_some_and(|c| matches!(c, '\n' | '\u{2028}')))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Each fixed-width text with the line breaks its file saved (one-line texts too) wraps there once imported, unless a
|
||||
/// line would overflow its box by more than the allowed slack in the fonts at hand (then it wraps
|
||||
/// as it would without them). Prints the counts (`--nocapture`).
|
||||
#[test]
|
||||
fn local_samples_wrap_at_their_saved_line_breaks() {
|
||||
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return };
|
||||
let Ok(dir) = std::fs::read_dir(dir) else { return };
|
||||
figcraft_text::scan_system_fonts();
|
||||
let (mut matched, mut fell_back, mut differed_before, mut wrapped) = (0, 0, 0, 0);
|
||||
let mut problems = Vec::new();
|
||||
for p in dir.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")) {
|
||||
let Ok((doc, _)) = figcraft_format::fig::import(&std::fs::read(&p).unwrap(), "sample") else { continue };
|
||||
for n in doc.nodes() {
|
||||
let NodeKind::Text(t) = &n.kind else { continue };
|
||||
let Some(saved) = t.saved_breaks.as_deref() else { continue };
|
||||
if t.auto_resize == TextAutoResize::WidthAndHeight {
|
||||
continue;
|
||||
}
|
||||
wrapped += usize::from(!saved.starts.is_empty());
|
||||
let plain = figcraft_doc::TextData { saved_breaks: None, ..t.clone() };
|
||||
let (own, hinted) = (soft_starts(&plain, n.width), soft_starts(t, n.width));
|
||||
differed_before += usize::from(own != saved.starts);
|
||||
if t.saved_breaks_for(n.width).is_none() {
|
||||
problems.push(format!("{}: {:?}: saved breaks no longer apply after import", p.display(), t.characters));
|
||||
} else if hinted == saved.starts {
|
||||
matched += 1;
|
||||
} else if hinted == own {
|
||||
fell_back += 1;
|
||||
} else {
|
||||
problems.push(format!("{}: {:?}: {hinted:?} != saved {:?}", p.display(), t.characters, saved.starts));
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"saved line breaks ({wrapped} of the texts wrap): {matched} matched, {fell_back} fell back, {} other; {differed_before} wrapped differently without them",
|
||||
problems.len()
|
||||
);
|
||||
assert!(problems.is_empty(), "{problems:#?}");
|
||||
}
|
||||
|
||||
/// Each text box that sizes itself comes out at the size stored in the file, to the pixel.
|
||||
#[test]
|
||||
fn local_samples_keep_their_text_box_sizes() {
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
//! Top-level paint variable props share `variable.bind` behaviour in one transaction (#1000).
|
||||
|
||||
use figcraft_color::Color;
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Session, params::id_of};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
|
||||
fn setup() -> (Session, String) {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
|
||||
let v = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "palette/surface", "type": "color", "value": "#0D99FF80"}));
|
||||
(s, v["id"].as_str().unwrap().to_string())
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, props: Value) -> NodeId {
|
||||
id_of(&run(s, "node.create", json!({"type": "rectangle", "width": 10, "height": 10, "props": props}))["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn color(s: &Session, id: NodeId, field: &str) -> Color {
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
let paints = if field == "fill" { &n.fills } else { &n.strokes };
|
||||
paints[0].solid_color().unwrap()
|
||||
}
|
||||
|
||||
fn assert_bound(s: &Session, id: NodeId, field: &str, variable: &str) {
|
||||
assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get(field).map(String::as_str), Some(variable));
|
||||
assert_eq!(color(s, id, field).to_hex(), "0D99FF");
|
||||
assert!((color(s, id, field).a - 128.0 / 255.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_set_take_variable_ids_names_and_collection_names_for_both_paints() {
|
||||
let (mut s, vid) = setup();
|
||||
for field in ["fill", "stroke"] {
|
||||
for name in [&vid, "palette/surface", "Theme/palette/surface"] {
|
||||
let id = create(&mut s, json!({field: {"$var": name}}));
|
||||
assert_bound(&s, id, field, &vid);
|
||||
let id = create(&mut s, json!({"fill": "none", "stroke": "none"}));
|
||||
run(&mut s, "node.set", json!({"id": id, "props": {field: {"$var": name}}}));
|
||||
assert_bound(&s, id, field, &vid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_and_unknown_variable_names_are_errors_but_ids_still_work() {
|
||||
let (mut s, vid) = setup();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Other"}));
|
||||
run(&mut s, "variable.create", json!({"collection": "Other", "name": "palette/surface", "type": "color", "value": "#FF0000"}));
|
||||
let e = s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": "palette/surface"}}})).unwrap_err().to_string();
|
||||
assert!(e.contains("ambiguous variable `palette/surface`"), "{e}");
|
||||
assert!(e.contains("Other/palette/surface") && e.contains("Theme/palette/surface"), "{e}");
|
||||
let id = create(&mut s, json!({"fill": {"$var": vid}}));
|
||||
assert_bound(&s, id, "fill", &vid);
|
||||
let id = create(&mut s, json!({"fill": {"$var": "Theme/palette/surface"}}));
|
||||
assert_bound(&s, id, "fill", &vid);
|
||||
let e = s.execute("node.set", &json!({"id": id, "props": {"fill": {"$var": "missing/surface"}}})).unwrap_err().to_string();
|
||||
assert!(e.contains("no such variable `missing/surface`"), "{e}");
|
||||
// The existing command still uses the first match, not the new strict resolver.
|
||||
run(&mut s, "variable.bind", json!({"id": id, "field": "fill", "variable": "palette/surface"}));
|
||||
assert_bound(&s, id, "fill", &vid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_ids_choose_between_variables_with_the_same_name() {
|
||||
let (mut s, first) = setup();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Other"}));
|
||||
let second = run(&mut s, "variable.create", json!({"collection": "Other", "name": "palette/surface", "type": "color", "value": "#FF0000"}))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let id = create(&mut s, json!({"fill": {"$var": second}}));
|
||||
assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get("fill"), Some(&second));
|
||||
assert_eq!(color(&s, id, "fill").to_hex(), "FF0000");
|
||||
// `variable.bind` deliberately keeps its first id-or-name match.
|
||||
run(&mut s, "variable.bind", json!({"id": id, "field": "fill", "variable": "palette/surface"}));
|
||||
assert_bound(&s, id, "fill", &first);
|
||||
run(&mut s, "node.set", json!({"id": id, "props": {"fill": {"$var": second}}}));
|
||||
assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get("fill"), Some(&second));
|
||||
assert_eq!(color(&s, id, "fill").to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_name_matches_and_qualified_matches_share_one_candidate_set() {
|
||||
let (mut s, _) = setup();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Other"}));
|
||||
run(&mut s, "variable.create", json!({"collection": "Other", "name": "Theme/palette/surface", "type": "color", "value": "#FF0000"}));
|
||||
let e = s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": "Theme/palette/surface"}}})).unwrap_err().to_string();
|
||||
assert!(e.contains("ambiguous variable"), "{e}");
|
||||
assert!(e.contains("Other/Theme/palette/surface") && e.contains("Theme/palette/surface"), "{e}");
|
||||
// Collection names can contain slashes too; no splitting at the first slash.
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Brand/Theme"}));
|
||||
let v = run(&mut s, "variable.create", json!({"collection": "Brand/Theme", "name": "palette/surface", "type": "color", "value": "#0D99FF80"}));
|
||||
let id = create(&mut s, json!({"fill": {"$var": "Brand/Theme/palette/surface"}}));
|
||||
assert_bound(&s, id, "fill", v["id"].as_str().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_variable_type_rolls_back_create_and_set() {
|
||||
let (mut s, _) = setup();
|
||||
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "size", "type": "number", "value": 20}));
|
||||
let id = create(&mut s, json!({"fill": "#FF0000"}));
|
||||
for field in ["fill", "stroke"] {
|
||||
for command in ["node.create", "node.set"] {
|
||||
let before = s.doc().unwrap().clone();
|
||||
let selection = s.selection().to_vec();
|
||||
let depth = s.undo_depth();
|
||||
let e = s.execute(command, &json!({"type": "rectangle", "id": id, "props": {"name": "must roll back", "fill": {"$var": "palette/surface"}, field: {"$var": "size"}}})).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("`{field}` doesn't take a number variable")), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.selection(), selection);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_props_follow_modes_and_later_literal_paints_detach() {
|
||||
let (mut s, vid) = setup();
|
||||
// Start with both paints present: mode alpha on a newly created paint is an existing,
|
||||
// separate `variable.bind` bug, not part of variable props.
|
||||
let id = create(&mut s, json!({"strokes": ["#FFFFFF"], "fill": {"$var": vid}, "stroke": {"$var": vid}}));
|
||||
run(&mut s, "variable.set", json!({"variable": vid, "mode": "Dark", "value": "#20212440"}));
|
||||
for mode in ["Dark", "Light", "Dark"] {
|
||||
run(&mut s, "variable.setMode", json!({"collection": "Theme", "mode": mode}));
|
||||
for field in ["fill", "stroke"] {
|
||||
let c = color(&s, id, field);
|
||||
assert_eq!(c.to_hex(), if mode == "Dark" { "202124" } else { "0D99FF" });
|
||||
let alpha = if mode == "Dark" { 64.0 } else { 128.0 } / 255.0;
|
||||
assert!((c.a - alpha).abs() < 1e-6, "{mode}: {c:?}");
|
||||
}
|
||||
}
|
||||
run(&mut s, "node.set", json!({"id": id, "props": {"fill": "#FF0000", "stroke": "#00FF00"}}));
|
||||
assert!(s.doc().unwrap().node(id).unwrap().bound.is_empty());
|
||||
run(&mut s, "variable.set", json!({"variable": vid, "mode": "Dark", "value": "#000000"}));
|
||||
assert_eq!(color(&s, id, "fill").to_hex(), "FF0000");
|
||||
assert_eq!(color(&s, id, "stroke").to_hex(), "00FF00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_with_variables_is_one_undo_entry_and_redo_restores_it() {
|
||||
let (mut s, vid) = setup();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let depth = s.undo_depth();
|
||||
let id = create(&mut s, json!({"fill": {"$var": vid}, "stroke": {"$var": vid}}));
|
||||
assert_eq!(s.undo_depth(), (depth.0 + 1, 0));
|
||||
let after = s.doc().unwrap().clone();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert!(!s.doc().unwrap().contains(id));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc().unwrap(), &after);
|
||||
assert_bound(&s, id, "fill", &vid);
|
||||
assert_bound(&s, id, "stroke", &vid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_variable_props_match_explicit_binding_including_typed_alpha_and_opacity() {
|
||||
let (mut s, vid) = setup();
|
||||
for field in ["fill", "stroke"] {
|
||||
let list = if field == "fill" { "fills" } else { "strokes" };
|
||||
for opacity in [1.0, 0.25] {
|
||||
let paints = json!({list: [{"type": "solid", "color": "#FF000080", "opacity": opacity}]});
|
||||
let explicit = create(&mut s, paints.clone());
|
||||
run(&mut s, "variable.bind", json!({"id": explicit, "field": field, "variable": vid}));
|
||||
let mut props = paints.clone();
|
||||
props[field] = json!({"$var": vid});
|
||||
let inline = create(&mut s, props);
|
||||
let set = create(&mut s, paints);
|
||||
run(&mut s, "node.set", json!({"id": set, "props": {field: {"$var": vid}}}));
|
||||
for id in [inline, set] {
|
||||
let doc = s.doc().unwrap();
|
||||
let (a, b) = (doc.node(id).unwrap(), doc.node(explicit).unwrap());
|
||||
assert_eq!(a.fills, b.fills);
|
||||
assert_eq!(a.strokes, b.strokes);
|
||||
assert_eq!(a.bound, b.bound);
|
||||
assert!((color(&s, id, field).a - 128.0 / 255.0 * opacity as f32).abs() < 1e-6);
|
||||
}
|
||||
// Repeating the same binding does not accumulate alpha.
|
||||
run(&mut s, "node.set", json!({"id": inline, "props": {field: {"$var": vid}}}));
|
||||
assert_eq!(color(&s, inline, field), color(&s, explicit, field));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_props_win_over_literal_paint_lists_and_style_links() {
|
||||
let (mut s, vid) = setup();
|
||||
let st = run(&mut s, "style.create", json!({"type": "paint", "name": "Red", "value": ["#FF000080"]}))["id"].clone();
|
||||
for field in ["fill", "stroke"] {
|
||||
let style_key = if field == "fill" { "fillStyle" } else { "strokeStyle" };
|
||||
let list = if field == "fill" { "fills" } else { "strokes" };
|
||||
let props = json!({list: ["#00FF00"], style_key: st, field: {"$var": vid}});
|
||||
let created = create(&mut s, props.clone());
|
||||
let set = create(&mut s, json!({}));
|
||||
run(&mut s, "node.set", json!({"id": set, "props": props}));
|
||||
for id in [created, set] {
|
||||
assert_bound(&s, id, field, &vid);
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
assert!(if field == "fill" { n.styles.fill.is_none() } else { n.styles.stroke.is_none() });
|
||||
}
|
||||
run(&mut s, "style.update", json!({"style": st, "value": ["#FF00FF"]}));
|
||||
for id in [created, set] {
|
||||
assert_bound(&s, id, field, &vid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_props_unlink_a_preexisting_paint_style_even_when_the_colour_is_unchanged() {
|
||||
let (mut s, vid) = setup();
|
||||
run(&mut s, "variable.set", json!({"variable": vid, "value": "#FF0000"}));
|
||||
let style = run(&mut s, "style.create", json!({"type": "paint", "name": "Red", "value": ["#FF0000"]}))["id"].clone();
|
||||
for field in ["fill", "stroke"] {
|
||||
let style_key = if field == "fill" { "fillStyle" } else { "strokeStyle" };
|
||||
let id = create(&mut s, json!({style_key: style}));
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
assert!(if field == "fill" { n.styles.fill.is_some() } else { n.styles.stroke.is_some() });
|
||||
run(&mut s, "node.set", json!({"id": id, "props": {field: {"$var": vid}}}));
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
assert_eq!(n.bound.get(field), Some(&vid));
|
||||
assert!(if field == "fill" { n.styles.fill.is_none() } else { n.styles.stroke.is_none() });
|
||||
assert_eq!(color(&s, id, field).to_hex(), "FF0000");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_unsupported_variable_props_are_errors_and_leave_no_edit() {
|
||||
let (mut s, _) = setup();
|
||||
let id = create(&mut s, json!({}));
|
||||
for props in [
|
||||
json!({"fill": {"$var": null}}),
|
||||
json!({"fill": {"$var": 5}}),
|
||||
json!({"stroke": {"$var": []}}),
|
||||
json!({"fill": {"$var": {}}}),
|
||||
json!({"fill": {"$var": ""}}),
|
||||
json!({"fill": {}}),
|
||||
json!({"fill": {"$var": "palette/surface", "extra": true}}),
|
||||
json!({"fill": {"$var": "palette/surface", "$ref": "x"}}),
|
||||
json!({"fill": {"$var": "palette/surface", "$step": 0}}),
|
||||
] {
|
||||
for command in ["node.create", "node.set"] {
|
||||
let before = s.doc().unwrap().clone();
|
||||
let depth = s.undo_depth();
|
||||
let e = s.execute(command, &json!({"type": "rectangle", "id": id, "props": props})).unwrap_err().to_string();
|
||||
assert!(e.contains("$var") || e.contains("must be a colour"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &before, "{props}");
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
}
|
||||
for key in ["width", "fills", "strokes", "opacity", "gap", "path", "pluginData", "fillStyle", "unknown"] {
|
||||
for command in ["node.create", "node.set"] {
|
||||
let e = s.execute(command, &json!({"type": "rectangle", "id": id, "props": {key: {"$var": "palette/surface"}}})).unwrap_err().to_string();
|
||||
assert!(e.contains("only `fill` and `stroke` take variables in props") && e.contains("variable.bind"), "{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_references_resolve_inside_variable_wrappers_and_mixed_keys_roll_back() {
|
||||
for reference in [json!({"$ref": "surface"}), json!({"$step": 1})] {
|
||||
let mut s = Session::new();
|
||||
let commands = json!([
|
||||
{"command": "variable.createCollection", "params": {"name": "Theme"}},
|
||||
{"command": "variable.create", "as": "surface", "params": {"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF80"}},
|
||||
{"command": "node.create", "params": {"type": "rectangle", "props": {"fill": {"$var": reference}}}}
|
||||
]);
|
||||
let result = run(&mut s, "command.batch", json!({"commands": commands}));
|
||||
let id = id_of(&result[2]["id"]).unwrap();
|
||||
let vid = result[1]["id"].as_str().unwrap();
|
||||
assert_bound(&s, id, "fill", vid);
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().variables.variables.is_empty());
|
||||
assert!(!s.doc().unwrap().contains(id));
|
||||
|
||||
let before = s.doc().unwrap().clone();
|
||||
let mut bad_commands = commands.clone();
|
||||
bad_commands[2]["params"]["props"]["fill"] = json!({"$var": "surface", "$ref": "surface"});
|
||||
let e = s.execute("command.batch", &json!({"commands": bad_commands})).unwrap_err().to_string();
|
||||
assert!(e.contains("not `$var`"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_props_replace_first_paint_aliases_but_keep_other_slots_bound() {
|
||||
let (mut s, vid) = setup();
|
||||
let old = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "old", "type": "color", "value": "#FF0000"}))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
for (field, list, aliases) in [
|
||||
("fill", "fills", vec!["fill", "fills", "fills/0", "fills/0/color", "fills/00/color"]),
|
||||
("stroke", "strokes", vec!["stroke", "strokes/0/color", "strokes/00/color"]),
|
||||
] {
|
||||
for alias in aliases {
|
||||
let first = format!("{list}/0/color");
|
||||
let second = format!("{list}/1/color");
|
||||
let props = json!({list: ["#FFFFFF", "#FFFFFF"], "boundVariables": {alias: old, second.clone(): old}});
|
||||
let set = create(&mut s, props.clone());
|
||||
run(&mut s, "node.set", json!({"id": set, "props": {field: {"$var": vid}}}));
|
||||
let mut props = props;
|
||||
props[field] = json!({"$var": vid});
|
||||
let created = create(&mut s, props);
|
||||
for id in [set, created] {
|
||||
assert_bound(&s, id, field, &vid);
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
assert_eq!(n.bound.len(), 2, "{alias}");
|
||||
assert_eq!(n.bound.get(&second), Some(&old));
|
||||
if alias != field {
|
||||
assert!(!n.bound.contains_key(alias), "{alias}");
|
||||
}
|
||||
assert!(!n.bound.contains_key(&first));
|
||||
let paints = if field == "fill" { &n.fills } else { &n.strokes };
|
||||
assert_eq!(paints[1].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_props_bind_a_mixed_selection_and_unsupported_wrappers_leave_no_edit() {
|
||||
let (mut s, vid) = setup();
|
||||
let ids: Vec<_> = ["frame", "rectangle", "text"]
|
||||
.into_iter()
|
||||
.map(|kind| id_of(&run(&mut s, "node.create", json!({"type": kind, "width": 10, "height": 10}))["id"]).unwrap())
|
||||
.collect();
|
||||
run(&mut s, "select.set", json!({"ids": ids}));
|
||||
run(&mut s, "node.set", json!({"props": {"fill": {"$var": vid}, "stroke": {"$var": vid}}}));
|
||||
for &id in &ids {
|
||||
for field in ["fill", "stroke"] {
|
||||
assert_bound(&s, id, field, &vid);
|
||||
}
|
||||
}
|
||||
let before = s.doc().unwrap().clone();
|
||||
let selection = s.selection().to_vec();
|
||||
let depth = s.undo_depth();
|
||||
let e = s.execute("node.set", &json!({"props": {"name": "must not change", "cornerRadius": {"$var": vid}}})).unwrap_err().to_string();
|
||||
assert!(e.contains("only `fill` and `stroke` take variables in props"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.selection(), selection);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_keys_inside_typed_paint_lists_stay_literal_but_bare_wrappers_error() {
|
||||
let (mut s, vid) = setup();
|
||||
let id = create(&mut s, json!({}));
|
||||
for command in ["node.create", "node.set"] {
|
||||
// Typed paints ignore extra keys today: `$var` is only a wrapper in top-level props.
|
||||
let result = run(
|
||||
&mut s,
|
||||
command,
|
||||
json!({"type": "rectangle", "id": id, "props": {
|
||||
"fills": [{"type": "solid", "color": "#FF0000", "$var": vid}]
|
||||
}}),
|
||||
);
|
||||
let painted = if command == "node.create" { id_of(&result["id"]).unwrap() } else { id };
|
||||
assert_eq!(color(&s, painted, "fill").to_hex(), "FF0000");
|
||||
assert!(s.doc().unwrap().node(painted).unwrap().bound.is_empty());
|
||||
let before = s.doc().unwrap().clone();
|
||||
let depth = s.undo_depth();
|
||||
assert!(s.execute(command, &json!({"type": "rectangle", "id": id, "props": {"fills": [{"$var": vid}]}})).is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_bind_keeps_its_legacy_type_lookup_when_a_name_collides_with_an_id() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let number = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "size", "type": "number", "value": 20}))["id"].clone();
|
||||
let paint = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FF0000"}))["id"].clone();
|
||||
run(&mut s, "variable.update", json!({"variable": number, "name": paint}));
|
||||
let id = create(&mut s, json!({}));
|
||||
let before = s.doc().unwrap().clone();
|
||||
let depth = s.undo_depth();
|
||||
let e = s.execute("variable.bind", &json!({"id": id, "field": "fill", "variable": "surface"})).unwrap_err().to_string();
|
||||
assert!(e.contains("`fill` doesn't take a number variable"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
figcraft-doc = { workspace = true }
|
||||
figcraft-pathops = { workspace = true }
|
||||
figcraft-layout = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -44,6 +44,8 @@ mod text_path_tests;
|
||||
mod text_skip_ink_tests;
|
||||
#[cfg(test)]
|
||||
mod text_style_tests;
|
||||
#[cfg(test)]
|
||||
mod text_wrap_tests;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
|
||||
@@ -183,12 +183,15 @@ impl<'a> View<'a> {
|
||||
v
|
||||
}
|
||||
|
||||
/// This view with `style` just above the node itself: the style beats the node's own copy of
|
||||
/// its fields (stale when the style changed since), and override rows beat both.
|
||||
fn styled(&self, style: Option<&'a Kv<'a>>) -> Self {
|
||||
/// This view with `style` just above the layer that links it in `key` (or `legacy`): the style
|
||||
/// beats that layer's and lower layers' copies of its fields (stale when the style changed
|
||||
/// since, or written for the style linked before), and override rows above the link beat it.
|
||||
fn styled(&self, style: Option<&'a Kv<'a>>, key: &str, legacy: &str) -> Self {
|
||||
let mut v = self.clone();
|
||||
if let Some(s) = style {
|
||||
v.layers.insert(v.layers.len().min(1), s);
|
||||
let linked_in = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some());
|
||||
let at = linked_in(key).or_else(|| linked_in(legacy)).map_or(1, |i| i + 1);
|
||||
v.layers.insert(at.min(v.layers.len()), s);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -36,10 +36,18 @@ pub(super) struct OverrideRow<'a> {
|
||||
/// How many mains are being expanded around the instance that wrote the row: the rows of
|
||||
/// the enclosing (outer) instances win over those of the inner ones.
|
||||
source_depth: usize,
|
||||
/// How many levels below the writing instance the row restates (`overrideLevel`; 0 for the
|
||||
/// instance's own rows).
|
||||
level: usize,
|
||||
/// Position among the rows of the instance that wrote it; later rows win.
|
||||
ordinal: usize,
|
||||
}
|
||||
|
||||
/// The nested level an override row restates: its `overrideLevel`, capped; 0 when it has none.
|
||||
fn override_level(row: &Kv) -> usize {
|
||||
row.get("overrideLevel").and_then(Kv::f64).filter(|l| l.is_finite() && *l > 0.0).map_or(0, |l| l.min(MAX_DEPTH as f64) as usize)
|
||||
}
|
||||
|
||||
/// The layers of a main by current GUID, and the old `overrideKey` of each that has one
|
||||
/// (`None` when two layers claim the same key).
|
||||
pub(super) struct OverrideScope {
|
||||
@@ -245,6 +253,7 @@ impl<'a> Mapper<'a> {
|
||||
if own_text(v) != Some(t) {
|
||||
o.remove("characterStyleOverrides");
|
||||
o.remove("styleOverrideTable");
|
||||
o.remove("_figLineStarts");
|
||||
if let Some(tv) = prop_text_data(val) {
|
||||
let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64);
|
||||
char_styles(o, tv, size, &mut |w| self.warn(w));
|
||||
@@ -350,6 +359,7 @@ impl<'a> Mapper<'a> {
|
||||
row: ov,
|
||||
entry,
|
||||
source_depth,
|
||||
level: override_level(ov),
|
||||
ordinal: row_ordinal,
|
||||
});
|
||||
}
|
||||
@@ -358,8 +368,10 @@ impl<'a> Mapper<'a> {
|
||||
overrides.entry(path).or_default().extend(rows);
|
||||
}
|
||||
for rows in overrides.values_mut() {
|
||||
// Deeper source rows apply first; outer rows win, with wire order preserved per source.
|
||||
rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth), row.ordinal));
|
||||
// Deeper rows apply first and outer rows win: a row restating a nested level counts at
|
||||
// that level's depth, wherever it sits on the wire. Equal depths keep the outer source
|
||||
// last, then wire order.
|
||||
rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth + row.level), std::cmp::Reverse(row.source_depth), row.ordinal));
|
||||
}
|
||||
let mut all_props: BTreeMap<String, &'a Kv<'a>> = outer.map(|i| (*i.props).clone()).unwrap_or_default();
|
||||
let mut slot_contents: BTreeMap<String, String> = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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, has_bound_variables, paint, paint_bound, stroke, style_link};
|
||||
use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke};
|
||||
use super::{
|
||||
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,
|
||||
@@ -684,7 +684,8 @@ impl<'a> Mapper<'a> {
|
||||
if own_fills.list().iter().any(paint_bound) {
|
||||
return Some(own_fills);
|
||||
}
|
||||
let linked_style = style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID")
|
||||
let linked_style = self
|
||||
.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 {
|
||||
|
||||
@@ -12,25 +12,29 @@ fn filter_number(value: &Kv) -> Option<f64> {
|
||||
number.is_finite().then_some(number)
|
||||
}
|
||||
|
||||
/// The style a layer links in `key`, or in the older `legacy` field when `key` names none. An
|
||||
/// explicit NO_STYLE in `key` is a detach and wins over the legacy field.
|
||||
pub(super) fn style_link(v: &View, key: &str, legacy: &str) -> Option<String> {
|
||||
match v.get(key).and_then(|r| r.get("guid")).and_then(guid) {
|
||||
Some(id) => Some(id).filter(|g| g != NO_STYLE),
|
||||
None => v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn color(c: &Kv) -> Value {
|
||||
let ch = |k: &str| num(c, k).unwrap_or(0.0);
|
||||
json!({"r": ch("r"), "g": ch("g"), "b": ch("b"), "a": num(c, "a").unwrap_or(1.0)})
|
||||
}
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
/// The style a layer links in `key`, by guid or by library asset (the file's copy of a library
|
||||
/// style), or in the older `legacy` field when `key` names none. An explicit NO_STYLE guid in
|
||||
/// `key` with no asset is a detach and wins over the legacy field.
|
||||
pub(super) fn style_link(&self, v: &View, key: &str, legacy: &str) -> Option<String> {
|
||||
let fallback = || v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE);
|
||||
let Some(link) = v.get(key) else { return fallback() };
|
||||
match (self.resource_id(link), link.get("guid").and_then(guid)) {
|
||||
(Some(id), _) => Some(id),
|
||||
(None, Some(_)) => None,
|
||||
(None, None) => fallback(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST
|
||||
/// `styles` map under `slot`.
|
||||
pub(super) fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> {
|
||||
let id = style_link(v, key, legacy)?;
|
||||
let id = self.style_link(v, key, legacy)?;
|
||||
let node = *self.nodes.get(&id)?;
|
||||
node.get("styleType").and_then(Kv::str)?;
|
||||
out.insert(slot.into(), json!(id));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::paint::{paint, style_link};
|
||||
use super::paint::paint;
|
||||
use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num};
|
||||
|
||||
/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through.
|
||||
@@ -36,14 +36,56 @@ fn leading_trim<'a>(v: &View<'a>) -> Option<&'a str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The chars that start a wrapped line in the layout the file saved with the text
|
||||
/// (`derivedTextData`'s `baselines`, each line's `firstCharacter` in UTF-16 units), when that layout
|
||||
/// is for these characters; lines after a hard break are left out (empty: it wraps nowhere). None
|
||||
/// when the saved layout is missing, for other text or inconsistent.
|
||||
fn saved_line_starts(v: &View, chars: &str) -> Option<Vec<usize>> {
|
||||
// Derived data from below the layer that set the characters was laid out for other text.
|
||||
let top = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some());
|
||||
if top("derivedTextData")? < top("textData")? {
|
||||
return None;
|
||||
}
|
||||
let baselines = v.get("derivedTextData")?.get("baselines")?.list();
|
||||
// UTF-16 offset of each char, and of the end.
|
||||
let mut offsets: Vec<usize> = Vec::with_capacity(chars.len() + 1);
|
||||
let mut unit = 0;
|
||||
for c in chars.chars() {
|
||||
offsets.push(unit);
|
||||
unit += c.len_utf16();
|
||||
}
|
||||
offsets.push(unit);
|
||||
let list: Vec<char> = chars.chars().collect();
|
||||
let mut starts = Vec::new();
|
||||
let mut prev = None;
|
||||
for b in baselines {
|
||||
let u = num(b, "firstCharacter").filter(|u| *u >= 0.0 && u.fract() == 0.0 && *u < unit as f64)? as usize;
|
||||
let c = offsets.binary_search(&u).ok()?;
|
||||
// Lines start at the first char, then further on each time.
|
||||
if !prev.map_or(c == 0, |p| c > p) {
|
||||
return None;
|
||||
}
|
||||
prev = Some(c);
|
||||
if c > 0 && !list.get(c - 1).is_some_and(|ch| matches!(ch, '\n' | '\u{2028}')) {
|
||||
starts.push(c);
|
||||
}
|
||||
}
|
||||
prev.map(|_| starts)
|
||||
}
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
pub(super) fn text(&mut self, o: &mut Obj, v: &View<'a>) {
|
||||
let style_node = style_link(v, "styleIdForText", "inheritTextStyleID").and_then(|s| self.nodes.get(&s).copied());
|
||||
// Instance overrides beat the shared style; the style beats the layer's own copy.
|
||||
let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")));
|
||||
let (key, legacy) = ("styleIdForText", "inheritTextStyleID");
|
||||
let style_node = self.style_link(v, key, legacy).and_then(|s| self.nodes.get(&s).copied());
|
||||
// Overrides above the one that links the style beat it; it beats the copies below.
|
||||
let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")), key, legacy);
|
||||
let td = v.get("textData");
|
||||
let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or("");
|
||||
o.insert("characters".into(), json!(chars));
|
||||
// Where the file's own layout wrapped the lines (not a REST field).
|
||||
if let Some(starts) = saved_line_starts(v, chars) {
|
||||
o.insert("_figLineStarts".into(), json!(starts));
|
||||
}
|
||||
let mut style = text_style(&sv, None);
|
||||
// The style's typography beats the layer's own copy of it (the copy is stale when the style
|
||||
// changed). The file cannot tell a stale copy of these four values from a deliberate local
|
||||
|
||||
@@ -182,6 +182,112 @@ fn an_instance_override_beats_the_shared_style_and_a_detach_unlinks_it() {
|
||||
assert_eq!(detached["style"]["fontSize"].as_f64(), Some(40.0));
|
||||
}
|
||||
|
||||
/// A link naming a library style by its asset `key` and `version` instead of a guid.
|
||||
fn asset_link(key: &str, version: &str) -> Kv<'static> {
|
||||
object(vec![("assetRef", object(vec![("key", Kv::Str(key.into())), ("version", Kv::Str(version.into()))]))])
|
||||
}
|
||||
|
||||
/// The file's copy of library text style "Heading" (Inter 24, asset `lib-heading` version 3).
|
||||
fn library_heading() -> Kv<'static> {
|
||||
node(
|
||||
10,
|
||||
"TEXT",
|
||||
"Heading",
|
||||
Some(2),
|
||||
vec![
|
||||
("styleType", Kv::Enum("TEXT")),
|
||||
("key", Kv::Str("lib-heading".into())),
|
||||
("version", Kv::Str("3".into())),
|
||||
("fontName", font("Inter")),
|
||||
("fontSize", Kv::Float(24.0)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// An override row for the layer at `path` (guids) with `fields`.
|
||||
fn row(path: &[u64], mut fields: Fields) -> Kv<'static> {
|
||||
let mut all = vec![("guidPath", object(vec![("guids", Kv::Array(path.iter().map(|g| guid(*g)).collect()))]))];
|
||||
all.append(&mut fields);
|
||||
object(all)
|
||||
}
|
||||
|
||||
fn instance(id: u64, name: &str, parent: u64, main: u64, rows: Vec<Kv<'static>>) -> Kv<'static> {
|
||||
node(id, "INSTANCE", name, Some(parent), vec![("symbolData", object(vec![("symbolID", guid(main)), ("symbolOverrides", Kv::Array(rows))]))])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_text_style_linked_by_asset_key_is_resolved() {
|
||||
let layer = vec![
|
||||
("fontName", font("Stale copy")),
|
||||
("fontSize", Kv::Float(40.0)),
|
||||
("styleIdForText", asset_link("lib-heading", "3")),
|
||||
("textData", chars("Hi")),
|
||||
];
|
||||
let doc = import(&rest(vec![library_heading(), node(11, "TEXT", "Label", Some(2), layer)]));
|
||||
let label = doc.nodes().find(|n| n.name == "Label").unwrap();
|
||||
assert_eq!(doc.style(label.styles.text.as_deref().unwrap()).unwrap().name, "Heading");
|
||||
let text = label.kind.text().unwrap();
|
||||
assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 24.0), "the style beats the stale copy");
|
||||
// A version the file holds no copy of names nothing: the layer keeps its own values, unlinked.
|
||||
let layer = vec![
|
||||
("fontName", font("Roboto")),
|
||||
("fontSize", Kv::Float(40.0)),
|
||||
("styleIdForText", asset_link("lib-heading", "9")),
|
||||
("textData", chars("Hi")),
|
||||
];
|
||||
let value = rest(vec![library_heading(), node(11, "TEXT", "Label", Some(2), layer)]);
|
||||
let label = find(&value["document"], "Label").unwrap();
|
||||
assert!(label["styles"].get("text").is_none(), "{}", label["styles"]);
|
||||
assert_eq!(label["style"]["fontSize"].as_f64(), Some(40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_text_style_an_instance_links_beats_the_copies_below_it_and_outer_overrides_beat_it() {
|
||||
let value = rest(vec![
|
||||
library_heading(),
|
||||
node(11, "TEXT", "Body", Some(2), vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(14.0))]),
|
||||
node(20, "SYMBOL", "Main", Some(2), vec![]),
|
||||
node(
|
||||
21,
|
||||
"TEXT",
|
||||
"Label",
|
||||
Some(20),
|
||||
vec![("fontName", font("Inter")), ("fontSize", Kv::Float(14.0)), ("styleIdForText", link(11)), ("textData", chars("Hi"))],
|
||||
),
|
||||
// The instance relinks the label to the library style; its row keeps a stale copy (40).
|
||||
instance(30, "Linked", 2, 20, vec![row(&[21], vec![("styleIdForText", asset_link("lib-heading", "3")), ("fontSize", Kv::Float(40.0))])]),
|
||||
node(40, "SYMBOL", "Card", Some(2), vec![]),
|
||||
instance(41, "Nested", 40, 20, vec![row(&[21], vec![("styleIdForText", asset_link("lib-heading", "3")), ("fontSize", Kv::Float(40.0))])]),
|
||||
// An instance of the card sets the size on top of the nested instance's style.
|
||||
instance(50, "Outer", 2, 40, vec![row(&[41, 21], vec![("fontSize", Kv::Float(30.0))])]),
|
||||
]);
|
||||
let linked = find(find(&value["document"], "Linked").unwrap(), "Label").unwrap();
|
||||
assert_eq!(linked["styles"]["text"], "9:10");
|
||||
assert_eq!(linked["style"]["fontSize"].as_f64(), Some(24.0), "the linked style beats the copies below it");
|
||||
let outer = find(find(&value["document"], "Outer").unwrap(), "Label").unwrap();
|
||||
assert_eq!(outer["styles"]["text"], "9:10");
|
||||
assert_eq!(outer["style"]["fontSize"].as_f64(), Some(30.0), "an override above the link is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_restating_a_nested_level_loses_to_the_instances_own_row() {
|
||||
let label = vec![("fontName", font("Inter")), ("fontSize", Kv::Float(14.0)), ("textData", chars("Hi"))];
|
||||
let value = rest(vec![
|
||||
node(20, "SYMBOL", "Main", Some(2), vec![]),
|
||||
node(21, "TEXT", "Label", Some(20), label),
|
||||
// The own row comes first on the wire; the restated nested-level row after it.
|
||||
instance(
|
||||
30,
|
||||
"Inst",
|
||||
2,
|
||||
20,
|
||||
vec![row(&[21], vec![("fontSize", Kv::Float(30.0))]), row(&[21], vec![("fontSize", Kv::Float(12.0)), ("overrideLevel", Kv::Int(1))])],
|
||||
),
|
||||
]);
|
||||
let label = find(find(&value["document"], "Inst").unwrap(), "Label").unwrap();
|
||||
assert_eq!(label["style"]["fontSize"].as_f64(), Some(30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_property_value_drops_the_run_styles_made_for_the_main_text() {
|
||||
let ids = [0u64, 0, 0, 1, 1, 1].map(Kv::Uint).to_vec();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! The line breaks a `.fig` file saved with its text (`derivedTextData`) come across as the text's
|
||||
//! saved breaks, on messages built in code.
|
||||
|
||||
use figcraft_doc::{Document, TextData};
|
||||
|
||||
use super::kiwi::Kv;
|
||||
|
||||
type Fields = Vec<(&'static str, Kv<'static>)>;
|
||||
|
||||
fn object(fields: Fields) -> Kv<'static> {
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
fn guid(id: u64) -> Kv<'static> {
|
||||
object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(id))])
|
||||
}
|
||||
|
||||
fn node(id: u64, ty: &'static str, name: &str, parent: u64, mut more: Fields) -> Kv<'static> {
|
||||
let mut fields = vec![
|
||||
("guid", guid(id)),
|
||||
("type", Kv::Enum(ty)),
|
||||
("name", Kv::Str(name.into())),
|
||||
("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))])),
|
||||
];
|
||||
fields.append(&mut more);
|
||||
object(fields)
|
||||
}
|
||||
|
||||
/// The saved layout: one baseline per line, starting at these UTF-16 offsets.
|
||||
fn derived(firsts: &[u64]) -> Kv<'static> {
|
||||
let lines = firsts.iter().map(|f| object(vec![("firstCharacter", Kv::Uint(*f))])).collect();
|
||||
object(vec![("baselines", Kv::Array(lines))])
|
||||
}
|
||||
|
||||
/// A 100 × 40 text layer named `name` with `chars`, sizing `resize` and the saved layout `firsts`.
|
||||
fn label(id: u64, name: &str, parent: u64, chars: &str, resize: &'static str, firsts: &[u64]) -> Kv<'static> {
|
||||
node(
|
||||
id,
|
||||
"TEXT",
|
||||
name,
|
||||
parent,
|
||||
vec![
|
||||
("size", object(vec![("x", Kv::Float(100.0)), ("y", Kv::Float(40.0))])),
|
||||
("fontSize", Kv::Float(14.0)),
|
||||
("textAutoResize", Kv::Enum(resize)),
|
||||
("textData", object(vec![("characters", Kv::Str(chars.into()))])),
|
||||
("derivedTextData", derived(firsts)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn import(nodes: Vec<Kv<'static>>) -> Document {
|
||||
let page = vec![
|
||||
object(vec![("guid", guid(1)), ("type", Kv::Enum("DOCUMENT")), ("name", Kv::Str("Document".into()))]),
|
||||
node(2, "CANVAS", "Page", 1, vec![]),
|
||||
];
|
||||
let message = object(vec![("nodeChanges", Kv::Array(page.into_iter().chain(nodes).collect()))]);
|
||||
let value = super::map::to_rest(&message, "Synthetic wrap", crate::rest::MAX_NODES).0;
|
||||
crate::rest::import_fig(&value, &|_| None).unwrap().0
|
||||
}
|
||||
|
||||
fn texts<'d>(doc: &'d Document, name: &str) -> Vec<&'d TextData> {
|
||||
doc.nodes().filter(|n| n.name == name).filter_map(|n| n.kind.text()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_line_starts_come_across_as_chars_without_hard_breaks() {
|
||||
// UTF-16: the emoji takes two units, so "c" is unit 6 but char 5; "e" follows a hard break.
|
||||
let doc = import(vec![
|
||||
label(10, "Wrapped", 2, "😀 ab cd\nef", "HEIGHT", &[0, 6, 9]),
|
||||
label(11, "Unwrapped", 2, "Followers", "NONE", &[0]),
|
||||
label(12, "Auto width", 2, "ab cd", "WIDTH_AND_HEIGHT", &[0, 3]),
|
||||
label(13, "Inside a pair", 2, "😀 ab", "HEIGHT", &[0, 1]),
|
||||
label(14, "Out of order", 2, "ab cd ef", "HEIGHT", &[0, 6, 3]),
|
||||
label(15, "Not from the start", 2, "ab cd", "HEIGHT", &[3]),
|
||||
]);
|
||||
let wrapped = texts(&doc, "Wrapped")[0];
|
||||
assert_eq!(wrapped.saved_breaks_for(100.0), Some(&[5][..]));
|
||||
assert_eq!(texts(&doc, "Unwrapped")[0].saved_breaks_for(100.0), Some(&[][..]), "one line: no wrapped start");
|
||||
for name in ["Auto width", "Inside a pair", "Out of order", "Not from the start"] {
|
||||
assert_eq!(texts(&doc, name)[0].saved_breaks, None, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_layout_for_other_text_is_left_out() {
|
||||
let row = object(vec![
|
||||
("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])),
|
||||
("textData", object(vec![("characters", Kv::Str("Something much longer".into()))])),
|
||||
]);
|
||||
let doc = import(vec![
|
||||
node(20, "SYMBOL", "Main", 2, vec![]),
|
||||
label(21, "Label", 20, "Hi there", "HEIGHT", &[0, 3]),
|
||||
node(30, "INSTANCE", "Inst", 2, vec![("symbolData", object(vec![("symbolID", guid(20)), ("symbolOverrides", Kv::Array(vec![row]))]))]),
|
||||
]);
|
||||
let labels = texts(&doc, "Label");
|
||||
let main = labels.iter().find(|t| t.characters == "Hi there").unwrap();
|
||||
assert_eq!(main.saved_breaks_for(100.0), Some(&[3][..]));
|
||||
// The instance's text comes from its override, the saved layout only from the main.
|
||||
let inst = labels.iter().find(|t| t.characters == "Something much longer").unwrap();
|
||||
assert_eq!(inst.saved_breaks, None);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! A JPEG header check run before any JPEG from a file is decoded: on the canvas and in exports
|
||||
//! (`figcraft-render`) and for the pictures files carry ([`crate::thumbnail`]).
|
||||
|
||||
/// Whether `bytes` are a progressive JPEG whose frame samples some component more finely (across
|
||||
/// or down) than its first. zune-jpeg 0.5.15, the decoder behind `image`, asserts in its upsampler
|
||||
/// on such a frame (etemesi254/zune-image#236; fixed there by 36007420, unreleased as of
|
||||
/// 0.5.16-rc3), and in the browser a panic aborts the page. Until the fix ships, such a JPEG is
|
||||
/// refused from its header: the decoder refuses the larger ones itself, so nothing that decoded is
|
||||
/// lost. Baseline frames of the same shape decode and are left to it.
|
||||
/// shortcut: remove this check (and its callers' early return) once `Cargo.lock` has zune-jpeg
|
||||
/// 0.5.16 or later.
|
||||
pub fn finer_than_first(bytes: &[u8]) -> bool {
|
||||
let Some((marker, frame)) = frame(bytes) else { return false };
|
||||
if !matches!(marker, 0xC2 | 0xC6 | 0xCA | 0xCE) {
|
||||
return false;
|
||||
}
|
||||
// Length (2), precision (1), height (2), width (2), components (1), then id, sampling and
|
||||
// table for each component.
|
||||
let n = usize::from(frame.get(7).copied().unwrap_or(0));
|
||||
let sampling = |k: usize| frame.get(9 + 3 * k).map(|b| (b >> 4, b & 0x0F));
|
||||
let Some((h0, v0)) = sampling(0) else { return false };
|
||||
(1..n).any(|k| sampling(k).is_some_and(|(h, v)| h > h0 || v > v0))
|
||||
}
|
||||
|
||||
/// A JPEG's start-of-frame marker and the segment after it (from its length bytes), found as the
|
||||
/// decoders find it: fill bytes are skipped and markers without a length stepped over. `None` for
|
||||
/// anything but a JPEG, or one whose scan, end or bytes come before a frame.
|
||||
fn frame(bytes: &[u8]) -> Option<(u8, &[u8])> {
|
||||
if !bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
return None;
|
||||
}
|
||||
let mut at = 2;
|
||||
loop {
|
||||
if *bytes.get(at)? != 0xFF {
|
||||
return None;
|
||||
}
|
||||
let marker = *bytes.get(at + 1)?;
|
||||
match marker {
|
||||
0xFF => at += 1,
|
||||
0x01 | 0xD0..=0xD8 => at += 2,
|
||||
0xD9 | 0xDA => return None,
|
||||
0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return Some((marker, bytes.get(at + 2..)?)),
|
||||
_ => {
|
||||
let len = usize::from(u16::from_be_bytes([*bytes.get(at + 2)?, *bytes.get(at + 3)?]));
|
||||
at = at.checked_add(2)?.checked_add(len)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Synthetic JPEGs for the header check in `jpeg.rs`, shared by its tests here and the decoder
|
||||
// tests in `figcraft-render` (`include!`d by both).
|
||||
|
||||
/// An 8 × 8 progressive RGB JPEG with every component sampled 1 × 1, as jpeg-encoder 0.6 writes it
|
||||
/// at quality 50 (JFIF header, the standard tables, three scans). Its frame header (SOF2) starts at
|
||||
/// byte 20; the second component's sampling byte is [`SECOND_SAMPLING`], the third's follows 3 on.
|
||||
const TINY_PROGRESSIVE: &[u8] = &[
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xC2, 0x00, 0x11,
|
||||
0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x00, 0x11, 0x00, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E,
|
||||
0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33,
|
||||
0x3D, 0x3C, 0x39, 0x33, 0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68,
|
||||
0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F,
|
||||
0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
|
||||
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
|
||||
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00,
|
||||
0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
|
||||
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24,
|
||||
0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A,
|
||||
0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A,
|
||||
0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99,
|
||||
0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
|
||||
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1,
|
||||
0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
|
||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00,
|
||||
0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31,
|
||||
0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15,
|
||||
0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39,
|
||||
0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
|
||||
0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97,
|
||||
0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4,
|
||||
0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA,
|
||||
0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6F, 0xFF, 0xDA, 0x00, 0x08,
|
||||
0x01, 0x01, 0x11, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00,
|
||||
0x00, 0x01, 0x14, 0x00, 0x4D, 0xB0, 0x97, 0x8E, 0x45, 0x01, 0x08, 0x03, 0x70, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x01, 0x14, 0x00,
|
||||
0xAD, 0x17, 0xA5, 0x4A, 0x6C, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x01, 0x14, 0x00, 0x8A, 0x75, 0xA1, 0xCB, 0x6D, 0x62, 0x7F, 0xFF, 0xDA,
|
||||
0x00, 0x08, 0x01, 0x00, 0x00, 0x15, 0x29, 0x00, 0xE0, 0x75, 0x1C, 0x8C, 0x74, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x15, 0x29, 0x00,
|
||||
0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x15, 0x29, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x2A, 0x3F, 0x00, 0xC8, 0x3C,
|
||||
0x7E, 0x1F, 0x79, 0x07, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x2A, 0x3F, 0x00, 0xFE, 0x96, 0x8E, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02,
|
||||
0x11, 0x2A, 0x3F, 0x00, 0xF9, 0x36, 0xFF, 0x00, 0x99, 0xFB, 0xDF, 0xFF, 0xD9,
|
||||
];
|
||||
const SECOND_SAMPLING: usize = 34;
|
||||
|
||||
/// An 8 × 8 baseline CMYK JPEG as jpeg-encoder 0.6 writes it at quality 50 with 2 × 2 sampling:
|
||||
/// its black is sampled 2 × 2 against 1 × 1 for the others (SOF0 at byte 36), a well-formed file
|
||||
/// of the shape the check is about, in a baseline frame.
|
||||
const TINY_BASELINE_CMYK: &[u8] = &[
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xEE, 0x00, 0x0E,
|
||||
0x41, 0x64, 0x6F, 0x62, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xC0, 0x00, 0x14, 0x08, 0x00, 0x08, 0x00, 0x08, 0x04, 0x00, 0x11,
|
||||
0x01, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0x03, 0x22, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D,
|
||||
0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33, 0x38,
|
||||
0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79,
|
||||
0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42,
|
||||
0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
|
||||
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
|
||||
0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02,
|
||||
0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51,
|
||||
0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09,
|
||||
0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47,
|
||||
0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77,
|
||||
0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5,
|
||||
0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2,
|
||||
0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6,
|
||||
0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04,
|
||||
0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07,
|
||||
0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16,
|
||||
0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46,
|
||||
0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76,
|
||||
0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3,
|
||||
0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9,
|
||||
0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6,
|
||||
0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x0E, 0x04, 0x00, 0x11, 0x01, 0x11, 0x02, 0x11, 0x03, 0x00, 0x00, 0x3F, 0x00, 0xEE, 0xA6, 0xAA, 0x2A,
|
||||
0xB3, 0x83, 0x95, 0xED, 0x6D, 0x75, 0xBB, 0xF5, 0xB4, 0x97, 0x6D, 0xB6, 0x5A, 0xD9, 0x2B, 0x8D, 0x39, 0x3A, 0xB3, 0x8F, 0x54, 0xFB, 0xCB, 0xAF,
|
||||
0xFD, 0xBD, 0x7E, 0x9D, 0xDA, 0xEC, 0x96, 0xB7, 0xE2, 0x95, 0x47, 0x2A, 0x8E, 0x0B, 0x46, 0xB7, 0x77, 0x7A, 0xDD, 0xBE, 0xCD, 0x3F, 0xBD, 0xBF,
|
||||
0x2B, 0x6C, 0x0E, 0xC3, 0x6E, 0xE9, 0xB6, 0xCA, 0xE0, 0x07, 0x1B, 0xC9, 0x21, 0x95, 0x4E, 0x72, 0x54, 0x0C, 0x8E, 0x00, 0x62, 0x00, 0x5C, 0x28,
|
||||
0x45, 0xEF, 0x51, 0xFF, 0x00, 0x6B, 0xDA, 0xFF, 0x00, 0x76, 0xC7, 0xFF, 0x00, 0x01, 0x27, 0xA1, 0xFF, 0x00, 0xE3, 0xFB, 0xFE, 0xDD, 0x65, 0xFF,
|
||||
0x00, 0xD1, 0x31, 0xD6, 0x35, 0x7F, 0xFF, 0xD9,
|
||||
];
|
||||
|
||||
fn with_second_sampled(hv: u8) -> Vec<u8> {
|
||||
let mut b = TINY_PROGRESSIVE.to_vec();
|
||||
b[SECOND_SAMPLING] = hv;
|
||||
b
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! A progressive JPEG whose frame samples a component more finely than its first is refused from
|
||||
//! its header, not handed to the decoder (zune-jpeg 0.5.15 asserts on it, and in the browser a
|
||||
//! panic aborts the page): here for the header check itself and for the pictures files carry.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use crate::jpeg::finer_than_first;
|
||||
use crate::thumbnail::{archive_preview, decode_thumbnail};
|
||||
|
||||
include!("jpeg_fixtures.rs");
|
||||
|
||||
#[test]
|
||||
fn the_jpeg_as_written_is_not_finer_than_its_first() {
|
||||
assert_eq!(TINY_PROGRESSIVE[SECOND_SAMPLING], 0x11);
|
||||
assert!(!finer_than_first(TINY_PROGRESSIVE));
|
||||
// Chroma sampled coarser than luma (the usual way round) isn't finer either.
|
||||
assert!(!finer_than_first(&{
|
||||
let mut b = TINY_PROGRESSIVE.to_vec();
|
||||
b[SECOND_SAMPLING - 3] = 0x22;
|
||||
b
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_progressive_jpeg_sampling_a_component_finer_than_its_first_is_caught_from_its_header() {
|
||||
for hv in [0x22, 0x21, 0x12] {
|
||||
assert!(finer_than_first(&with_second_sampled(hv)), "second component {hv:#x}");
|
||||
}
|
||||
let mut third = TINY_PROGRESSIVE.to_vec();
|
||||
third[SECOND_SAMPLING + 3] = 0x22;
|
||||
assert!(finer_than_first(&third));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_frames_and_other_files_are_not_caught() {
|
||||
// A well-formed baseline frame with its black sampled finer than its cyan: the decoder takes it.
|
||||
assert!(!finer_than_first(TINY_BASELINE_CMYK));
|
||||
// Not JPEGs, or JPEGs that end before their frame.
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
assert!(!finer_than_first(&png));
|
||||
assert!(!finer_than_first(&TINY_PROGRESSIVE[..19]));
|
||||
assert!(!finer_than_first(&[0xFF, 0xD8]));
|
||||
assert!(!finer_than_first(&[]));
|
||||
}
|
||||
|
||||
/// Without the check in `decode_thumbnail` this test panics in the decoder (an assertion in
|
||||
/// zune-jpeg's upsampler): a crafted file's thumbnail, on the home screen and in recent files.
|
||||
#[test]
|
||||
fn a_thumbnail_sampling_a_component_finer_than_its_first_gives_no_picture() {
|
||||
for hv in [0x22, 0x21, 0x12] {
|
||||
assert_eq!(decode_thumbnail(&with_second_sampled(hv), 64), None, "second component {hv:#x}");
|
||||
}
|
||||
let mut zip = Cursor::new(Vec::new());
|
||||
let mut z = zip::ZipWriter::new(&mut zip);
|
||||
z.start_file("thumbnail.png", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut z, &with_second_sampled(0x22)).unwrap();
|
||||
z.finish().unwrap();
|
||||
assert_eq!(archive_preview(Cursor::new(zip.into_inner()), 64).picture, None);
|
||||
// The well-formed ones still give their picture.
|
||||
for good in [TINY_PROGRESSIVE, TINY_BASELINE_CMYK] {
|
||||
let t = decode_thumbnail(good, 64).expect("decoded");
|
||||
assert_eq!((t.width, t.height), (8, 8));
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod fig;
|
||||
pub mod jpeg;
|
||||
pub mod library;
|
||||
pub mod penpot;
|
||||
pub mod rest;
|
||||
pub mod sketch;
|
||||
pub mod thumbnail;
|
||||
@@ -986,6 +988,8 @@ mod image_budget_tests;
|
||||
#[cfg(test)]
|
||||
mod image_tests;
|
||||
#[cfg(test)]
|
||||
mod jpeg_tests;
|
||||
#[cfg(test)]
|
||||
mod large_tests;
|
||||
#[cfg(test)]
|
||||
mod staging_tests;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
//! Embedded font detection without decoding or retaining unsupported binaries.
|
||||
|
||||
use super::{BudgetReader, InflateBudget, MAX_ENTRIES, warn};
|
||||
use std::io::{Cursor, Read};
|
||||
use zip::ZipArchive;
|
||||
|
||||
/// Identify unsupported fonts from signatures without decoding their payloads.
|
||||
pub(super) fn report(z: &mut ZipArchive<Cursor<&[u8]>>, warnings: &mut Vec<String>, budget: &mut InflateBudget) {
|
||||
let names: Vec<String> = z
|
||||
.file_names()
|
||||
.filter(|n| n.starts_with("objects/") || n.starts_with("fonts/") || n.starts_with("assets/fonts/"))
|
||||
.take(MAX_ENTRIES)
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let mut count = 0;
|
||||
for name in names {
|
||||
let Ok(member) = z.by_name(&name) else { continue };
|
||||
let mut header = [0; 4];
|
||||
if (BudgetReader { inner: member, budget }).read_exact(&mut header).is_ok()
|
||||
&& (matches!(&header, b"OTTO" | b"true" | b"ttcf" | b"wOFF" | b"wOF2") || header == [0, 1, 0, 0])
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
warn(
|
||||
warnings,
|
||||
format!(
|
||||
"Penpot contains {count} embedded font file(s); the current model does not retain font binaries. Install the licensed fonts locally; original family references are preserved"
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use zip::{ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
#[test]
|
||||
fn uuid_fonts_are_reported_without_decoding_their_payload() {
|
||||
let mut z = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
z.start_file("objects/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(b"wOF2").unwrap();
|
||||
z.write_all(&vec![0; 100_000]).unwrap();
|
||||
let bytes = z.finish().unwrap().into_inner();
|
||||
let mut z = ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
let mut budget = InflateBudget::new();
|
||||
let before = budget.remaining;
|
||||
let mut warnings = Vec::new();
|
||||
report(&mut z, &mut warnings, &mut budget);
|
||||
assert_eq!(before - budget.remaining, 4);
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains("does not retain font binaries"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,893 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use figcraft_doc::color::{Color, ColorStop, Gradient, GradientKind, ImageScaleMode, Paint, PaintKind};
|
||||
use figcraft_doc::geom::{Affine, Point, Shape};
|
||||
use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, OverlayPosition, Transition, Trigger};
|
||||
use figcraft_doc::{
|
||||
AutoLayout, Blur, Collection, CrossAlign, Document, Effect, EffectKind, GridTrack, LayoutMode, LineHeight, MainAlign, Mode, NodeId, NodeKind,
|
||||
Shadow, Sizing, StrokeAlign, StrokeCap, StyleRun, TextAlignH, TextAutoResize, TextData, Variable, VariableType, VariableValue, VectorNetwork,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{ROOT_ID, export, import};
|
||||
|
||||
fn entries(bytes: &[u8]) -> BTreeMap<String, Value> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut out = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_owned();
|
||||
if name.ends_with(".json") {
|
||||
let mut text = String::new();
|
||||
f.read_to_string(&mut text).unwrap();
|
||||
out.insert(name, serde_json::from_str(&text).unwrap());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn shape<'a>(entries: &'a BTreeMap<String, Value>, name: &str) -> &'a Value {
|
||||
entries
|
||||
.iter()
|
||||
.filter(|(path, _)| path.matches('/').count() == 4)
|
||||
.map(|(_, v)| v)
|
||||
.find(|v| v["name"] == name)
|
||||
.unwrap_or_else(|| panic!("no shape named {name}"))
|
||||
}
|
||||
|
||||
fn named(doc: &Document, name: &str) -> NodeId {
|
||||
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}")).id
|
||||
}
|
||||
|
||||
fn roundtrip(doc: &Document) -> (Document, BTreeMap<String, Value>, Vec<u8>) {
|
||||
let (bytes, _) = export(doc).unwrap();
|
||||
let (back, _) = import(&bytes).unwrap();
|
||||
(back, entries(&bytes), bytes)
|
||||
}
|
||||
|
||||
fn page(doc: &Document) -> NodeId {
|
||||
doc.first_page().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_wrapped_line_alignment_follows_the_cross_axis() {
|
||||
for (cross_align, expected) in [(CrossAlign::Min, "start"), (CrossAlign::Center, "center"), (CrossAlign::Max, "end")] {
|
||||
let mut doc = Document::new("Wrapped alignment");
|
||||
let p = page(&doc);
|
||||
let frame = add(&mut doc, p, NodeKind::Frame, "Wrapped", 0.0, 0.0, 100.0, 100.0);
|
||||
doc.node_mut(frame).unwrap().layout = Some(AutoLayout { wrap: true, cross_align, ..AutoLayout::default() });
|
||||
let exported = entries(&export(&doc).unwrap().0);
|
||||
assert_eq!(shape(&exported, "Wrapped")["layoutAlignContent"], expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
let id = doc.create(parent, kind, name, x, y, w, h).unwrap();
|
||||
doc.node_mut(id).unwrap().fills.clear();
|
||||
id
|
||||
}
|
||||
|
||||
fn world_bounds(doc: &Document, id: NodeId) -> [f64; 4] {
|
||||
let n = doc.node(id).unwrap();
|
||||
let xf = doc.world_transform(id);
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(n.width, 0.0), Point::new(n.width, n.height), Point::new(0.0, n.height)].map(|p| xf * p);
|
||||
let xs = pts.map(|p| p.x);
|
||||
let ys = pts.map(|p| p.y);
|
||||
[
|
||||
xs.iter().cloned().fold(f64::MAX, f64::min),
|
||||
ys.iter().cloned().fold(f64::MAX, f64::min),
|
||||
xs.iter().cloned().fold(f64::MIN, f64::max),
|
||||
ys.iter().cloned().fold(f64::MIN, f64::max),
|
||||
]
|
||||
}
|
||||
|
||||
fn outline_bounds(doc: &Document, id: NodeId) -> [f64; 4] {
|
||||
let n = doc.node(id).unwrap();
|
||||
let path = match &n.kind {
|
||||
NodeKind::Vector { network } => network.stroke_path(),
|
||||
_ => figcraft_doc::geometry::shape_path(n).unwrap(),
|
||||
};
|
||||
let b = (doc.world_transform(id) * path).bounding_box();
|
||||
[b.x0, b.y0, b.x1, b.y1]
|
||||
}
|
||||
|
||||
fn close(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_boards_and_groups_keep_their_tree_and_ids() {
|
||||
let mut doc = Document::new("Tree");
|
||||
let p1 = page(&doc);
|
||||
if let NodeKind::Page { background, .. } = &mut doc.node_mut(p1).unwrap().kind {
|
||||
*background = Color::from_hex("#123456").unwrap();
|
||||
}
|
||||
let p2 = doc.add_page("Second");
|
||||
let board = add(&mut doc, p1, NodeKind::Frame, "Board", 10.0, 20.0, 300.0, 200.0);
|
||||
doc.node_mut(board).unwrap().clip = false;
|
||||
let inner = add(&mut doc, board, NodeKind::Frame, "Inner", 10.0, 10.0, 100.0, 100.0);
|
||||
let group = add(&mut doc, inner, NodeKind::Group, "Group", 0.0, 0.0, 50.0, 50.0);
|
||||
add(&mut doc, group, NodeKind::Rectangle, "Rect", 5.0, 5.0, 40.0, 30.0);
|
||||
add(&mut doc, p2, NodeKind::Rectangle, "Loose", 0.0, 0.0, 10.0, 10.0);
|
||||
|
||||
let (back, e, bytes) = roundtrip(&doc);
|
||||
assert_eq!(e["manifest.json"]["type"], "penpot/export-files");
|
||||
let file = e.iter().find(|(k, _)| k.matches('/').count() == 1).unwrap().1;
|
||||
assert_eq!(file["version"], 67);
|
||||
assert!(file["migrations"].as_array().unwrap().iter().any(|m| m == "0026-fix-svg-raw-shapes-uuids"));
|
||||
let root = e.iter().find(|(k, _)| k.ends_with(&format!("{ROOT_ID}.json"))).unwrap().1;
|
||||
assert_eq!(root["fills"][0]["fillColor"], "#123456");
|
||||
let rect = shape(&e, "Rect");
|
||||
assert_eq!(rect["parentId"], shape(&e, "Group")["id"]);
|
||||
assert_eq!(rect["frameId"], shape(&e, "Inner")["id"]);
|
||||
assert_eq!(shape(&e, "Inner")["frameId"], shape(&e, "Board")["id"]);
|
||||
assert_eq!(shape(&e, "Board")["showContent"], true);
|
||||
|
||||
assert_eq!(back.pages.len(), 2);
|
||||
assert_eq!(back.node(back.pages[1]).unwrap().name, "Second");
|
||||
assert!(close(world_bounds(&back, named(&back, "Rect")), world_bounds(&doc, named(&doc, "Rect"))));
|
||||
assert!(matches!(back.node(named(&back, "Group")).unwrap().kind, NodeKind::Group));
|
||||
assert!(!back.node(named(&back, "Board")).unwrap().clip);
|
||||
|
||||
let (again, _) = export(&back).unwrap();
|
||||
let (twice, _) = export(&back).unwrap();
|
||||
assert_eq!(again, twice);
|
||||
let ids_before: BTreeSet<String> = e.keys().cloned().collect();
|
||||
let ids_after: BTreeSet<String> = entries(&again).keys().cloned().collect();
|
||||
assert_eq!(ids_before, ids_after, "re-exporting an imported file keeps every page and shape id");
|
||||
assert!(!bytes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangles_ellipses_and_paths_keep_their_geometry() {
|
||||
let mut doc = Document::new("Shapes");
|
||||
let p = page(&doc);
|
||||
let rect = add(&mut doc, p, NodeKind::Rectangle, "Rotated", 100.0, 100.0, 80.0, 40.0);
|
||||
{
|
||||
let n = doc.node_mut(rect).unwrap();
|
||||
n.transform = Affine::translate((100.0, 100.0)) * Affine::rotate(30f64.to_radians());
|
||||
n.corners.radii = [1.0, 2.0, 3.0, 4.0];
|
||||
}
|
||||
add(&mut doc, p, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Circle", 0.0, 0.0, 30.0, 20.0);
|
||||
add(&mut doc, p, NodeKind::Star { count: 5, inner_ratio: 0.4 }, "Star", 200.0, 0.0, 50.0, 50.0);
|
||||
let network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(40.0, 30.0)], false);
|
||||
let line = add(&mut doc, p, NodeKind::Vector { network }, "Open", 300.0, 0.0, 40.0, 30.0);
|
||||
doc.node_mut(line).unwrap().strokes = vec![Paint::solid(Color::BLACK)];
|
||||
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
assert_eq!(shape(&e, "Rotated")["type"], "rect");
|
||||
assert_eq!(shape(&e, "Rotated")["r3"], 3);
|
||||
assert!((shape(&e, "Rotated")["rotation"].as_f64().unwrap() - 30.0).abs() < 1e-6);
|
||||
assert_eq!(shape(&e, "Circle")["type"], "circle");
|
||||
assert_eq!(shape(&e, "Star")["type"], "path");
|
||||
assert_eq!(shape(&e, "Open")["content"].as_array().unwrap().len(), 3);
|
||||
for name in ["Rotated", "Circle", "Star", "Open"] {
|
||||
assert!(close(outline_bounds(&back, named(&back, name)), outline_bounds(&doc, named(&doc, name))), "{name} moved");
|
||||
}
|
||||
let r = back.node(named(&back, "Rotated")).unwrap();
|
||||
assert_eq!(r.corners.radii, [1.0, 2.0, 3.0, 4.0]);
|
||||
let [a, b, ..] = doc.world_transform(rect).as_coeffs();
|
||||
let [a2, b2, ..] = back.world_transform(r.id).as_coeffs();
|
||||
assert!((a - a2).abs() < 1e-6 && (b - b2).abs() < 1e-6);
|
||||
assert!(matches!(back.node(named(&back, "Circle")).unwrap().kind, NodeKind::Ellipse { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_and_strokes_keep_their_order_and_styles() {
|
||||
let mut doc = Document::new("Paints");
|
||||
let p = page(&doc);
|
||||
let id = add(&mut doc, p, NodeKind::Rectangle, "Painted", 0.0, 0.0, 100.0, 50.0);
|
||||
let mut linear = Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
ColorStop { position: 0.0, color: Color::from_hex("#ff0000").unwrap() },
|
||||
ColorStop { position: 1.0, color: Color::from_hex("#0000ff80").unwrap() },
|
||||
],
|
||||
from: (0.0, 0.5),
|
||||
to: (1.0, 0.5),
|
||||
width: (0.0, 0.0),
|
||||
};
|
||||
linear.square_linear_width(1.0, 1.0);
|
||||
let radial = Gradient { kind: GradientKind::Radial, from: (0.5, 0.5), to: (1.0, 0.5), width: (0.5, 0.75), ..linear.clone() };
|
||||
{
|
||||
let n = doc.node_mut(id).unwrap();
|
||||
let mut bottom = Paint::solid(Color::from_hex("#00ff00").unwrap());
|
||||
bottom.opacity = 0.5;
|
||||
n.fills = vec![bottom, Paint::gradient(linear), Paint::gradient(radial)];
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.weight = 2.0;
|
||||
n.stroke.align = StrokeAlign::Outside;
|
||||
n.stroke.dashes = vec![12.0, 12.0];
|
||||
}
|
||||
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
let s = shape(&e, "Painted");
|
||||
assert_eq!(s["fills"][0]["fillColorGradient"]["type"], "radial");
|
||||
assert_eq!(s["fills"][2]["fillColor"], "#00ff00");
|
||||
assert_eq!(s["fills"][2]["fillOpacity"], 0.5);
|
||||
assert_eq!(s["strokes"][0]["strokeStyle"], "dashed");
|
||||
assert_eq!(s["strokes"][0]["strokeAlignment"], "outer");
|
||||
assert!(s["strokes"][0].get("strokeJoin").is_none(), "Penpot strokes are a closed schema with no join");
|
||||
|
||||
let n = back.node(named(&back, "Painted")).unwrap();
|
||||
let kinds: Vec<&str> = n
|
||||
.fills
|
||||
.iter()
|
||||
.map(|f| match &f.kind {
|
||||
PaintKind::Solid { .. } => "solid",
|
||||
PaintKind::Gradient(g) if g.kind == GradientKind::Radial => "radial",
|
||||
PaintKind::Gradient(_) => "linear",
|
||||
_ => "other",
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kinds, ["solid", "linear", "radial"]);
|
||||
assert!((n.fills[0].opacity - 0.5).abs() < 1e-6);
|
||||
if let PaintKind::Gradient(g) = &n.fills[2].kind {
|
||||
assert!((g.width.0 - 0.5).abs() < 1e-6 && (g.width.1 - 0.75).abs() < 1e-6);
|
||||
}
|
||||
if let PaintKind::Gradient(g) = &n.fills[1].kind {
|
||||
assert!((g.stops[1].color.a - 128.0 / 255.0).abs() < 0.01);
|
||||
}
|
||||
assert_eq!(n.stroke.weight, 2.0);
|
||||
assert_eq!(n.stroke.align, StrokeAlign::Outside);
|
||||
assert_eq!(n.stroke.dashes, vec![12.0, 12.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_path_caps_and_dotted_strokes_survive() {
|
||||
let mut doc = Document::new("Caps");
|
||||
let p = page(&doc);
|
||||
let network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 0.0)], false);
|
||||
let id = add(&mut doc, p, NodeKind::Vector { network }, "Arrow", 0.0, 0.0, 50.0, 1.0);
|
||||
{
|
||||
let n = doc.node_mut(id).unwrap();
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.weight = 1.0;
|
||||
n.stroke.cap_end = StrokeCap::TriangleArrow;
|
||||
n.stroke.dashes = vec![0.0, 6.0];
|
||||
}
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
let stroke = &shape(&e, "Arrow")["strokes"][0];
|
||||
assert_eq!(stroke["strokeStyle"], "dotted");
|
||||
assert_eq!(stroke["strokeCapEnd"], "triangle-arrow");
|
||||
let n = back.node(named(&back, "Arrow")).unwrap();
|
||||
assert_eq!(n.stroke.cap_end, StrokeCap::TriangleArrow);
|
||||
assert_eq!(n.stroke.dashes, vec![0.0, 6.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_and_blurs_round_trip() {
|
||||
let mut doc = Document::new("Effects");
|
||||
let p = page(&doc);
|
||||
let id = add(&mut doc, p, NodeKind::Rectangle, "Shadowed", 0.0, 0.0, 50.0, 50.0);
|
||||
let shadow = Shadow { color: Color::from_hex("#00000040").unwrap(), offset: (2.0, 4.0), radius: 8.0, spread: 1.0, ..Shadow::default() };
|
||||
doc.node_mut(id).unwrap().effects = vec![
|
||||
Effect { kind: EffectKind::DropShadow(shadow.clone()), visible: true },
|
||||
Effect { kind: EffectKind::InnerShadow(shadow), visible: false },
|
||||
Effect { kind: EffectKind::LayerBlur(Blur::uniform(3.0)), visible: true },
|
||||
Effect { kind: EffectKind::BackgroundBlur(Blur::uniform(6.0)), visible: true },
|
||||
];
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
let s = shape(&e, "Shadowed");
|
||||
assert_eq!(s["shadow"][1]["style"], "inner-shadow");
|
||||
assert_eq!(s["blur"]["type"], "layer-blur");
|
||||
assert_eq!(s["backgroundBlur"]["type"], "background-blur");
|
||||
let effects = &back.node(named(&back, "Shadowed")).unwrap().effects;
|
||||
assert_eq!(effects.len(), 4);
|
||||
assert!(matches!(&effects[0].kind, EffectKind::DropShadow(s) if s.offset == (2.0, 4.0) && s.radius == 8.0 && (s.color.a - 0.25).abs() < 0.01));
|
||||
assert!(matches!(&effects[1].kind, EffectKind::InnerShadow(_)) && !effects[1].visible);
|
||||
assert!(matches!(&effects[2].kind, EffectKind::LayerBlur(b) if b.radius == 3.0));
|
||||
assert!(matches!(&effects[3].kind, EffectKind::BackgroundBlur(b) if b.radius == 6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flex_layout_round_trips_with_its_child_order() {
|
||||
let mut doc = Document::new("Flex");
|
||||
let p = page(&doc);
|
||||
let frame = add(&mut doc, p, NodeKind::Frame, "Row", 0.0, 0.0, 300.0, 100.0);
|
||||
for name in ["First", "Second", "Third"] {
|
||||
add(&mut doc, frame, NodeKind::Rectangle, name, 0.0, 0.0, 40.0, 40.0);
|
||||
}
|
||||
let second = named(&doc, "Second");
|
||||
doc.node_mut(second).unwrap().child_layout.sizing_h = Sizing::Fill;
|
||||
doc.node_mut(frame).unwrap().layout = Some(AutoLayout {
|
||||
mode: LayoutMode::Horizontal,
|
||||
gap: 12.0,
|
||||
cross_gap: 6.0,
|
||||
padding: [1.0, 2.0, 3.0, 4.0],
|
||||
main_align: MainAlign::Center,
|
||||
cross_align: CrossAlign::Center,
|
||||
wrap: true,
|
||||
strokes_included: false,
|
||||
..AutoLayout::default()
|
||||
});
|
||||
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
let s = shape(&e, "Row");
|
||||
assert_eq!(s["layout"], "flex");
|
||||
assert_eq!(s["layoutFlexDir"], "row");
|
||||
assert_eq!(s["layoutGap"], json!({"rowGap": 6, "columnGap": 12}));
|
||||
assert_eq!(s["layoutPadding"], json!({"p1": 1, "p2": 2, "p3": 3, "p4": 4}));
|
||||
assert_eq!(shape(&e, "Second")["layoutItemHSizing"], "fill");
|
||||
|
||||
let row = back.node(named(&back, "Row")).unwrap();
|
||||
let al = row.layout.as_ref().unwrap();
|
||||
assert_eq!((al.mode, al.gap, al.cross_gap, al.padding, al.wrap), (LayoutMode::Horizontal, 12.0, 6.0, [1.0, 2.0, 3.0, 4.0], true));
|
||||
assert_eq!((al.main_align, al.cross_align), (MainAlign::Center, CrossAlign::Center));
|
||||
let order: Vec<String> = row.children.iter().map(|c| back.node(*c).unwrap().name.clone()).collect();
|
||||
assert_eq!(order, ["First", "Second", "Third"]);
|
||||
let second = back.node(named(&back, "Second")).unwrap();
|
||||
assert_eq!(second.child_layout.sizing_h, Sizing::Fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_layout_keeps_tracks_and_cells() {
|
||||
let mut doc = Document::new("Grid");
|
||||
let p = page(&doc);
|
||||
let frame = add(&mut doc, p, NodeKind::Frame, "Grid", 0.0, 0.0, 300.0, 200.0);
|
||||
let a = add(&mut doc, frame, NodeKind::Rectangle, "A", 0.0, 0.0, 10.0, 10.0);
|
||||
add(&mut doc, frame, NodeKind::Rectangle, "B", 0.0, 0.0, 10.0, 10.0);
|
||||
{
|
||||
let n = doc.node_mut(a).unwrap();
|
||||
n.child_layout.grid_row = Some(1);
|
||||
n.child_layout.grid_column = Some(0);
|
||||
n.child_layout.grid_column_span = 2;
|
||||
}
|
||||
doc.node_mut(frame).unwrap().layout = Some(AutoLayout {
|
||||
mode: LayoutMode::Grid,
|
||||
grid_columns: 3,
|
||||
grid_rows: 2,
|
||||
column_tracks: vec![GridTrack::fixed(50.0), GridTrack::fill(2.0), GridTrack::fixed(75.0)],
|
||||
row_tracks: vec![GridTrack::hug(), GridTrack::fixed(40.0)],
|
||||
gap: 5.0,
|
||||
cross_gap: 7.0,
|
||||
..AutoLayout::default()
|
||||
});
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
let s = shape(&e, "Grid");
|
||||
assert_eq!(s["layoutGridColumns"], json!([{"type": "fixed", "value": 50}, {"type": "flex", "value": 2}, {"type": "fixed", "value": 75}]));
|
||||
assert_eq!(s["layoutGridCells"].as_object().unwrap().len(), 5, "one spanning cell and four free ones");
|
||||
let al = back.node(named(&back, "Grid")).unwrap().layout.clone().unwrap();
|
||||
assert_eq!((al.mode, al.grid_columns, al.grid_rows, al.gap, al.cross_gap), (LayoutMode::Grid, 3, 2, 5.0, 7.0));
|
||||
assert_eq!(al.column_tracks[2], GridTrack::fixed(75.0));
|
||||
let a = &back.node(named(&back, "A")).unwrap().child_layout;
|
||||
assert_eq!((a.grid_row, a.grid_column, a.grid_column_span), (Some(1), Some(0), 2));
|
||||
let b = &back.node(named(&back, "B")).unwrap().child_layout;
|
||||
assert_eq!((b.grid_row, b.grid_column), (Some(0), Some(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_text_is_rebuilt_with_runs_and_styles() {
|
||||
let mut doc = Document::new("Text");
|
||||
let p = page(&doc);
|
||||
let mut text = TextData::new("Hello bold\nSecond line");
|
||||
text.style.font_family = "Inter".into();
|
||||
text.style.font_size = 18.0;
|
||||
text.style.line_height = LineHeight::Percent(150.0);
|
||||
text.align_h = TextAlignH::Center;
|
||||
text.auto_resize = TextAutoResize::Height;
|
||||
let mut bold = text.style.clone();
|
||||
bold.font_style = "Bold Italic".into();
|
||||
bold.fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]);
|
||||
text.runs.push(StyleRun { start: 6, end: 10, style: bold });
|
||||
let id = add(&mut doc, p, NodeKind::Text(text), "Copy", 0.0, 0.0, 200.0, 60.0);
|
||||
doc.node_mut(id).unwrap().fills = vec![Paint::solid(Color::from_hex("#333333").unwrap())];
|
||||
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
let s = shape(&e, "Copy");
|
||||
assert_eq!(s["growType"], "auto-height");
|
||||
let paragraphs = s["content"]["children"][0]["children"].as_array().unwrap();
|
||||
assert_eq!(paragraphs.len(), 2);
|
||||
let leaves = paragraphs[0]["children"].as_array().unwrap();
|
||||
assert_eq!(leaves[1]["text"], "bold");
|
||||
assert_eq!(leaves[1]["fontWeight"], "700");
|
||||
assert_eq!(leaves[1]["fontStyle"], "italic");
|
||||
assert_eq!(leaves[1]["fontId"], "gfont-inter");
|
||||
assert_eq!(leaves[0]["textAlign"], "center");
|
||||
|
||||
let t = back.node(named(&back, "Copy")).unwrap().kind.text().unwrap().clone();
|
||||
assert_eq!(t.characters, "Hello bold\nSecond line");
|
||||
assert_eq!(t.style.font_size, 18.0);
|
||||
assert_eq!(t.style.line_height, LineHeight::Percent(150.0));
|
||||
assert_eq!(t.align_h, TextAlignH::Center);
|
||||
assert_eq!(t.auto_resize, TextAutoResize::Height);
|
||||
let run = t.runs.iter().find(|r| r.start == 6).unwrap();
|
||||
assert_eq!((run.end, run.style.font_style.as_str()), (10, "Bold Italic"));
|
||||
assert_eq!(run.style.fills.as_ref().and_then(|f| f[0].solid_color()), Color::from_hex("#ff0000"));
|
||||
}
|
||||
|
||||
fn penpot_text_archive() -> Vec<u8> {
|
||||
let file = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
let page = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
let text = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||
let content = json!({"type": "root", "children": [{"type": "paragraph-set", "children": [{"type": "paragraph", "key": "k1", "fontId": "gfont-dm-sans", "fontFamily": "DM Sans", "fontSize": "20", "fontWeight": "600", "fontVariantId": "600", "children": [{"text": "Kept as written", "key": "k2", "fontId": "gfont-dm-sans", "fontFamily": "DM Sans", "fontSize": "20", "fontWeight": "600", "fontVariantId": "600", "fills": [{"fillColor": "#09071f", "fillOpacity": 1}]}]}]}]});
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
let files = [
|
||||
("manifest.json".to_owned(), json!({"type": "penpot/export-files", "version": 1, "files": [{"id": file, "name": "Kept"}]})),
|
||||
(format!("files/{file}.json"), json!({"id": file, "name": "Kept", "version": 67, "features": ["components/v2"], "migrations": ["legacy-2"]})),
|
||||
(format!("files/{file}/pages/{page}.json"), json!({"id": page, "name": "Page", "index": 0})),
|
||||
(format!("files/{file}/pages/{page}/{ROOT_ID}.json"), json!({"id": ROOT_ID, "type": "frame", "shapes": [text]})),
|
||||
(
|
||||
format!("files/{file}/pages/{page}/{text}.json"),
|
||||
json!({"id": text, "name": "Title", "type": "text", "x": 10, "y": 20, "width": 200, "height": 24, "parentId": ROOT_ID, "frameId": ROOT_ID, "growType": "auto-width", "content": content, "positionData": [{"x": 10, "y": 44, "width": 150, "height": 24, "text": "Kept as written", "fills": []}]}),
|
||||
),
|
||||
];
|
||||
for (name, value) in files {
|
||||
zip::ZipWriter::start_file(&mut z, name, opts).unwrap();
|
||||
std::io::Write::write_all(&mut z, value.to_string().as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_penpot_text_and_file_header_are_written_back() {
|
||||
let (doc, _) = import(&penpot_text_archive()).unwrap();
|
||||
let (bytes, _) = export(&doc).unwrap();
|
||||
let e = entries(&bytes);
|
||||
let s = shape(&e, "Title");
|
||||
assert_eq!(s["id"], "cccccccc-cccc-4ccc-8ccc-cccccccccccc");
|
||||
assert_eq!(s["content"]["children"][0]["children"][0]["key"], "k1");
|
||||
assert_eq!(s["positionData"][0]["text"], "Kept as written");
|
||||
let file = &e["files/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.json"];
|
||||
assert_eq!(file["migrations"], json!(["legacy-2"]));
|
||||
assert_eq!(file["features"], json!(["components/v2"]));
|
||||
assert!(e.contains_key("files/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/pages/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.json"));
|
||||
|
||||
let mut edited = doc.clone();
|
||||
let id = named(&edited, "Title");
|
||||
if let NodeKind::Text(t) = &mut edited.node_mut(id).unwrap().kind {
|
||||
t.characters = "Edited".into();
|
||||
}
|
||||
let e = entries(&export(&edited).unwrap().0);
|
||||
let s = shape(&e, "Title");
|
||||
assert_eq!(s["content"]["children"][0]["children"][0]["children"][0]["text"], "Edited");
|
||||
assert_eq!(s["content"]["children"][0]["children"][0]["children"][0]["fontId"], "gfont-dm-sans");
|
||||
assert!(s.get("positionData").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_instances_and_variants_round_trip() {
|
||||
let mut doc = Document::new("Library");
|
||||
let p = page(&doc);
|
||||
let main = add(&mut doc, p, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Button", 0.0, 0.0, 120.0, 40.0);
|
||||
let label = add(&mut doc, main, NodeKind::Rectangle, "Fill", 0.0, 0.0, 120.0, 40.0);
|
||||
doc.node_mut(label).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
let copy = add(&mut doc, p, NodeKind::Instance { component: main, props: BTreeMap::new() }, "Button copy", 0.0, 100.0, 120.0, 40.0);
|
||||
let inner = add(&mut doc, copy, NodeKind::Rectangle, "Fill", 0.0, 0.0, 120.0, 40.0);
|
||||
{
|
||||
let n = doc.node_mut(inner).unwrap();
|
||||
n.fills = vec![Paint::solid(Color::WHITE)];
|
||||
n.source = Some(label);
|
||||
n.overrides.insert("fills".into());
|
||||
}
|
||||
doc.node_mut(copy).unwrap().source = Some(main);
|
||||
let set = add(&mut doc, p, NodeKind::ComponentSet { description: String::new() }, "Size", 300.0, 0.0, 200.0, 100.0);
|
||||
for (name, y) in [("size=small", 0.0), ("size=large", 50.0)] {
|
||||
add(&mut doc, set, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name, 0.0, y, 100.0, 40.0);
|
||||
}
|
||||
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
let components: Vec<&Value> = e.iter().filter(|(k, _)| k.contains("/components/")).map(|(_, v)| v).collect();
|
||||
assert_eq!(components.len(), 3);
|
||||
let variant = components.iter().find(|c| c["variantProperties"].is_array()).unwrap();
|
||||
assert_eq!(variant["variantId"], shape(&e, "Size")["id"]);
|
||||
assert_eq!(shape(&e, "Size")["isVariantContainer"], true);
|
||||
let copy_json = shape(&e, "Button copy");
|
||||
assert_eq!(copy_json["componentRoot"], true);
|
||||
assert_eq!(copy_json["shapeRef"], shape(&e, "Button")["id"]);
|
||||
let inner_json = e.values().find(|v| v["name"] == "Fill" && v["shapeRef"].is_string()).unwrap();
|
||||
assert_eq!(inner_json["touched"], json!(["fill-group"]));
|
||||
|
||||
let copy = back.node(named(&back, "Button copy")).unwrap();
|
||||
assert!(matches!(copy.kind, NodeKind::Instance { component, .. } if component == named(&back, "Button")));
|
||||
assert!(copy.children.iter().all(|c| back.node(*c).unwrap().source.is_some()));
|
||||
let set = back.node(named(&back, "Size")).unwrap();
|
||||
assert!(matches!(set.kind, NodeKind::ComponentSet { .. }));
|
||||
let names: BTreeSet<String> = set.children.iter().map(|c| back.node(*c).unwrap().name.clone()).collect();
|
||||
assert_eq!(names, BTreeSet::from(["size=small".to_owned(), "size=large".to_owned()]));
|
||||
}
|
||||
|
||||
fn variable(id: &str, name: &str, collection: &str, kind: VariableType, values: &[(&str, VariableValue)]) -> Variable {
|
||||
Variable {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
collection: collection.into(),
|
||||
kind,
|
||||
values: values.iter().map(|(m, v)| ((*m).to_owned(), v.clone())).collect(),
|
||||
description: String::new(),
|
||||
scopes: None,
|
||||
hidden_from_publishing: false,
|
||||
extends: None,
|
||||
overrides: BTreeSet::new(),
|
||||
code_syntax: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_variables_become_token_sets_themes_and_applied_tokens() {
|
||||
let mut doc = Document::new("Tokens");
|
||||
doc.variables.collections.push(Collection {
|
||||
id: "c1".into(),
|
||||
name: "Color".into(),
|
||||
modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }],
|
||||
extends: None,
|
||||
});
|
||||
doc.variables.collections.push(Collection {
|
||||
id: "c2".into(),
|
||||
name: "Size".into(),
|
||||
modes: vec![Mode { id: "one".into(), name: "Default".into() }],
|
||||
extends: None,
|
||||
});
|
||||
doc.variables.variables.push(variable(
|
||||
"v1",
|
||||
"brand/primary",
|
||||
"c1",
|
||||
VariableType::Color,
|
||||
&[("light", VariableValue::Color(Color::from_hex("#ff0000").unwrap())), ("dark", VariableValue::Color(Color::from_hex("#880000").unwrap()))],
|
||||
));
|
||||
doc.variables.variables.push(variable("v2", "space/m", "c2", VariableType::Number, &[("one", VariableValue::Number(16.0))]));
|
||||
doc.variables.variables.push(variable(
|
||||
"v3",
|
||||
"brand/alias",
|
||||
"c1",
|
||||
VariableType::Color,
|
||||
&[("light", VariableValue::Alias("v1".into())), ("dark", VariableValue::Alias("v1".into()))],
|
||||
));
|
||||
let p = page(&doc);
|
||||
let id = add(&mut doc, p, NodeKind::Rectangle, "Bound", 0.0, 0.0, 16.0, 10.0);
|
||||
doc.node_mut(id).unwrap().fills = vec![Paint::solid(Color::from_hex("#ff0000").unwrap())];
|
||||
doc.node_mut(id).unwrap().bound.insert("fills/0/color".into(), "v1".into());
|
||||
doc.node_mut(id).unwrap().bound.insert("width".into(), "v2".into());
|
||||
|
||||
let (bytes, _) = export(&doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(&bytes[..])).unwrap();
|
||||
let tokens_path = z.file_names().find(|n| n.ends_with("tokens.json")).unwrap().to_owned();
|
||||
let mut text = String::new();
|
||||
z.by_name(&tokens_path).unwrap().read_to_string(&mut text).unwrap();
|
||||
let tokens: Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(tokens["Color/Light"]["brand"]["primary"]["$value"], "#ff0000");
|
||||
assert_eq!(tokens["Color/Dark"]["brand"]["primary"]["$value"], "#880000");
|
||||
assert_eq!(tokens["Color/Light"]["brand"]["alias"]["$value"], "{brand.primary}");
|
||||
assert_eq!(tokens["Size"]["space"]["m"]["$value"], "16");
|
||||
assert_eq!(tokens["$themes"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(tokens["$metadata"]["activeThemes"], json!(["Color/Light"]));
|
||||
let e = entries(&bytes);
|
||||
assert_eq!(shape(&e, "Bound")["appliedTokens"], json!({"fill": "brand.primary", "width": "space.m"}));
|
||||
|
||||
let (back, _) = import(&bytes).unwrap();
|
||||
assert!(back.variables.variables.iter().any(|v| v.name.ends_with("brand.primary")));
|
||||
let bound = back.node(named(&back, "Bound")).unwrap();
|
||||
assert!(bound.bound.contains_key("width"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_penpot_tokens_are_written_back_verbatim() {
|
||||
let mut doc = Document::new("Kept tokens");
|
||||
let p = page(&doc);
|
||||
let source = r#"{"Base":{"radius":{"$value":"4","$type":"borderRadius","$description":""}},"$themes":[],"$metadata":{"tokenSetOrder":["Base"]}}"#;
|
||||
doc.node_mut(p).unwrap().plugin_data.insert("penpot.tokens.f".into(), source.into());
|
||||
let fp = super::tokens_fingerprint(&doc).unwrap();
|
||||
doc.node_mut(p).unwrap().plugin_data.insert("penpot.fp.tokens".into(), fp);
|
||||
let (bytes, warnings) = export(&doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(&bytes[..])).unwrap();
|
||||
let path = z.file_names().find(|n| n.ends_with("tokens.json")).unwrap().to_owned();
|
||||
let mut text = String::new();
|
||||
z.by_name(&path).unwrap().read_to_string(&mut text).unwrap();
|
||||
assert_eq!(text, source);
|
||||
assert!(warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prototype_interactions_and_flows_round_trip() {
|
||||
let mut doc = Document::new("Proto");
|
||||
let p = page(&doc);
|
||||
let home = add(&mut doc, p, NodeKind::Frame, "Home", 0.0, 0.0, 100.0, 100.0);
|
||||
let next = add(&mut doc, p, NodeKind::Frame, "Next", 200.0, 0.0, 100.0, 100.0);
|
||||
let menu = add(&mut doc, p, NodeKind::Frame, "Menu", 400.0, 0.0, 50.0, 50.0);
|
||||
let button = add(&mut doc, home, NodeKind::Rectangle, "Go", 10.0, 10.0, 30.0, 30.0);
|
||||
doc.node_mut(button).unwrap().interactions = vec![
|
||||
Interaction {
|
||||
trigger: Trigger::OnClick,
|
||||
actions: vec![Action::Navigate { dest: next, preserve_scroll: true }],
|
||||
transition: Transition::SlideIn { direction: Direction::Right },
|
||||
duration_ms: 300,
|
||||
easing: Easing::EaseOut,
|
||||
delay_ms: 0,
|
||||
},
|
||||
Interaction {
|
||||
trigger: Trigger::MouseEnter,
|
||||
actions: vec![Action::OpenOverlay {
|
||||
anchor: Default::default(),
|
||||
dest: menu,
|
||||
position: OverlayPosition::Center,
|
||||
close_on_outside: true,
|
||||
background: Some(Color::BLACK),
|
||||
offset: None,
|
||||
}],
|
||||
transition: Transition::Dissolve,
|
||||
duration_ms: 200,
|
||||
easing: Easing::Linear,
|
||||
delay_ms: 0,
|
||||
},
|
||||
Interaction {
|
||||
trigger: Trigger::AfterDelay { ms: 1500 },
|
||||
actions: vec![Action::Back],
|
||||
transition: Transition::Instant,
|
||||
duration_ms: 0,
|
||||
easing: Easing::Linear,
|
||||
delay_ms: 0,
|
||||
},
|
||||
];
|
||||
if let NodeKind::Page { flows, .. } = &mut doc.node_mut(p).unwrap().kind {
|
||||
flows.push(Flow { name: "Main".into(), start: home, description: String::new() });
|
||||
}
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
let items = shape(&e, "Go")["interactions"].as_array().unwrap().clone();
|
||||
assert_eq!(items[0]["destination"], shape(&e, "Next")["id"]);
|
||||
assert_eq!(items[0]["animation"]["animationType"], "slide");
|
||||
assert_eq!(items[1]["actionType"], "open-overlay");
|
||||
assert_eq!(items[2]["actionType"], "prev-screen");
|
||||
let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1;
|
||||
assert_eq!(page_json["flows"].as_object().unwrap().values().next().unwrap()["startingFrame"], shape(&e, "Home")["id"]);
|
||||
|
||||
let go = back.node(named(&back, "Go")).unwrap();
|
||||
assert_eq!(go.interactions.len(), 3);
|
||||
assert!(matches!(go.interactions[0].actions[0], Action::Navigate { dest, preserve_scroll: true } if dest == named(&back, "Next")));
|
||||
assert_eq!(go.interactions[0].transition, Transition::SlideIn { direction: Direction::Right });
|
||||
assert_eq!((go.interactions[0].duration_ms, go.interactions[0].easing), (300, Easing::EaseOut));
|
||||
assert!(matches!(go.interactions[1].actions[0], Action::OpenOverlay { dest, close_on_outside: true, .. } if dest == named(&back, "Menu")));
|
||||
assert!(matches!(go.interactions[2].trigger, Trigger::AfterDelay { ms: 1500 }));
|
||||
let NodeKind::Page { flows, .. } = &back.node(back.pages[0]).unwrap().kind else { panic!() };
|
||||
assert_eq!(flows.len(), 1);
|
||||
assert_eq!(flows[0].start, named(&back, "Home"));
|
||||
if let Action::OpenOverlay { anchor, .. } = &mut doc.node_mut(button).unwrap().interactions[1].actions[0] {
|
||||
*anchor = figcraft_doc::proto::OverlayAnchor::TriggerOutside;
|
||||
}
|
||||
let (_, warnings) = export(&doc).unwrap();
|
||||
assert!(warnings.iter().any(|warning| warning.contains("Overlay anchor") && warning.contains("screen")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_fills_write_media_and_storage_objects() {
|
||||
let mut doc = Document::new("Images");
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
image::RgbaImage::from_pixel(3, 2, image::Rgba([10, 20, 30, 255])).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
let (hash, _, _) = crate::add_image(&mut doc, png.into_inner()).unwrap();
|
||||
let p = page(&doc);
|
||||
let id = add(&mut doc, p, NodeKind::Rectangle, "Picture", 0.0, 0.0, 30.0, 20.0);
|
||||
doc.node_mut(id).unwrap().fills = vec![Paint::image(hash.clone(), ImageScaleMode::Fill)];
|
||||
let (back, e, bytes) = roundtrip(&doc);
|
||||
let fill = &shape(&e, "Picture")["fills"][0]["fillImage"];
|
||||
assert_eq!((fill["width"].as_u64(), fill["height"].as_u64(), fill["mtype"].as_str()), (Some(3), Some(2), Some("image/png")));
|
||||
let media = e.iter().find(|(k, _)| k.contains("/media/")).unwrap().1;
|
||||
assert_eq!(media["id"], fill["id"]);
|
||||
let object = &e[&format!("objects/{}.json", media["mediaId"].as_str().unwrap())];
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(&bytes[..])).unwrap();
|
||||
let blob = z.by_name(&format!("objects/{}.png", media["mediaId"].as_str().unwrap())).unwrap().size();
|
||||
assert_eq!(object["size"].as_u64(), Some(blob));
|
||||
assert_eq!(object["bucket"], "file-media-object");
|
||||
let n = back.node(named(&back, "Picture")).unwrap();
|
||||
assert!(matches!(&n.fills[0].kind, PaintKind::Image(img) if img.image == hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicated_penpot_ids_are_made_unique_per_page() {
|
||||
let mut doc = Document::new("Dupes");
|
||||
let p = page(&doc);
|
||||
for name in ["One", "Two"] {
|
||||
let id = add(&mut doc, p, NodeKind::Rectangle, name, 0.0, 0.0, 10.0, 10.0);
|
||||
doc.node_mut(id).unwrap().plugin_data.insert("penpot.id".into(), "dddddddd-dddd-4ddd-8ddd-dddddddddddd".into());
|
||||
}
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
assert_eq!(shape(&e, "One")["id"], "dddddddd-dddd-4ddd-8ddd-dddddddddddd");
|
||||
assert_ne!(shape(&e, "Two")["id"], shape(&e, "One")["id"]);
|
||||
assert_eq!(back.nodes().filter(|n| matches!(n.kind, NodeKind::Rectangle)).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_paints_and_effects_are_reported() {
|
||||
let mut doc = Document::new("Lossy");
|
||||
let p = page(&doc);
|
||||
let id = add(&mut doc, p, NodeKind::Rectangle, "Lossy", 0.0, 0.0, 10.0, 10.0);
|
||||
let mut hidden = Paint::solid(Color::BLACK);
|
||||
hidden.visible = false;
|
||||
doc.node_mut(id).unwrap().fills = vec![hidden];
|
||||
let (_, warnings) = export(&doc).unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("Hidden fills")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_coloured_run_does_not_recolour_the_rest_of_the_text() {
|
||||
let mut text = TextData::new("plain red");
|
||||
text.style.fills = Some(vec![Paint::solid(Color::BLACK)]);
|
||||
let mut red = text.style.clone();
|
||||
red.fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]);
|
||||
text.runs.push(StyleRun { start: 6, end: 9, style: red });
|
||||
assert_eq!(super::uniform_text_fills(&text), None);
|
||||
text.runs[0].start = 0;
|
||||
assert!(super::uniform_text_fills(&text).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_grids_and_guides_round_trip() {
|
||||
let mut doc = Document::new("Grids");
|
||||
let p = page(&doc);
|
||||
doc.node_mut(p).unwrap().guides.push(figcraft_doc::Guide { vertical: true, offset: 64.0 });
|
||||
let frame = add(&mut doc, p, NodeKind::Frame, "Board", 100.0, 50.0, 400.0, 300.0);
|
||||
{
|
||||
let n = doc.node_mut(frame).unwrap();
|
||||
n.grids = vec![
|
||||
figcraft_doc::LayoutGrid { count: 12, gutter: 16.0, offset: 24.0, ..figcraft_doc::LayoutGrid::columns() },
|
||||
figcraft_doc::LayoutGrid { size: 8.0, ..figcraft_doc::LayoutGrid::default() },
|
||||
];
|
||||
n.guides = vec![figcraft_doc::Guide { vertical: false, offset: 10.0 }];
|
||||
}
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
let grids = &shape(&e, "Board")["grids"];
|
||||
assert_eq!(grids[0]["type"], "column");
|
||||
assert_eq!(grids[0]["params"]["size"], 12);
|
||||
assert_eq!(grids[1]["params"]["size"], 8);
|
||||
let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1;
|
||||
let guides: Vec<&Value> = page_json["guides"].as_object().unwrap().values().collect();
|
||||
assert!(guides.iter().any(|g| g["axis"] == "x" && g["position"] == 64 && g.get("frameId").is_none()));
|
||||
assert!(guides.iter().any(|g| g["axis"] == "y" && g["position"] == 60 && g["frameId"] == shape(&e, "Board")["id"]));
|
||||
let n = back.node(named(&back, "Board")).unwrap();
|
||||
assert_eq!((n.grids[0].count, n.grids[0].gutter, n.grids[0].offset), (12, 16.0, 24.0));
|
||||
assert_eq!(n.grids[1].size, 8.0);
|
||||
let guides = &back.node(back.pages[0]).unwrap().guides;
|
||||
assert!(guides.iter().any(|g| g.vertical && g.offset == 64.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_svg_layers_go_back_as_svg_raw_without_their_expansion() {
|
||||
let mut doc = Document::new("Svg");
|
||||
let p = page(&doc);
|
||||
let raw = add(&mut doc, p, NodeKind::Frame, "Logo", 0.0, 0.0, 24.0, 24.0);
|
||||
add(&mut doc, raw, NodeKind::Frame, "Expanded", 0.0, 0.0, 24.0, 24.0);
|
||||
{
|
||||
let n = doc.node_mut(raw).unwrap();
|
||||
n.plugin_data.insert("penpot.id".into(), "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee".into());
|
||||
n.plugin_data.insert("penpot.svgRawStatus".into(), "expanded".into());
|
||||
n.plugin_data.insert("penpot.source.content".into(), r#"{"tag":"svg","attrs":{"viewBox":"0 0 24 24"},"content":[]}"#.into());
|
||||
}
|
||||
let (bytes, _) = export(&doc).unwrap();
|
||||
let e = entries(&bytes);
|
||||
let s = shape(&e, "Logo");
|
||||
assert_eq!(s["type"], "svg-raw");
|
||||
assert_eq!(s["content"]["tag"], "svg");
|
||||
assert_eq!(s["shapes"], json!([]));
|
||||
assert!(!e.values().any(|v| v["name"] == "Expanded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_children_beyond_the_declared_rows_get_implicit_rows() {
|
||||
let mut doc = Document::new("Overflow");
|
||||
let p = page(&doc);
|
||||
let frame = add(&mut doc, p, NodeKind::Frame, "Grid", 0.0, 0.0, 200.0, 200.0);
|
||||
for i in 0..5 {
|
||||
add(&mut doc, frame, NodeKind::Rectangle, &format!("Item {i}"), 0.0, 0.0, 10.0, 10.0);
|
||||
}
|
||||
doc.node_mut(frame).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Grid, grid_columns: 2, grid_rows: 1, ..AutoLayout::default() });
|
||||
let (bytes, warnings) = export(&doc).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let e = entries(&bytes);
|
||||
let s = shape(&e, "Grid");
|
||||
assert_eq!(s["layoutGridRows"].as_array().unwrap().len(), 3);
|
||||
let filled = s["layoutGridCells"].as_object().unwrap().values().filter(|c| !c["shapes"].as_array().unwrap().is_empty()).count();
|
||||
assert_eq!(filled, 5);
|
||||
}
|
||||
|
||||
fn penpot_archive(shapes: &[Value]) -> Vec<u8> {
|
||||
let file = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
let page = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
let top: Vec<&Value> = shapes.iter().filter(|s| s["parentId"] == ROOT_ID).map(|s| &s["id"]).collect();
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
let mut files = vec![
|
||||
("manifest.json".to_owned(), json!({"type": "penpot/export-files", "version": 1, "files": [{"id": file, "name": "Kept"}]})),
|
||||
(format!("files/{file}/pages/{page}.json"), json!({"id": page, "name": "Page", "index": 0})),
|
||||
(format!("files/{file}/pages/{page}/{ROOT_ID}.json"), json!({"id": ROOT_ID, "type": "frame", "shapes": top})),
|
||||
];
|
||||
for s in shapes {
|
||||
files.push((format!("files/{file}/pages/{page}/{}.json", s["id"].as_str().unwrap()), s.clone()));
|
||||
}
|
||||
for (name, value) in files {
|
||||
zip::ZipWriter::start_file(&mut z, name, opts).unwrap();
|
||||
std::io::Write::write_all(&mut z, value.to_string().as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn penpot_only_layer_data_survives_unchanged_layers() {
|
||||
let board = "11111111-1111-4111-8111-111111111111";
|
||||
let ring = "22222222-2222-4222-8222-222222222222";
|
||||
let label = "33333333-3333-4333-8333-333333333333";
|
||||
let strokes = json!([
|
||||
{"strokeColor": "#000000", "strokeOpacity": 1, "strokeWidth": 4, "strokeAlignment": "outer", "strokeStyle": "solid"},
|
||||
{"strokeColor": "#00ffff", "strokeOpacity": 1, "strokeWidth": 1, "strokeAlignment": "inner", "strokeStyle": "solid"}
|
||||
]);
|
||||
let position: Vec<Value> =
|
||||
(0..400).map(|i| json!({"x": 0, "y": i, "width": 100, "height": 20, "text": "a line of positioned text", "fills": []})).collect();
|
||||
let content = json!({"type": "root", "children": [{"type": "paragraph-set", "children": [{"type": "paragraph", "children": [{"text": "Label", "fontSize": "14"}]}, {"type": "paragraph", "children": [{"text": ""}]}]}]});
|
||||
let bytes = penpot_archive(&[
|
||||
json!({"id": board, "name": "Board", "type": "frame", "x": 0, "y": 0, "width": 200, "height": 100, "parentId": ROOT_ID, "frameId": ROOT_ID, "shapes": [ring, label], "layout": "flex", "layoutFlexDir": "row", "exports": [{"type": "png", "suffix": "", "scale": 2}]}),
|
||||
json!({"id": ring, "name": "Ring", "type": "circle", "x": 10, "y": 10, "width": 40, "height": 40, "parentId": board, "frameId": board, "strokes": strokes, "layoutItemZIndex": 2}),
|
||||
json!({"id": label, "name": "Label", "type": "text", "x": 60, "y": 10, "width": 100, "height": 40, "parentId": board, "frameId": board, "content": content, "positionData": position, "hideInViewer": true}),
|
||||
]);
|
||||
let (doc, _) = import(&bytes).unwrap();
|
||||
let e = entries(&export(&doc).unwrap().0);
|
||||
assert_eq!(shape(&e, "Ring")["strokes"], strokes);
|
||||
assert_eq!(shape(&e, "Ring")["layoutItemZIndex"], 2);
|
||||
assert_eq!(shape(&e, "Board")["exports"][0]["scale"], 2);
|
||||
assert_eq!(shape(&e, "Label")["hideInViewer"], true);
|
||||
assert_eq!(shape(&e, "Label")["positionData"].as_array().unwrap().len(), 400);
|
||||
assert_eq!(shape(&e, "Label")["fills"], json!([]));
|
||||
|
||||
let mut edited = doc.clone();
|
||||
let id = named(&edited, "Ring");
|
||||
edited.node_mut(id).unwrap().stroke.weight = 6.0;
|
||||
let e = entries(&export(&edited).unwrap().0);
|
||||
let regenerated = shape(&e, "Ring")["strokes"].as_array().unwrap().clone();
|
||||
assert!(regenerated.iter().all(|s| s["strokeWidth"] == 6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_penpot_header_without_migrations_lets_penpot_run_them() {
|
||||
let mut doc = Document::new("Old");
|
||||
let p = page(&doc);
|
||||
doc.node_mut(p).unwrap().plugin_data.insert("penpot.source.file".into(), r#"{"version":58,"features":["components/v2"]}"#.into());
|
||||
let e = entries(&export(&doc).unwrap().0);
|
||||
let file = e.iter().find(|(k, _)| k.matches('/').count() == 1).unwrap().1;
|
||||
assert_eq!(file["version"], 58);
|
||||
assert!(file.get("migrations").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_image_shapes_stretch_over_their_fills_like_penpot() {
|
||||
let file = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
let page = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
let photo_id = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||
let media = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
||||
let object = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
image::RgbaImage::from_pixel(4, 2, image::Rgba([200, 10, 10, 255])).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
let members: Vec<(String, Vec<u8>)> = vec![
|
||||
("manifest.json".into(), json!({"type": "penpot/export-files", "version": 1, "files": [{"id": file, "name": "Legacy"}]}).to_string().into_bytes()),
|
||||
(format!("files/{file}/pages/{page}.json"), json!({"id": page, "name": "Page", "index": 0}).to_string().into_bytes()),
|
||||
(format!("files/{file}/pages/{page}/{ROOT_ID}.json"), json!({"id": ROOT_ID, "type": "frame", "shapes": [photo_id]}).to_string().into_bytes()),
|
||||
(
|
||||
format!("files/{file}/pages/{page}/{photo_id}.json"),
|
||||
json!({"id": photo_id, "type": "image", "name": "Photo", "width": 100, "height": 100, "metadata": {"id": media, "width": 4, "height": 2, "mtype": "image/png"}, "fills": [{"fillColor": "#00ff00", "fillOpacity": 1}]})
|
||||
.to_string()
|
||||
.into_bytes(),
|
||||
),
|
||||
(format!("files/{file}/media/{media}.json"), json!({"id": media, "mtype": "image/png", "mediaId": object}).to_string().into_bytes()),
|
||||
(format!("objects/{object}.png"), png.into_inner()),
|
||||
];
|
||||
for (name, data) in members {
|
||||
zip::ZipWriter::start_file(&mut z, name, opts).unwrap();
|
||||
std::io::Write::write_all(&mut z, &data).unwrap();
|
||||
}
|
||||
let (doc, _) = import(&z.finish().unwrap().into_inner()).unwrap();
|
||||
let photo = doc.node(named(&doc, "Photo")).unwrap();
|
||||
assert_eq!(photo.fills.len(), 2);
|
||||
assert!(matches!(&photo.fills[1].kind, PaintKind::Image(img) if img.scale_mode == ImageScaleMode::Crop));
|
||||
let e = entries(&export(&doc).unwrap().0);
|
||||
assert_eq!(shape(&e, "Photo")["fills"][0]["fillImage"]["keepAspectRatio"], false);
|
||||
assert_eq!(shape(&e, "Photo")["fills"][1]["fillColor"], "#00ff00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_guides_reloaded_from_a_figcraft_file_are_exported() {
|
||||
let mut doc = Document::new("Reloaded");
|
||||
let p = page(&doc);
|
||||
doc.node_mut(p).unwrap().guides = vec![figcraft_doc::Guide { vertical: false, offset: 410.0 }];
|
||||
let e = entries(&export(&doc).unwrap().0);
|
||||
let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1;
|
||||
assert_eq!(page_json["guides"].as_object().unwrap().values().next().unwrap()["position"], 410);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Penpot prototype interactions and flows, mapped to FigCraft's native prototype model.
|
||||
//!
|
||||
//! Penpot stores an interaction on the shape that fires it: an event type (`click`,
|
||||
//! `mouse-press`, `mouse-over`, `mouse-enter`, `mouse-leave`, `after-delay`), an action
|
||||
//! type (`navigate`, `open-overlay`, `toggle-overlay`, `close-overlay`, `prev-screen`,
|
||||
//! `open-url`), the destination shape and an optional animation. FigCraft models the same
|
||||
//! idea as an [`Interaction`] (a trigger plus a list of actions) and a [`Flow`] (a named
|
||||
//! starting frame on a page).
|
||||
//!
|
||||
//! A destination is a Penpot shape id, but a FigCraft action points at a node id. The
|
||||
//! importer therefore keeps the Penpot source of every interaction in the layer's metadata
|
||||
//! and reads it back once every page is in the document, when the Penpot ids can be
|
||||
//! resolved to node ids (see [`resolve_destinations`]).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_doc::color::Color;
|
||||
use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, OverlayPosition, Transition, Trigger};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::warn;
|
||||
|
||||
/// Longest animation Penpot may declare, in milliseconds.
|
||||
const MAX_DURATION_MS: u32 = 600_000;
|
||||
/// Longest "after delay" wait, in milliseconds.
|
||||
const MAX_DELAY_MS: u32 = 600_000;
|
||||
/// Most interactions read from one shape.
|
||||
const MAX_INTERACTIONS: usize = 64;
|
||||
/// Most flows a page may declare (Penpot allows two).
|
||||
const MAX_FLOWS: usize = 8;
|
||||
/// Longest stored interaction source for one shape, in bytes.
|
||||
const MAX_INTERACTION_SOURCE: usize = 64 * 1024;
|
||||
|
||||
/// Penpot's `event-type` names, mapped to FigCraft's triggers.
|
||||
fn penpot_trigger(event: Option<&str>, delay_ms: Option<u32>) -> Option<Trigger> {
|
||||
match event? {
|
||||
"click" => Some(Trigger::OnClick),
|
||||
// "mouse-press" fires while the pointer is held: FigCraft's "while pressing".
|
||||
"mouse-press" => Some(Trigger::OnPress),
|
||||
// "mouse-over" fires while the pointer is over the shape.
|
||||
"mouse-over" => Some(Trigger::OnHover),
|
||||
"mouse-enter" => Some(Trigger::MouseEnter),
|
||||
"mouse-leave" => Some(Trigger::MouseLeave),
|
||||
// Penpot's own default wait for a delayed trigger.
|
||||
"after-delay" => Some(Trigger::AfterDelay { ms: delay_ms.unwrap_or(600).min(MAX_DELAY_MS) }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Penpot's `overlay-pos-type` names, mapped to FigCraft's overlay positions.
|
||||
fn penpot_overlay_position(position: Option<&str>) -> Option<OverlayPosition> {
|
||||
Some(match position? {
|
||||
"manual" => OverlayPosition::Manual,
|
||||
"center" => OverlayPosition::Center,
|
||||
"top-left" => OverlayPosition::TopLeft,
|
||||
"top-right" => OverlayPosition::TopRight,
|
||||
"top-center" => OverlayPosition::TopCenter,
|
||||
"bottom-left" => OverlayPosition::BottomLeft,
|
||||
"bottom-right" => OverlayPosition::BottomRight,
|
||||
"bottom-center" => OverlayPosition::BottomCenter,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// What a Penpot action does, without the destination it points at.
|
||||
enum PenpotActionKind {
|
||||
Navigate,
|
||||
OpenOverlay,
|
||||
ToggleOverlay,
|
||||
CloseOverlay,
|
||||
Back,
|
||||
OpenUrl,
|
||||
}
|
||||
|
||||
/// Penpot's `action-type` names.
|
||||
fn penpot_action_kind(action: Option<&str>) -> Option<PenpotActionKind> {
|
||||
Some(match action? {
|
||||
"navigate" => PenpotActionKind::Navigate,
|
||||
"open-overlay" => PenpotActionKind::OpenOverlay,
|
||||
"toggle-overlay" => PenpotActionKind::ToggleOverlay,
|
||||
"close-overlay" => PenpotActionKind::CloseOverlay,
|
||||
"prev-screen" => PenpotActionKind::Back,
|
||||
"open-url" => PenpotActionKind::OpenUrl,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the action opens an overlay (Penpot plays its animation differently).
|
||||
fn is_overlay(kind: &PenpotActionKind) -> bool {
|
||||
matches!(kind, PenpotActionKind::OpenOverlay | PenpotActionKind::ToggleOverlay)
|
||||
}
|
||||
|
||||
/// Penpot's animation kinds, mapped to FigCraft's transitions.
|
||||
///
|
||||
/// Penpot animates a navigation with `dissolve`, `slide` or `push`; FigCraft's `SlideIn`
|
||||
/// and `SlideOut` are the closest to Penpot's `slide`, whose `way` says whether the new
|
||||
/// screen comes in or the old one goes out.
|
||||
fn penpot_transition(animation: Option<&Value>, overlay: bool) -> (Transition, u32, Easing) {
|
||||
let Some(animation) = animation else { return (Transition::Instant, 0, Easing::Linear) };
|
||||
let kind = animation.get("animationType").and_then(Value::as_str);
|
||||
let duration = animation
|
||||
.get("duration")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|d| d.is_finite() && *d >= 0.0)
|
||||
.map(|d| d.min(f64::from(MAX_DURATION_MS)) as u32)
|
||||
.unwrap_or(0);
|
||||
let easing = match animation.get("easing").and_then(Value::as_str) {
|
||||
Some("linear") => Easing::Linear,
|
||||
Some("ease") => Easing::EaseInOut,
|
||||
Some("ease-in") => Easing::EaseIn,
|
||||
Some("ease-out") => Easing::EaseOut,
|
||||
Some("ease-in-out") => Easing::EaseInOut,
|
||||
_ => Easing::Linear,
|
||||
};
|
||||
// Penpot's direction names the way the screen moves, as FigCraft's does.
|
||||
let direction = match animation.get("direction").and_then(Value::as_str) {
|
||||
Some("right") => Direction::Right,
|
||||
Some("up") => Direction::Top,
|
||||
Some("down") => Direction::Bottom,
|
||||
_ => Direction::Left,
|
||||
};
|
||||
// An overlay's animation is the overlay coming in: its "way" does not apply, and a
|
||||
// push is not an overlay animation at all.
|
||||
let way = if overlay { Some("in") } else { animation.get("way").and_then(Value::as_str) };
|
||||
let transition = match (kind, way) {
|
||||
(Some("dissolve"), _) => Transition::Dissolve,
|
||||
(Some("slide"), Some("out")) => Transition::SlideOut { direction },
|
||||
(Some("slide"), _) => Transition::SlideIn { direction },
|
||||
(Some("push"), _) if !overlay => Transition::Push { direction },
|
||||
_ => Transition::Instant,
|
||||
};
|
||||
(transition, duration, easing)
|
||||
}
|
||||
|
||||
/// One Penpot interaction, read from a shape.
|
||||
pub(super) struct PenpotInteraction {
|
||||
/// The trigger that fires it.
|
||||
pub trigger: Trigger,
|
||||
/// The action it runs. Its destination is a placeholder until the ids are resolved.
|
||||
pub action: Action,
|
||||
/// The Penpot shape id the action points at, if any.
|
||||
pub destination: Option<String>,
|
||||
/// The transition it plays.
|
||||
pub transition: Transition,
|
||||
/// How long the transition lasts, in milliseconds.
|
||||
pub duration_ms: u32,
|
||||
/// The curve the transition follows.
|
||||
pub easing: Easing,
|
||||
}
|
||||
|
||||
/// The action a Penpot interaction runs, with its destination left for the resolver.
|
||||
fn penpot_action(item: &Value, kind: &PenpotActionKind) -> Option<(Action, Option<String>)> {
|
||||
let destination = item.get("destination").and_then(Value::as_str).map(str::to_owned);
|
||||
let action = match kind {
|
||||
PenpotActionKind::Navigate => {
|
||||
Action::Navigate { dest: NodeId(0), preserve_scroll: item.get("preserveScroll").and_then(Value::as_bool).unwrap_or(false) }
|
||||
}
|
||||
PenpotActionKind::ToggleOverlay => return None,
|
||||
PenpotActionKind::OpenOverlay => {
|
||||
let position = penpot_overlay_position(item.get("overlayPosType").and_then(Value::as_str))?;
|
||||
// A manual overlay sits at an explicit offset from the layer it was opened
|
||||
// from. FigCraft measures that offset from the top-left of the opening layer.
|
||||
let offset = match (position, item.get("overlayPosition")) {
|
||||
(OverlayPosition::Manual, Some(p)) => {
|
||||
let x = p.get("x").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let y = p.get("y").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
(x.is_finite() && y.is_finite()).then_some([x.clamp(-100_000.0, 100_000.0), y.clamp(-100_000.0, 100_000.0)])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// Penpot leaves "close when clicking outside" off unless it is asked for.
|
||||
let close_on_outside = item.get("closeClickOutside").and_then(Value::as_bool).unwrap_or(false);
|
||||
// Penpot draws a translucent black sheet behind an overlay that asks for one.
|
||||
let background = item.get("backgroundOverlay").and_then(Value::as_bool).unwrap_or(false).then(|| Color::new(0.0, 0.0, 0.0, 0.2));
|
||||
Action::OpenOverlay { dest: NodeId(0), position, close_on_outside, background, offset, anchor: Default::default() }
|
||||
}
|
||||
PenpotActionKind::CloseOverlay => Action::CloseOverlay,
|
||||
// Penpot's "previous screen" is the player's back action.
|
||||
PenpotActionKind::Back => Action::Back,
|
||||
PenpotActionKind::OpenUrl => {
|
||||
let url = item.get("url").and_then(Value::as_str)?.trim();
|
||||
if url.is_empty() || url.len() > 2048 {
|
||||
return None;
|
||||
}
|
||||
Action::OpenUrl { url: url.chars().take(2048).collect() }
|
||||
}
|
||||
};
|
||||
let needs_destination = matches!(kind, PenpotActionKind::Navigate | PenpotActionKind::OpenOverlay | PenpotActionKind::ToggleOverlay);
|
||||
Some((action, if needs_destination { destination } else { None }))
|
||||
}
|
||||
|
||||
/// Map one shape's `interactions` array.
|
||||
///
|
||||
/// Returns the interactions that could be mapped, and a note for each one that could not.
|
||||
pub(super) fn shape_interactions(items: &[Value], warnings: &mut Vec<String>) -> Vec<PenpotInteraction> {
|
||||
let mut out = Vec::new();
|
||||
for item in items.iter().take(MAX_INTERACTIONS) {
|
||||
let event = item.get("eventType").and_then(Value::as_str);
|
||||
let Some(trigger) = penpot_trigger(event, item.get("delay").and_then(Value::as_u64).map(|d| d.min(u64::from(MAX_DELAY_MS)) as u32)) else {
|
||||
warnings.push(format!("Penpot interaction with event type '{}' has no FigCraft equivalent and was skipped", event.unwrap_or("unknown")));
|
||||
continue;
|
||||
};
|
||||
let Some(kind) = penpot_action_kind(item.get("actionType").and_then(Value::as_str)) else {
|
||||
warnings.push(format!(
|
||||
"Penpot interaction with action type '{}' has no FigCraft equivalent and was skipped",
|
||||
item.get("actionType").and_then(Value::as_str).unwrap_or("unknown")
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let Some((action, destination)) = penpot_action(item, &kind) else {
|
||||
warnings.push(format!(
|
||||
"Penpot interaction with action type '{}' has no FigCraft equivalent and was skipped",
|
||||
item.get("actionType").and_then(Value::as_str).unwrap_or("unknown")
|
||||
));
|
||||
continue;
|
||||
};
|
||||
if destination.is_none() && matches!(kind, PenpotActionKind::Navigate | PenpotActionKind::OpenOverlay | PenpotActionKind::ToggleOverlay) {
|
||||
warnings.push("A Penpot interaction names no destination; it was skipped".into());
|
||||
continue;
|
||||
}
|
||||
let (transition, duration_ms, easing) = penpot_transition(item.get("animation"), is_overlay(&kind));
|
||||
out.push(PenpotInteraction { trigger, action, destination, transition, duration_ms, easing });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Keep a shape's interaction source in its metadata, so the destinations can be resolved
|
||||
/// once every page is in the document.
|
||||
pub(super) fn keep_interaction_source(node: &mut figcraft_doc::Node, source: &Value, warnings: &mut Vec<String>) {
|
||||
let Some(items) = source.get("interactions").and_then(Value::as_array) else { return };
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
match serde_json::to_string(items) {
|
||||
Ok(encoded) if encoded.len() <= MAX_INTERACTION_SOURCE => {
|
||||
node.plugin_data.insert("penpot.interactions".into(), encoded);
|
||||
}
|
||||
_ => warn(warnings, "A Penpot layer's interaction source is too large to keep; its prototype links were skipped".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The interactions a shape's kept source holds.
|
||||
fn stored_interactions(node: &figcraft_doc::Node) -> Option<Vec<Value>> {
|
||||
let raw = node.plugin_data.get("penpot.interactions")?;
|
||||
serde_json::from_str::<Value>(raw).ok()?.as_array().cloned()
|
||||
}
|
||||
|
||||
/// Rewrite the placeholder destinations of the imported interactions once every page is in
|
||||
/// the document.
|
||||
///
|
||||
/// The importer keeps each shape's Penpot interactions in its metadata. Once the document
|
||||
/// is complete the Penpot ids are replaced by the FigCraft node ids they name, so the
|
||||
/// prototype player can follow them. A destination that names no layer is dropped, and an
|
||||
/// interaction left with no action is removed.
|
||||
pub(super) fn resolve_destinations(doc: &mut Document, by_penpot_id: &BTreeMap<String, NodeId>, warnings: &mut Vec<String>) {
|
||||
let mut mapped = 0usize;
|
||||
let mut missing = 0usize;
|
||||
let mut updates: Vec<(NodeId, Vec<Interaction>)> = Vec::new();
|
||||
for node in doc.nodes() {
|
||||
let Some(items) = stored_interactions(node) else { continue };
|
||||
let mut out = Vec::new();
|
||||
let mut notes = Vec::new();
|
||||
let interactions = shape_interactions(&items, &mut notes);
|
||||
for note in notes {
|
||||
warn(warnings, note);
|
||||
}
|
||||
for interaction in interactions {
|
||||
let Some(destination) = &interaction.destination else {
|
||||
// An action with no destination (a link, a back) needs no resolution.
|
||||
out.push(Interaction {
|
||||
trigger: interaction.trigger,
|
||||
actions: vec![interaction.action],
|
||||
transition: interaction.transition,
|
||||
duration_ms: interaction.duration_ms,
|
||||
easing: interaction.easing,
|
||||
delay_ms: 0,
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let Some(&id) = by_penpot_id.get(destination) else {
|
||||
missing += 1;
|
||||
continue;
|
||||
};
|
||||
out.push(Interaction {
|
||||
trigger: interaction.trigger,
|
||||
actions: vec![replace_dest(interaction.action, id)],
|
||||
transition: interaction.transition,
|
||||
duration_ms: interaction.duration_ms,
|
||||
easing: interaction.easing,
|
||||
delay_ms: 0,
|
||||
});
|
||||
mapped += 1;
|
||||
}
|
||||
if !out.is_empty() {
|
||||
updates.push((node.id, out));
|
||||
}
|
||||
}
|
||||
for (id, interactions) in updates {
|
||||
if let Some(node) = doc.node_mut(id) {
|
||||
node.interactions = interactions;
|
||||
}
|
||||
}
|
||||
if mapped > 0 {
|
||||
warnings.push(format!("Imported {mapped} Penpot prototype connection(s) as working FigCraft interactions"));
|
||||
}
|
||||
if missing > 0 {
|
||||
warnings.push(format!("{missing} Penpot prototype destination(s) named no layer in the file; those connections were skipped"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The same action with another destination.
|
||||
fn replace_dest(action: Action, dest: NodeId) -> Action {
|
||||
match action {
|
||||
Action::Navigate { preserve_scroll, .. } => Action::Navigate { dest, preserve_scroll },
|
||||
Action::ScrollTo { offset, .. } => Action::ScrollTo { dest, offset },
|
||||
Action::OpenOverlay { position, close_on_outside, background, offset, anchor, .. } => {
|
||||
Action::OpenOverlay { dest, position, close_on_outside, background, offset, anchor }
|
||||
}
|
||||
Action::SwapOverlay { .. } => Action::SwapOverlay { dest },
|
||||
Action::ChangeTo { .. } => Action::ChangeTo { dest },
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a page's Penpot `flows` and turn them into FigCraft flows.
|
||||
///
|
||||
/// A Penpot flow names the frame the prototype starts at. FigCraft's flow needs a node id,
|
||||
/// so the Penpot frame id is resolved through `by_penpot_id`.
|
||||
pub(super) fn page_flows(doc: &Document, flows: &Value, by_penpot_id: &BTreeMap<String, NodeId>, warnings: &mut Vec<String>) -> Vec<Flow> {
|
||||
let mut out = Vec::new();
|
||||
let Some(flows) = flows.as_object() else { return out };
|
||||
for flow in flows.values().take(MAX_FLOWS) {
|
||||
let Some(start) = flow.get("startingFrame").and_then(Value::as_str) else { continue };
|
||||
let Some(&frame) = by_penpot_id.get(start) else {
|
||||
// A flow whose starting frame is not in the file cannot start anything.
|
||||
warn(warnings, "A Penpot flow names a starting frame that is not in the file; it was skipped".into());
|
||||
continue;
|
||||
};
|
||||
// The player shows the top-level frame a starting frame sits in.
|
||||
let start = doc.top_level_frame(frame).unwrap_or(frame);
|
||||
let name = flow.get("name").and_then(Value::as_str).map(str::to_owned).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Flow".to_owned());
|
||||
out.push(Flow { name: name.chars().take(256).collect(), start, description: String::new() });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_flow_warnings_are_deduplicated_and_bounded_across_pages() {
|
||||
let doc = Document::new("Missing flows");
|
||||
let flows = serde_json::json!({"a":{"startingFrame":"missing"},"b":{"startingFrame":"missing"}});
|
||||
let mut warnings = Vec::new();
|
||||
for _ in 0..110 {
|
||||
assert!(page_flows(&doc, &flows, &BTreeMap::new(), &mut warnings).is_empty());
|
||||
}
|
||||
assert_eq!(warnings.len(), 1);
|
||||
warnings = (0..100).map(|i| format!("Prior warning {i}")).collect();
|
||||
page_flows(&doc, &flows, &BTreeMap::new(), &mut warnings);
|
||||
assert_eq!(warnings.len(), 100);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,17 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData {
|
||||
_ => cx.warn("invalid internal .fig text path data was ignored"),
|
||||
}
|
||||
}
|
||||
// The line breaks the .fig file saved, for the box width the layer imports with.
|
||||
let width = v.get("size").and_then(super::props::vec2).map(|(w, _)| w.clamp(0.0, super::props::MAX_SIZE));
|
||||
if cx.fig_sources.is_some()
|
||||
&& td.path.is_none()
|
||||
&& td.auto_resize != TextAutoResize::WidthAndHeight
|
||||
&& let (Some(starts), Some(width)) = (v.get("_figLineStarts").and_then(Value::as_array), width)
|
||||
{
|
||||
let n = td.char_count();
|
||||
let starts = starts.iter().filter_map(Value::as_u64).filter_map(|s| usize::try_from(s).ok()).filter(|s| *s < n).collect();
|
||||
td.save_breaks(starts, width);
|
||||
}
|
||||
td
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ pub fn sketch_preview_png<R: Read + Seek>(archive: R) -> Option<Vec<u8>> {
|
||||
/// Decode a picture (PNG, JPEG, WebP, GIF, BMP or TIFF) no larger than [`MAX_THUMBNAIL_SIDE`] on
|
||||
/// either side, scaled down to at most `fit` pixels on its longer side (never up).
|
||||
pub fn decode_thumbnail(bytes: &[u8], fit: u32) -> Option<Thumbnail> {
|
||||
if crate::jpeg::finer_than_first(bytes) {
|
||||
return None;
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_THUMBNAIL_SIDE);
|
||||
|
||||
@@ -55,9 +55,14 @@ impl Headless {
|
||||
}
|
||||
|
||||
fn execute(&mut self, cmd: &str, params: &Value) -> Result<Value, String> {
|
||||
self.execute_as(None, cmd, params)
|
||||
}
|
||||
|
||||
/// A command for `agent` (an AI agent's name, `engine.execute {agent}`) or for the user.
|
||||
fn execute_as(&mut self, agent: Option<&str>, cmd: &str, params: &Value) -> Result<Value, String> {
|
||||
// What happened in a live session since the last call (latest presence only).
|
||||
self.session.live_poll();
|
||||
let r = self.session.execute(cmd, params).map_err(|e| e.to_string());
|
||||
let r = self.session.execute_as(agent, cmd, params).map_err(|e| e.to_string());
|
||||
self.session.live_sync();
|
||||
// No UI: dialogs and panels requested by commands are dropped.
|
||||
self.session.ui_requests.clear();
|
||||
@@ -234,8 +239,8 @@ impl Headless {
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Headless {
|
||||
fn call(&mut self, method: &str, p: Value) -> Result<Value, String> {
|
||||
impl Headless {
|
||||
fn dispatch(&mut self, method: &str, p: Value) -> Result<Value, String> {
|
||||
match method {
|
||||
"engine.execute" => {
|
||||
let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?.to_string();
|
||||
@@ -243,7 +248,7 @@ impl Backend for Headless {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(v) => v.clone(),
|
||||
};
|
||||
self.execute(&cmd, ¶ms)
|
||||
self.execute_as(p.get("agent").and_then(Value::as_str), &cmd, ¶ms)
|
||||
}
|
||||
"engine.commands" => Ok(Value::Array(
|
||||
figcraft_engine::all_commands().iter().map(|c| json!({"id": c.id, "label": c.label, "params": c.params, "menu": c.menu(), "shortcut": c.shortcut(), "undoable": c.history})).collect(),
|
||||
@@ -296,6 +301,18 @@ impl Backend for Headless {
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Headless {
|
||||
/// With `document` (an `id` from `doc.list`, or an open file's path) the call acts on that
|
||||
/// file, and the file in front stays in front.
|
||||
fn call(&mut self, method: &str, p: Value) -> Result<Value, String> {
|
||||
let Some(target) = p.get("document").filter(|d| !d.is_null()) else { return self.dispatch(method, p) };
|
||||
let forward = self.session.bring_forward(target).map_err(|e| e.to_string())?;
|
||||
let r = self.dispatch(method, p);
|
||||
self.session.put_back(forward);
|
||||
r
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
|
||||
@@ -86,7 +86,7 @@ pub const PROMPTS: &[PromptDef] = &[
|
||||
],
|
||||
template: r#"Fix the open comment threads in this FigCraft document.
|
||||
|
||||
1. Read them: run_command {"command": "comment.list", "params": {"page": "{page}", "filter": "open"}}. Each thread has an id, text, pageName, frameName, anchor (a node id; null if the comment is only pinned to a page point), x/y, author, replies and link.
|
||||
1. Read them: run_command {"command": "comment.list", "params": {"page": "{page}", "filter": "open"}}. Each thread has an id, text, pageName, frameName, anchor (a node id; null if the comment is only pinned to a page point), x/y, author, mentions (the names a message @mentions: {name, known, you}), replies and link.
|
||||
2. For each thread, in list order:
|
||||
a. Inspect the anchor with get_node {"id": <anchor>, "depth": 2} (or get_node on frame if anchor is null), and look at the frame: screenshot {"ids": [<frame>]}.
|
||||
b. Make the smallest change that answers the comment with set_properties, create_node or run_command. Put the edits for one thread in one batch so the user can undo them in one step. Do not edit layers the comment does not mention.
|
||||
@@ -104,7 +104,7 @@ pub const PROMPTS: &[PromptDef] = &[
|
||||
template: r#"Watch for new feedback in this FigCraft document for {minutes} minutes.
|
||||
|
||||
1. Start the watcher: wait_for_changes {"after": 0, "timeoutMs": 0, "topics": ["comments"]}. Keep its `cursor` and note `reset`; if the returned cursor is 0, remember that the initial cursor was zero.
|
||||
2. Handle what is already open: run_command {"command": "comment.list", "params": {"page": "all", "filter": "open"}}. For each thread: inspect the anchor and frame, make the smallest change (one batch per thread), comment.reply with what changed (author "AI"), verify, then comment.resolve. Leave ambiguous threads open with a question.
|
||||
2. Handle what is already open: run_command {"command": "comment.list", "params": {"page": "all", "filter": "open"}}. For each thread: inspect the anchor and frame, make the smallest change (one batch per thread), comment.reply with what changed (it is signed with your own agent name), verify, then comment.resolve. Leave ambiguous threads open with a question.
|
||||
3. Then loop until the budget ends: wait_for_changes {"after": <cursor>, "timeoutMs": 20000, "topics": ["comments"]}. Set cursor to the result's cursor. For each comment.added or comment.replied event, read the thread with comment.list. If its latest message is your own reply, skip it (comment.reply emits comment.replied too); otherwise fix it the same way. If reset is true, dropped is greater than 0, or the cursor was 0 before this poll and the returned cursor is greater than 0, re-read every open thread and continue with the returned cursor.
|
||||
4. Stop after {minutes} minutes; summarize the threads you fixed and the ones still open (and why)."#,
|
||||
defaults: &[("minutes", "5")],
|
||||
|
||||
@@ -52,6 +52,46 @@ pub struct Server {
|
||||
/// The automation roots every request's file access is confined to (`--automation-read-root`,
|
||||
/// `--automation-write-root`), if any.
|
||||
roots: Option<Arc<Roots>>,
|
||||
/// The client's name from `initialize` (`clientInfo.title`, else `.name`; empty until then):
|
||||
/// every command runs as this AI agent, so its comments are signed with it, not the user's name.
|
||||
agent: String,
|
||||
}
|
||||
|
||||
/// The backend as the MCP client's AI agent: `engine.execute` carries its name (`agent`).
|
||||
struct AsAgent<'a> {
|
||||
inner: &'a mut dyn Backend,
|
||||
agent: &'a str,
|
||||
}
|
||||
|
||||
impl Backend for AsAgent<'_> {
|
||||
fn call(&mut self, method: &str, mut params: Value) -> Result<Value, String> {
|
||||
if method == "engine.execute"
|
||||
&& let Some(p) = params.as_object_mut()
|
||||
{
|
||||
p.insert("agent".into(), json!(self.agent));
|
||||
}
|
||||
self.inner.call(method, params)
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
self.inner.has_ui()
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
self.inner.describe()
|
||||
}
|
||||
|
||||
fn render_png(&mut self, ids: &[u64], scale: f64) -> Result<Vec<u8>, String> {
|
||||
self.inner.render_png(ids, scale)
|
||||
}
|
||||
|
||||
fn screenshot_png(&mut self) -> Result<Vec<u8>, String> {
|
||||
self.inner.screenshot_png()
|
||||
}
|
||||
|
||||
fn window_png(&mut self, window: u64) -> Result<Vec<u8>, String> {
|
||||
self.inner.window_png(window)
|
||||
}
|
||||
}
|
||||
|
||||
fn response(id: Value, result: Value) -> Value {
|
||||
@@ -62,13 +102,20 @@ fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
|
||||
}
|
||||
|
||||
/// The name an `initialize` request gives its client (`clientInfo.title`, else `.name`; empty
|
||||
/// without one). The engine keeps 64 characters of it; this only bounds what each call carries.
|
||||
fn client_name(params: &Value) -> String {
|
||||
let name = |k: &str| params.get("clientInfo").and_then(|i| i.get(k)).and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty());
|
||||
name("title").or_else(|| name("name")).unwrap_or_default().chars().take(256).collect()
|
||||
}
|
||||
|
||||
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload.downcast_ref::<&str>().map(ToString::to_string).or_else(|| payload.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||||
Self { backend, initialized: false, roots: None }
|
||||
Self { backend, initialized: false, roots: None, agent: String::new() }
|
||||
}
|
||||
|
||||
/// Confine the files every request reads and writes to `roots` (see
|
||||
@@ -192,9 +239,15 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend, acting as the client's agent.
|
||||
fn as_agent(&mut self) -> AsAgent<'_> {
|
||||
AsAgent { inner: self.backend.as_mut(), agent: &self.agent }
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => {
|
||||
self.agent = client_name(params);
|
||||
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
|
||||
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
|
||||
Ok(json!({
|
||||
@@ -219,13 +272,13 @@ impl Server {
|
||||
if !crate::tools::TOOL_NAMES.contains(&name) {
|
||||
return Err((INVALID_PARAMS, format!("unknown tool `{name}`")));
|
||||
}
|
||||
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
|
||||
Ok(call_tool(&mut self.as_agent(), name, &args).to_value())
|
||||
}
|
||||
"resources/list" => Ok(resources::list()),
|
||||
"resources/templates/list" => Ok(resources::templates()),
|
||||
"resources/read" => {
|
||||
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri` (a string)".to_string()))?;
|
||||
let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e {
|
||||
let v = resources::read(&mut self.as_agent(), uri).map_err(|e| match e {
|
||||
resources::ReadError::NotFound(uri) => (RESOURCE_NOT_FOUND, format!("resource not found: {uri}")),
|
||||
resources::ReadError::Backend(why) => (INTERNAL_ERROR, why),
|
||||
})?;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user