Compare commits

..
Author SHA1 Message Date
bflatastic b67b71732f Merge fix/680 into batch 12 (#680's rebased head; batch already has its content) 2026-10-17 17:17:55 +07:00
bflatastic cbbe735db0 Merge #680's rebased head (the batch carries the reviewed resolution) 2026-10-17 17:17:54 +07:00
bflatastic 99d097636e Merge fix/all-f5 into batch 12 2026-10-17 16:59:57 +07:00
BFlat e17f58ba85 Merge pull request #733 from storytold/community/batch-11
Community batch 11: editor fixes and the Actions palette (9 PRs)
2026-10-17 16:54:01 +07:00
bflatastic e912a2efe8 #655: fill.set / stroke.set read index with params::opt_index (#675) 2026-10-17 16:38:35 +07:00
bflatastic 1ede34074e Merge fix/633 2026-10-17 16:37:44 +07:00
bflatastic e86cae4fe0 Merge fix/641 2026-10-17 16:37:43 +07:00
bflatastic 59d3d7e0f8 Merge fix/616 2026-10-17 16:37:41 +07:00
bflatastic 2945b3042c Merge fix/599 2026-10-17 16:37:40 +07:00
bflatastic 98fcf2472b Merge fix/699 2026-10-17 16:37:38 +07:00
bflatastic 6f2fb6cce8 Merge fix/712 2026-10-17 16:37:37 +07:00
bflatastic 74c2088292 Merge fix/705 2026-10-17 16:37:36 +07:00
bflatastic 432a3e4016 Merge fix/687 2026-10-17 16:37:34 +07:00
bflatastic c7cba0d626 Merge fix/670 2026-10-17 16:37:33 +07:00
bflatastic b96e007a2c Merge fix/675 2026-10-17 16:37:32 +07:00
bflatastic a2e9b5ade2 Merge fix/673 2026-10-17 16:37:28 +07:00
bflatastic 6f8c2a657e Merge fix/680 2026-10-17 16:37:27 +07:00
bflatastic e2d9e7b493 Merge fix/625 2026-10-17 16:37:25 +07:00
bflatastic 3b66750a5a Merge fix/655 2026-10-17 16:37:23 +07:00
bflatastic 0232031bbe Merge main into #633 2026-10-17 16:37:09 +07:00
bflatastic 734f8efedd Merge main into #641 2026-10-17 16:37:02 +07:00
bflatastic 8a66e7bdc5 Merge main into #616 2026-10-17 16:36:58 +07:00
bflatastic fec54d9361 #599: leave the progress docs to the maintainer 2026-10-17 16:36:54 +07:00
bflatastic f5112a3d52 Merge main into #599 2026-10-17 16:36:40 +07:00
NetterSilverandClaude Haiku 5.5 234339a786 Refuse pages in edit.copy and edit.cut
Found while testing crates/engine/src/cmd/edit.rs:499 (copy_to_clipboard, called from copy at :536 and cut at :542)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 11:36:32 +02:00
bflatastic 7f7921260c #699: menu input dialog and language picker use the shared widgets; File > Rename edits the file name in place 2026-10-17 16:36:24 +07:00
bflatastic b6d98e28dc #699: keep en.toml's end of file as on main 2026-10-17 16:36:24 +07:00
bflatastic fba23e9ddb Merge main into #699 2026-10-17 16:36:20 +07:00
bflatastic 532b0f7321 Batch 11: Paste here puts copied design layers at the point 2026-10-17 16:18:57 +07:00
bflatastic 02b231db6d #712: read the invite link from the URL fragment (#join=), never sent to the web host; ignore overlong links 2026-10-17 16:18:50 +07:00
bflatastic 313386c1ac Merge main into batch 11
# Conflicts:
#	apps/figcraft/src/services.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/menus.rs
2026-10-17 16:15:12 +07:00
bflatastic e8260b1b7a Merge main into #712 2026-10-17 16:12:05 +07:00
bflatastic 3c73045088 #705: cap what a client buffers from a server at 1 GiB; warn the operator about files over 64 MiB when open and over what the app opens 2026-10-17 16:11:59 +07:00
BFlat 753d073f1b Merge pull request #728 from storytold/community/batch-10
Community batch 10: .fig import and file handling (12 PRs)
2026-10-17 16:09:50 +07:00
bflatastic 7da7ed226e Merge main into #705 2026-10-17 16:08:08 +07:00
bflatastic 7d67d13b62 Merge main into #687 2026-10-17 16:06:44 +07:00
bflatastic 93fc6fd082 Merge main into #670 2026-10-17 16:06:30 +07:00
bflatastic 1503f0743c #675: cap the offending value echoed in id/ids errors 2026-10-17 16:06:26 +07:00
bflatastic 7bf814f6b4 Merge main into #675 2026-10-17 16:03:45 +07:00
bflatastic 6f0e14a271 #673: read the failed step with get() instead of indexing 2026-10-17 16:03:41 +07:00
bflatastic b3a2891c2e Merge main into #673 2026-10-17 15:58:31 +07:00
bflatastic 5ea5590673 #680: move the page clipboard test to its own module, check the error id 2026-10-17 15:58:24 +07:00
bflatastic 01ce23ca76 #680: page refusal names the calling command (edit.copy / edit.cut) 2026-10-17 15:58:23 +07:00
bflatastic abfc0071f1 Merge main into #680 2026-10-17 15:55:55 +07:00
bflatastic 7de7dbc991 Merge main into #625 2026-10-17 15:53:28 +07:00
bflatastic 84dc9c4f22 Merge fix/524 into batch 10 (#524's rebased head; batch already has its content) 2026-10-17 15:52:39 +07:00
bflatastic f6bfd6953f Merge #524's rebased head (the batch carries the reviewed resolution) 2026-10-17 15:52:18 +07:00
bflatastic cd8a88733f Merge main into #655 2026-10-17 15:48:00 +07:00
bflatastic a6b35f32e0 Merge fix/all-f3 (9 editor PRs) 2026-10-17 15:47:01 +07:00
Andrii VolkovandClaude Opus 5.5 58d891f63d fix(format): one kiwi value per compressed canvas byte
One value per two bytes of the compressed canvas was too tight for a
public design system: a 39.5 MB canvas holding 29.7 million values got
a budget of 19.7 million (reported in #701). Real files hold 0.37 to 0.75
values per compressed byte; the budget is now one per byte, at least the
old 20 million and at most 128 million. A small file still gets main's
20 million, however far it unpacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-17 11:29:41 +03:00
Andrii VolkovandClaude Opus 5.5 62ade9b9ad fix(format): reopen large documents once saved
`document.json` was read under the 512 MB cap for any archive member, so a
large imported document saved but didn't open again: 900 000 layers save
as a 1.48 GB document.json, packed into 244 MB. It may now unpack to 16
times its packed size (counted up to the archive's own size), within
512 MB and 4 GB; images and other members keep 512 MB. A small archive
still can't unpack into gigabytes.

That saved file reopens in 13 s with a 5.9 GB peak.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-17 11:29:41 +03:00
Andrii VolkovandClaude Opus 5.5 330f80fcf5 fix(format): open large .fig files
A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are
expanded) didn't open: "message: too many values". Lifting that cap alone
made it import only its first 200 000 layers, and lifting that one took
41 GB of memory.

The global caps stay as they are. The .fig import gets budgets from the
size of its compressed canvas instead (counted up to the file's own size),
not from the message it unpacks to, since chunks inflate up to 512 MB:
- the kiwi value budget is one value per 2 bytes of the canvas, at least
  the old 20 million (kiwi is only read for .fig files);
- the layer cap is one layer per 32 bytes of the canvas, at least the REST
  import's 200 000 and at most 2 000 000. A small or crafted file can't
  expand its instances into millions of layers, however far it unpacks; the
  REST JSON import keeps 200 000.

Memory:
- The mapper no longer passes the document through `json!`, which serialises
  its arguments: the whole tree was deep-copied twice.
- Vector networks are built once per distinct network and handed to the REST
  importer beside the JSON (`vectorNetworkRef`), not written into it as JSON
  objects for every instance that shows them.
- Decoded kiwi objects are trimmed, and each import stage's input is dropped
  as soon as the next stage is built.

On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-17 11:29:41 +03:00
bflatastic da5fe1359b Merge fix/524 into batch 10 2026-10-17 15:26:46 +07:00
bflatastic 546c09b3ba Merge fix/all-f2 into batch 10 2026-10-17 15:26:45 +07:00
bflatastic 2fe28bf0a0 Merge fix/707 2026-10-17 15:20:40 +07:00
bflatastic d7da6555f1 Merge fix/654 2026-10-17 15:20:38 +07:00
bflatastic c6ad0bd55d Merge fix/657 2026-10-17 15:20:37 +07:00
bflatastic 2446ed8ddb Merge fix/713 2026-10-17 15:20:35 +07:00
bflatastic 8d97b16e90 Merge fix/691 2026-10-17 15:20:34 +07:00
bflatastic e4d06b2f63 Merge fix/717 2026-10-17 15:20:33 +07:00
bflatastic 6b88e2ea64 Merge fix/718 2026-10-17 15:20:31 +07:00
bflatastic 62308e247e Merge fix/666 2026-10-17 15:20:30 +07:00
bflatastic 3a8e6e2860 #707: search every command, ranked; a highlight for Enter; recents from the palette only
- A search covers every command again (the engine's and the UI's own), grouped by
  menu where one lists it and under Other otherwise, best matches first (prefix,
  then shortest), at most 60. Without a query the menus' items show as before.
- Up and Down move a visible highlight (the pointer moves it too); Enter runs
  the highlighted row, not an invisible first one.
- Only runs from the palette are kept as Recents; menus, shortcuts and context
  menus no longer write the preferences. Recents match the translated label.
- The Plugins menu's items (run last, manage, import) show with the plugins.
- The search keeps its hint, so every catalog keeps its translation.
- The panel sits above the toolbar pill's own rect (toolbar::pill_rect); one
  set_palette/toggle_palette opens it from every entry point.
- Clippy: needless borrows in the toolbar.
2026-10-17 15:20:08 +07:00
bflatastic 45b154e40b #707: drop the unrelated .gitignore line and the parity doc edit 2026-10-17 15:20:08 +07:00
bflatastic 5fcf82abfc Merge #524's latest commit (same budget as the fix above)
# Conflicts:
#	crates/format/src/fig/kiwi.rs
#	crates/format/src/fig/tests.rs
2026-10-17 15:19:59 +07:00
bflatastic 58923a22e8 Merge fix/647 2026-10-17 15:11:24 +07:00
bflatastic 426217327b #647: text copied in another app after a design paste pastes as text again 2026-10-17 15:11:23 +07:00
bflatastic 946004f0d6 Merge main into #707 2026-10-17 15:08:01 +07:00
bflatastic bbfc408ab8 #654: leave the roadmap to the maintainers' docs PR 2026-10-17 15:06:47 +07:00
bflatastic 00e6fc1d59 Merge main into #654
# Conflicts:
#	ROADMAP.md
2026-10-17 15:06:46 +07:00
bflatastic 58de7d4dab #657: a drop that would leave no segment only moves the point
Dropping one end of a 2-point line on the other left a 1-point vector.
2026-10-17 15:06:37 +07:00
BFlat c1347d28c5 Merge pull request #727 from storytold/community/batch-9
Community batch 9: the .fig import series (11 PRs by @Skyfuller1303)
2026-10-17 15:06:06 +07:00
bflatastic 22a821c742 #657: leave the roadmap to the maintainers' docs PR 2026-10-17 15:03:02 +07:00
bflatastic 45e1c38d60 Merge main into #657
# Conflicts:
#	ROADMAP.md
2026-10-17 15:03:02 +07:00
bflatastic 5c0b4e3325 Merge main into #713 2026-10-17 15:00:51 +07:00
bflatastic ef684c5df4 #691: leave the parity docs to the maintainers' docs PR 2026-10-17 14:58:06 +07:00
bflatastic e21becfa85 Merge main into #691 2026-10-17 14:57:55 +07:00
bflatastic c1e86b7f02 Merge main and the #693 fixes into #717 2026-10-17 14:55:43 +07:00
bflatastic 25e9be3936 #693: clipboard reads on Linux and the BSDs give up after 2 s
A Wayland clipboard read waits on the app that copied with no timeout of its
own. The read now runs on a helper thread with its own clipboard handle, so a
stuck app can't hang the window.
2026-10-17 14:49:48 +07:00
bflatastic 22ac265435 Merge main into #693 2026-10-17 14:46:14 +07:00
bflatastic 9e16cddda8 Merge main into #718 2026-10-17 14:43:10 +07:00
bflatastic 6020d94d55 Merge fix/647 2026-10-17 14:41:03 +07:00
bflatastic a3e8c86331 Merge fix/620 2026-10-17 14:40:55 +07:00
bflatastic 9f01b21a54 Merge fix/619 2026-10-17 14:40:49 +07:00
bflatastic 5633453f5b Merge fix/524 2026-10-17 14:40:43 +07:00
bflatastic d8df6f6895 Merge main into #666 2026-10-17 14:36:15 +07:00
bflatastic 460985672c Merge fix/656 2026-10-17 14:36:06 +07:00
bflatastic aa90259f1c Merge fix/617 2026-10-17 14:36:06 +07:00
bflatastic b5cba7c885 Merge fix/711 2026-10-17 14:35:20 +07:00
bflatastic 4c1f2b978b Merge fix/697 2026-10-17 14:35:17 +07:00
bflatastic 1328092a7e Merge fix/612 2026-10-17 14:35:15 +07:00
bflatastic 45c68177d0 Merge fix/695 2026-10-17 14:35:14 +07:00
bflatastic 090266ad40 Merge fix/610 2026-10-17 14:35:12 +07:00
bflatastic d07b55104d #647: paste takes the design path only when the HTML holds readable design data; neutral wording in every catalog 2026-10-17 14:34:58 +07:00
bflatastic 3353d2d5d3 #647: edit.pasteDesignLayers, named neutrally; design data capped at 256 MB before decoding, data accepts wrapped or unpadded base64 2026-10-17 14:34:57 +07:00
bflatastic 946006f100 #647: leave progress docs to the maintainer's docs PR 2026-10-17 14:34:56 +07:00
bflatastic f7fb03f25d Merge #632 2026-10-17 14:34:20 +07:00
bflatastic 9415ef6579 Merge #631 2026-10-17 14:34:20 +07:00
bflatastic 2e32a1659c Merge the .fig import stack (#626-#639) fixed for landing 2026-10-17 14:34:19 +07:00
bflatastic edc2845324 Merge #632's reviewed head (its changes are folded in above) 2026-10-17 14:34:17 +07:00
bflatastic 8f9be76fac Merge #631's reviewed head (its changes are folded in above) 2026-10-17 14:34:15 +07:00
bflatastic 92714667f6 Merge main into #647 2026-10-17 14:27:33 +07:00
bflatastic 02c4d7d825 #620: test the summary and report of a file opened from data 2026-10-17 14:27:08 +07:00
bflatastic fd98f572d2 Merge fix/635 2026-10-17 14:26:04 +07:00
bflatastic 4a572572db #635: no negative zero from the flipped tint 2026-10-17 14:25:56 +07:00
bflatastic 01c3f0bca6 #620: the import report and its notes in every catalog; #617's notes added, stale and unshipped ones dropped 2026-10-17 14:21:29 +07:00
bflatastic d40728d0b8 #620: import notes come from the importers' own warn() texts, translated once when the report opens 2026-10-17 14:21:29 +07:00
bflatastic f757044d8c fix/all: rustfmt the merged module list 2026-10-17 14:08:05 +07:00
bflatastic d0539b83e9 #631: follow the decoder rename in #639's connector check
#639 lands first and reads networks with decode_styled; once #631 drops the count it calls
network::decode. Needed whenever #631 is rebased onto a main that has #639.
2026-10-17 14:06:43 +07:00
bflatastic 7cb0b68d73 Merge fix/635 2026-10-17 14:05:09 +07:00
bflatastic 3881cce291 Merge fix/632 2026-10-17 14:05:08 +07:00
bflatastic 3f36adfa90 Merge fix/638 2026-10-17 14:05:05 +07:00
bflatastic add7806197 Merge fix/639 2026-10-17 14:05:02 +07:00
bflatastic de28945484 Merge fix/636 2026-10-17 14:04:59 +07:00
bflatastic 313a38c0a8 Merge fix/630 2026-10-17 14:04:55 +07:00
bflatastic 1e85d54952 #635: cap the .fig import warnings like the REST ones
Image filter warnings name fields of the file, so a crafted schema could add any number of
distinct warnings (each checked against all earlier ones). The mapper keeps at most as many as
the REST importer does.
2026-10-17 14:04:00 +07:00
BFlat 9e28e11954 M3: number variables in the text content picker (#629) (#725)
The Typography section's variable picker also lists number variables for a text layer's content (shown as the number), as the engine already accepted; other text fields keep their own types (#629).
2026-10-17 14:03:59 +07:00
bflatastic 48d74df226 #635: one tint sign for REST file JSON and .fig
Both carry the same image filter value, which the public docs describe as green when lower and
magenta when higher; a positive native tint adds green. Both readers flip it now (the .fig path
did, the REST path didn't). The .fig test message no longer names the product.
2026-10-17 14:03:57 +07:00
bflatastic 601ff4218b Merge #651 into #620: translate the vector style import notes 2026-10-17 14:02:15 +07:00
bflatastic 4771eb45fa Merge #617 into #620: its import notes join the report 2026-10-17 14:02:13 +07:00
bflatastic baa58b21fa #620: file.open with base64 data returns the summary too 2026-10-17 13:59:03 +07:00
bflatastic 24eec5d6bb Merge main into #620 2026-10-17 13:59:02 +07:00
bflatastic 2ac4ce3a73 #635: rebase onto #628: the filter tests add their paint fields themselves
The test schema extended the image scale-mode field that only #634 adds; it now adds the
filter containers to the paint message itself, so it works with and without #634.
2026-10-17 13:58:41 +07:00
Skyfuller1303 d84aa7f1e1 M1: import image adjustments from .fig files
Image paints that carry a current filter container now import their exposure, contrast, vibrance (as saturation), temperature, tint, highlights and shadows as native image filters. Neutral values leave the paint untouched. The older color-adjust container alone, both containers together, filters that have no native equivalent (brightness, detail, vignette and unknown fields) and malformed or non-finite values are skipped and reported as import notes. A positive tint in .fig data moves toward magenta, so it is flipped once at the .fig boundary; public REST image filters keep their sign and are not reported as approximated. Imported filters use the approximate native renderer, which the import says once.
2026-10-17 13:58:04 +07:00
bflatastic ca94b5fe6f #632: leave the progress docs to the maintainer 2026-10-17 13:54:27 +07:00
bflatastic 3fadbf4ba9 #632: read the network with the decoder #631 left 2026-10-17 13:54:25 +07:00
bflatastic 6baeb53b20 #619: the Save as messages in every catalog 2026-10-17 13:51:01 +07:00
bflatastic a96222012b #619: leave progress docs to the maintainer's docs PR 2026-10-17 13:51:00 +07:00
Skyfuller1303 6233b90ba7 M1: import editable text on a path from .fig files
Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path.

The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged.
2026-10-17 13:49:58 +07:00
bflatastic c605bd5781 #631: drop the styled-segment count nobody reads 2026-10-17 13:49:46 +07:00
bflatastic 3134849b81 Merge main into #619 2026-10-17 13:49:02 +07:00
bflatastic 5fba7e5dba #524: one kiwi value per compressed canvas byte (up to 128M), lower value and document.json ceilings on 32-bit targets 2026-10-17 13:48:56 +07:00
bflatastic ec325c648c Merge main into #631
# Conflicts:
#	crates/format/src/fig/network.rs
2026-10-17 13:46:35 +07:00
bflatastic cca1058b4a Merge main into #524 2026-10-17 13:46:29 +07:00
bflatastic 99dc199bb8 #656: borrow embedded image bytes and copy only the ones a paint uses 2026-10-17 13:46:22 +07:00
bflatastic 22bdad22fe #656: leave progress docs to the maintainer's docs PR 2026-10-17 13:46:21 +07:00
Skyfuller1303 d9e79d604b M1: distinguish unresolved vector segment style metadata in .fig import
The vector network decoder now keeps each segment's style id. Import reports a segment style that names a row of the layer's style table as metadata that was not interpreted, and one that names no row as a reference to a missing style entry, each at most once per file. The earlier note claimed that stroke styles were left out, which overstated what is known about the metadata. Segment geometry, paint and width are unchanged, and the style ids are not kept in the native vector model.
2026-10-17 13:45:55 +07:00
bflatastic b31ef2273a Merge main into #656 2026-10-17 13:45:26 +07:00
bflatastic 7fe0770b9d #617: point joins equal to the layer's no longer count as point styles (SVG keeps a plain stroke) 2026-10-17 13:45:16 +07:00
bflatastic 6023d3547a #617: leave progress docs to the maintainer's docs PR 2026-10-17 13:45:15 +07:00
bflatastic 15533338e8 Merge main into #638 2026-10-17 13:44:45 +07:00
bflatastic ee3e3f0bd4 #637: skip ink only counts under an underline
The public docs don't give a default for skip ink; they say the value is unset when the text
isn't underlined, and the exporter always wrote it on. So the export keeps writing on for text
without an underline and the real value under one (that's what the canvas draws), and the import
leaves a value on text without an underline out of the decoration look, so plain text keeps the
default look and round-trips unchanged. The native default stays off, so existing documents
draw as before.
2026-10-17 13:44:34 +07:00
bflatastic 9de5b333ac Merge main into #617 2026-10-17 13:41:04 +07:00
bflatastic 9b4e45aa0b #711: move the vector.offset tests into their own module 2026-10-17 13:40:55 +07:00
bflatastic 0dac325868 Merge main into #637 2026-10-17 13:40:41 +07:00
bflatastic 0ef8966844 #639: drop the ignored test that needs a fixture outside the repo 2026-10-17 13:40:30 +07:00
bflatastic e4f2c7fc4c Merge main into #711 2026-10-17 13:38:42 +07:00
bflatastic f44d7ed598 Merge main into #639 2026-10-17 13:36:31 +07:00
bflatastic 102f553f2c Merge main into #697 2026-10-17 13:35:58 +07:00
BFlat 69806c9edd Plugins: refuse file and network access through path params and collab (#723)
Plugins can no longer reach the disk or network through path parameters (variable.export/codeConnect.export/component.playground writes, codeConnect.import/variable.import reads) or through collab.* and window.*; found and fixed by @lucasmartins-ai, split out of #696.
2026-10-17 13:35:00 +07:00
bflatastic c42207fdee Merge main into #636 2026-10-17 13:33:43 +07:00
bflatastic fbe953e9b0 Merge main into #612 2026-10-17 13:33:11 +07:00
bflatastic b6820d6710 Merge main into #630 2026-10-17 13:31:18 +07:00
bflatastic ab6cf4fd58 Merge main into #628 2026-10-17 13:27:46 +07:00
Andrii VolkovandClaude Opus 5.5 7dcd7fd05b fix(format): one kiwi value per compressed canvas byte
One value per two bytes of the compressed canvas was too tight for a
public design system: a 39.5 MB canvas holding 29.7 million values got
a budget of 19.7 million (reported in #701). Real files hold 0.37 to 0.75
values per compressed byte; the budget is now one per byte, at least the
old 20 million and at most 128 million. A small file still gets main's
20 million, however far it unpacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-17 09:27:42 +03:00
bflatastic 51e02a6b1a #695: leave progress docs to the maintainer's docs PR 2026-10-17 13:27:28 +07:00
bflatastic b4d58a362e #627: leave the progress docs to the maintainer 2026-10-17 13:27:13 +07:00
bflatastic 6e15e30790 #627: remove the dead network::decode shim 2026-10-17 13:27:12 +07:00
bflatastic b9d587e80e Merge main into #695 2026-10-17 13:27:11 +07:00
bflatastic fa4821bcea #627: settle bound paints once per open, not also in the native loader
DocState::new already settles every opened document (native files, branches and live joins
included); the .fig/REST path keeps its settlement in link_instances for file.import into an
open document.
2026-10-17 13:27:11 +07:00
bflatastic de06c1ed5f Merge main into #610 2026-10-17 13:25:09 +07:00
bflatastic b0e9e87129 Merge main into #627 2026-10-17 13:21:48 +07:00
bflatastic 52df73c44c Merge main into #626 2026-10-17 13:10:59 +07:00
BFlat 40c744da10 M1: convert frames to sections and sections to frames (#719) (#721)
Frames on the page or in a section convert to sections (object.convertToSection) and sections back to frames (object.convertToFrame), in place, one undo step each, from the canvas context menu; nested frames, instances, main components and sections holding sections are refused.
2026-10-17 13:03:49 +07:00
justinjohn0306andClaude Opus 5.5 df674d0d53 #710: Resources and Shift+I open the Assets panel from a collapsed sidebar or the version history
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 08:17:28 +05:30
RealtaNuaandClaude Opus 5.5 18a7a063fe fix(ui): paste chords read modifiers from modifier changes only (#716)
Every shell reports a modifier change before the key it goes with, so key
events needn't update the held modifiers too. The old test that pressed
Shift+Cmd+V and Alt+Cmd+V expecting "nothing" now covers plain V only:
those chords run their commands, covered by their own tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 09:00:56 +07:00
RealtaNuaandClaude Opus 5.5 9f52819fe9 fix(ui): Shift+Cmd/Ctrl+V pastes over the selection and Alt+Cmd/Ctrl+V pastes properties (#716)
egui-winit swallows every Cmd/Ctrl+V press, Shift or Alt held or not, and
sends a paste event only for text. Shift+Cmd+V and Alt+Cmd+V became a
plain paste (text) or did nothing (image). The keymap now decides what a
swallowed chord means, from the modifiers tracked in the raw events: its
paste event or its release runs paste over or paste properties instead.
In the browser the key press runs the command, and the paste event no
longer pastes its text as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 08:35:03 +07:00
Ismael Trejo ce472a251c fix(doc): thin strokes take a double-click a few pixels away (#646)
A 1 px Line or stroke-only path only counted as hit within 2 screen
pixels, so a double-click slightly off it cleared the selection and
never entered vector edit mode, and Cmd/Ctrl could not bend it.
Strokes with nothing filled now reach 5 screen pixels; filled shapes
keep the plain tolerance.
2026-10-16 16:40:27 -07:00
sushi-killer a9bcc48df7 feat(web): join a live session from ?join= in the page URL 2026-10-17 01:45:08 +03:00
NetterSilverandClaude Haiku 5.5 9e2596e975 Skip layers with children in vector.offset
Found while testing crates/engine/src/cmd/structure.rs:575

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:09:53 +00:00
sushi-killer 1b4db71957 fix(collab-net): keep the client's message cap in range on 32-bit targets 2026-10-17 00:37:55 +03:00
Ahmed5Emad 3b5c5d563b Palette follows the bar: toggle, dismiss, no invented icons
The Actions button toggles (re-click and Cmd+K close), reads on in
accent blue with a white icon while open, and a press anywhere outside
closes the panel in one click. The opening press is spared once (the
spare expires on press-free frames); presses on the button itself never
dismiss, so the release toggles cleanly shut instead of reopening.

Rows drop the per-family icons (labels + right-side hints only);
plugins and widgets keep their letter tiles. The panel anchors to the
canvas centre at the toolbar pill's exact width (toolbar::main_width),
at a fixed size, with inset hover pills and larger section titles.

Tests: one-click dismiss pinned (palette_tests).
2026-10-16 23:27:45 +03:00
sushi-killer b0f56ed169 fix(server): let message limits follow max_file_mb so files above 64 MiB publish and open 2026-10-16 23:19:32 +03:00
Ahmed5Emad dc97118c6f Actions palette rework: Figma-shaped panel over the toolbar
Tabs (All, Assets, Plugins & widgets), Recents kept in the preferences
(newest first, capped at 5), commands grouped by top-level menu, this
file's components (placed at the view centre) and styles (applied to
the selection), installed plugins and widgets with letter tiles. Rows
carry right-side shortcut/kind hints; Enter runs the first visible row.

The panel floats 8 px above the toolbar pill with its exact width and
centre (toolbar::main_width, canvas-centred), at a fixed size so tab
switches never resize it. Tabs keep their frames when idle so the
active pill never moves its neighbours.

Engine: Prefs.recent_actions (persisted, skipped when empty). UI:
invoke/menu/plugin picks record recents; gone entries drop out.
Locales: new keys in en + es, retired the old search hint everywhere.
Docs: ui-parity row describes the new surface (palette_tests).
2026-10-16 22:58:36 +03:00
Yarila 8c90ef962a M7: open inputs for parameterized menu actions 2026-10-16 23:36:23 +05:00
RealtaNuaandClaude Opus 5.5 d27f45e063 fix(format): write a list's start on every line; report it in get_node (#686)
The .fig export now writes a numbered list's start on each of its lines,
so a reader that takes it per line or per list counts the same. Bullets
no longer keep a start. The field is named start_offset (it holds the
offset: 1 shows "2."). get_node reports listStartOffsets. The parity doc
lists what's still not read from .fig lists.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 00:58:20 +07:00
NetterSilverandClaude Haiku 5.5 9aeb9fb752 Cap stroke width in the outliner so huge weights don't panic
Found while testing crates/engine/src/cmd/props.rs:361 (strokeWeight is only clamped with .max(0.0)); the panic is raised when the renderer draws the stroke (render crate / tiny-skia stroker)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 19:58:00 +02:00
RealtaNuaandClaude Opus 5.5 4adbd6cee9 fix(ui): a V held while the window was away doesn't paste on release (#663)
winit sends no press for a key held across a focus change (X11's
synthetic presses are dropped by egui-winit), so letting go of V after
coming back looked like a swallowed Cmd+V. After a focus gain, a V
release only pastes with Cmd still held.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 00:35:46 +07:00
RealtaNuaandClaude Opus 5.5 60fa813eb2 fix(format): numbered lists from .fig files keep their start number (#686)
Figma stores where a numbered list starts counting on each text line
(listStartOffset; a list shown as "2." has offset 1). The import dropped
it, so every list counted from 1.

A paragraph's list settings now carry a start number (written only when
set, so older files open and save unchanged). Numbering counts on from
it where a list starts; items that go on counting ignore it. The .fig
import reads it, the .fig export writes it on a list's first line, and
plain paragraphs drop it. Toggling a list off ignores differing starts.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 00:27:34 +07:00
RealtaNuaandClaude Opus 5.5 569a3b3669 fix(engine): a Shift-click on a selected widget doesn't also click it (#498)
With Shift let go before the button, the release took the layer out of
the selection and also ran the widget's click. The take-out now skips
the widget click, as a Shift-click always has.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:11:00 +07:00
RealtaNuaandClaude Opus 5.5 40955aae7c fix(ui): Cmd/Ctrl+V pastes an image copied in another app (#663)
egui-winit swallows the Cmd/Ctrl+V press and sends a paste event only
when the clipboard holds text, so with an image on the clipboard only
the key's release arrives and the image fallback, which waited for the
press, never ran. Paste from a V release whose press never came and
brought no paste event; the press and paste event are tracked from the
raw events at the start of each frame.

On Wayland arboard read the X11 clipboard, which the compositor does
not fill for a native Wayland window, so every system paste found it
empty. Enable arboard's wayland-data-control: it uses the Wayland
clipboard when WAYLAND_DISPLAY is set and falls back to X11 otherwise.

Right-click > Paste here now follows the same newest-wins rule, so a
newer image beats copied layers there too, and lands at the point.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:09:59 +07:00
Dylan M. Taylor d1d8c3f652 ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action (#640)
* ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action

* ui: widen About window so contributor table fits with Inter font
2026-10-16 10:08:58 -06:00
RealtaNuaandClaude Opus 5.5 8303cfa56f fix(engine): Shift-drag on a selected layer moves the selection (#498)
Shift on a selected layer took it out of the selection on press, so the
drag that followed had nothing to move, and with several layers selected
the grabbed one stayed behind. It now leaves the selection on release,
and only when the press didn't become a drag. Where no move can start
(a version being viewed), it still leaves on press.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:52:41 +07:00
Barzabel 554821afef M9: a damaged generation field no longer drops a log's flushed edits (#408)
A log whose generation isn't its snapshot's was skipped as a leftover of a
compaction cut short, but neither generation is under a checksum: one flipped
bit made the server serve the older document, report nothing and overwrite the
log. Replay such a log over the snapshot instead: replay skips what the
snapshot already holds, so a genuine leftover changes nothing, while edits the
snapshot lacks are recovered and the damage is reported.
2026-10-16 17:31:11 +03:00
NetterSilverandClaude Haiku 5.5 0e25cf4c26 Refuse pages in edit.copy and edit.cut
Found while testing crates/engine/src/cmd/edit.rs:499 (copy_to_clipboard, called from copy at :536 and cut at :542)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 13:47:07 +00:00
phonedu 9daf39e24c M1: a param that names a layer says so when it isn't one
`node.create` read its `parent` as `id(p, "parent").unwrap_or(page)`, so a `parent` that isn't a
layer id — a `{"$step": n}` nothing had resolved, a name, a negative number — quietly meant "no
parent" and the layer landed on the page, with the call reporting success. The same shape sat on
`image.place` and `instance.create`, on `index` (a string index appended where it was asked to
insert), and on the `ids` of every command that targets layers (an entry that wasn't an id was
dropped and the rest ran). A number that names nothing was reported; anything that isn't a number at
all was not.

`params::opt_id` and `params::opt_index` now say what the commands mean: missing and `null` are
"none", and anything else has to be a layer id (or a whole, non-negative number) and is named when it
isn't. `req_targets` refuses an `ids` entry that isn't an id, before falling back to the selection. A
number that names no layer still reports `no such node`, and a boolean is still a number to `f64_of`,
as it is for `x`, `width` and the rest.

This turns a silently wrong result into an error on purpose, so a caller that passed something that
isn't an id and relied on the fallback now hears about it.
2026-10-16 19:53:43 +08:00
Ahmed5Emad 1aa499c84f Merge remote-tracking branch 'origin/main' into feat/font-hover-preview
# Conflicts:
#	crates/ui-egui/src/style_picker.rs
#	crates/ui-egui/src/typography.rs
2026-10-16 14:34:02 +03:00
phonedu 0f4c79b629 M0.9: a script runs as one undo step and resolves $step, like command.batch
`figcraft-cli run` ran each step of a script on its own, so a script's `{"$step": n}` reached
`parent` unresolved and the layer landed on the page while every step reported success — the same
commands through `command.batch` (the MCP `batch` tool, the control channel, plugins) put it inside
the frame. One `edit.undo` took back one step of a script rather than the script, and a failing step
left the earlier ones applied.

`Session::batch_step` is now the one path both take — one step of a batch, its `$step` references
resolved against the results before it — and `run_script` wraps its steps in `Session::grouped`: the
whole script is one undo step, and a step that fails takes the script back. The result lines come out
once the script is through, so none of them reports work a later failure took back; a failure prints
one line with `rolledBack: true` and exits 1. `docs/cli.md` documents the reference and the output.
2026-10-16 19:33:59 +08:00
Hamza c5a8420089 Make the control server's connection-limit test pass on macOS
`cargo xtask ci` was red on a clean main on macOS: the test's last shutdown
runs after the server has answered an invalid request and closed the
connection, and `shutdown(2)` on a socket whose peer has already closed is
ENOTCONN on macOS and the BSDs where Linux returns success.

The test is right about what it checks; it checked a return value that is not
portable. Closing the client is what the worker thread waits for, and dropping
the socket does that whether or not shutdown reported anything. The file's
other six shutdowns run while the peer is still open, which is well defined on
both platforms, so they stay checked.

With no CI running the tests on pull requests, the local gate is the only
signal a contributor gets, and it could not be green on macOS.
2026-10-16 16:27:05 +05:00
Ahmed5Emad 2cac36a4e2 Live font preview on hover in font picker (#653)
Hovering a family in the Typography font dropdown previews it on the
canvas via text.preview; closing without picking restores the original,
clicking applies it as one undo step.
2026-10-16 12:31:50 +03:00
AganzeFelicite b962603707 M4: points dropped on a point merge
In vector edit mode a point dragged onto another point of its path
snapped there but stayed a separate point, so a path couldn't be closed
or joined by dragging. A single dragged point dropped within the point
hit radius (6 px) of another point now merges into it, in the same undo
step as the move, and that point stays selected.

Closes #401
2026-10-16 10:46:36 +02:00
loyet 11455e0fe9 M1: .fig import reads images carried in the message (image.dataBlob)
Copies written by plugins and tools carry their image bytes in the
message: a paint's image.dataBlob indexes the message's blobs. A bare
canvas (what the clipboard holds, see #647) has no images/, so these
fills came in as grey placeholders. The importer now falls back to the
embedded bytes, keyed by image hash, after the archive's images/.

Blobs over the image size cap and indexes past the blobs are skipped and
stay placeholders. Tests: a bare canvas carrying its image in a blob, and
a dataBlob past the blobs.
2026-10-16 10:44:29 +02:00
NetterSilverandClaude Haiku 5.5 a911ab9614 Reject negative or fractional index in fill.set and stroke.set
Found while testing crates/engine/src/cmd/edit.rs:1180

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 08:29:55 +00:00
AganzeFelicite e8b181de39 M4: Flatten keeps open paths
Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result
from each layer's fill outline, so open paths, which have none, were
dropped: two pen paths flattened to "nothing to flatten", and next to a
closed shape they vanished. Vectors now bring their own network (open
paths, unfilled loops and each region's winding stay), mapped into the
parent's space; other layers still flatten to their outline.

Closes #377
2026-10-16 10:23:57 +02:00
Andrii VolkovandClaude Opus 5.5 0efddc3894 fix(format): reopen large documents once saved
`document.json` was read under the 512 MB cap for any archive member, so a
large imported document saved but didn't open again: 900 000 layers save
as a 1.48 GB document.json, packed into 244 MB. It may now unpack to 16
times its packed size (counted up to the archive's own size), within
512 MB and 4 GB; images and other members keep 512 MB. A small archive
still can't unpack into gigabytes.

That saved file reopens in 13 s with a 5.9 GB peak.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 10:46:08 +03:00
Andrii VolkovandClaude Opus 5.5 12d43b1f99 fix(format): open large .fig files
A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are
expanded) didn't open: "message: too many values". Lifting that cap alone
made it import only its first 200 000 layers, and lifting that one took
41 GB of memory.

The global caps stay as they are. The .fig import gets budgets from the
size of its compressed canvas instead (counted up to the file's own size),
not from the message it unpacks to, since chunks inflate up to 512 MB:
- the kiwi value budget is one value per 2 bytes of the canvas, at least
  the old 20 million (kiwi is only read for .fig files);
- the layer cap is one layer per 32 bytes of the canvas, at least the REST
  import's 200 000 and at most 2 000 000. A small or crafted file can't
  expand its instances into millions of layers, however far it unpacks; the
  REST JSON import keeps 200 000.

Memory:
- The mapper no longer passes the document through `json!`, which serialises
  its arguments: the whole tree was deep-copied twice.
- Vector networks are built once per distinct network and handed to the REST
  importer beside the JSON (`vectorNetworkRef`), not written into it as JSON
  objects for every instance that shows them.
- Decoded kiwi objects are trimmed, and each import stage's input is dropped
  as soon as the next stage is built.

On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 10:46:08 +03:00
Skyfuller1303 41d465a678 M1: reword the vector segment style import notes 2026-10-16 09:40:06 +02:00
Skyfuller1303 d8e64a73bf M1: reword the vector segment style import notes 2026-10-16 09:40:04 +02:00
Skyfuller1303 771f7e869f M1: reword the unresolved point style import note 2026-10-16 09:40:04 +02:00
Skyfuller1303 657a8f4df6 M1: translate the vector style import notes 2026-10-16 09:16:10 +02:00
JimKnoxxandClaude Opus 5.5 ab10e37d43 M1: paste layers copied in Figma
Figma puts copied layers in the clipboard's HTML as a base64 .fig canvas
between (figma) markers. edit.pasteFromFigma reads it with the .fig import,
turns the layers into the session clipboard and pastes them as edit.paste
does (selected frame, in place or view centre), with the import's warnings.
The desktop app reads the clipboard's HTML (arboard) and Paste prefers a
Figma copy; the browser's paste event carries plain text only.

Tested on copies written by our own .fig export; not yet on real copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 08:20:49 +02:00
Patrik LermonandClaude Opus 5.5 107aa27d0a docs: Swedish now covers the command labels and menu wordings
ROADMAP.md, docs/localization-parity.md and docs/translating.md said
Swedish had 120 of the 588 command labels and menu wordings; with the
previous commit it has all of them. Worded without counts, so the docs
stay right as commands are added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:20:40 +02:00
Zaxaerith 167e06f695 docs: correct example plugin count (#392) 2026-10-16 12:08:26 +08:00
Dylan M. Taylor cc26c29217 ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:57:45 -04:00
Patrik LermonandClaude Opus 5.5 a00b2e0583 i18n(sv): translate the command and menu labels
The [commands] and [menu] rows held their command id as the text
("align.bottom" = "align.bottom"), which the menus and the command
palette would show as is. They now translate the English labels the
code gives those ids (spec! and m! in crates/engine), e.g.
"align.bottom" = "Justera nederkant". Also the interface strings that
are new on upstream main (23bdebd).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:42:54 +02:00
Patrik Lermon ba3455cc6e Merge upstream/main into swedish-ui-2026-10-15 2026-10-16 05:41:29 +02:00
skylar taylor-barrick 23bdebd973 Merge pull request #176 from dylanmtaylor/use-craft-common-actions
ci: delegate release, freebsd, windows-arm64, packaging-lint to craft-common-actions and add nix check
2026-10-15 21:34:16 -06:00
skylartaylor d9584410af ci: retain FigCraft package and metadata checks 2026-10-16 05:24:23 +02:00
Skyfuller1303 c4536f412b M1: import labels of straight connectors from .fig files
Straight connectors saved with a manual routing snapshot (connector and derived-frame version 1) now come in as an editable vector plus their label text layers, instead of an empty frame. Label placement composes the saved background and text transforms, and label paints and text styles use the existing import path. Connectors of other versions or line styles keep the existing fallback with their compatibility notes. At the layer depth or node limit the saved vector is kept without extra label layers, and skipped labels are reported once. Live endpoint bindings and routing are not retained.
2026-10-16 05:08:37 +02:00
Skyfuller1303 7fd44641f2 M1: keep paragraph options from .fig files
Text layers, text styles and instance overrides in .fig files can carry list spacing, hanging list, hanging punctuation and leading trim. The importer now reads them from the highest layer with a valid value, so an invalid instance override row falls through to the valid value below it, while explicit zero, false and NONE values stay resets. Both text layers and text styles share one mapping. The native text data stores these options once per text node, so differing values on separate runs are not represented, and the experimental .fig exporter keeps its existing note that these settings are left out.
2026-10-16 05:03:38 +02:00
Skyfuller1303 3723fcffbd M1: keep text decoration skip ink from .fig files
Text layers, styled runs and text styles in .fig files carry a skip-ink boolean for their decoration. The importer now reads it into the native decoration look: an omitted run value inherits the layer's, a local value overrides a linked shared text style without changing the style itself, and instance overrides keep an explicit false over a true main. A malformed value counts as unset. The exporter writes the actual value of the layer and of each styled run instead of a constant true, and a decoration look that differs from the default only in skip ink no longer triggers the unsupported-decoration note, so default native text exports its real false value.
2026-10-16 05:03:34 +02:00
Skyfuller1303 120b6830ff M1: import editable text on a path from .fig files
Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path.

The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged.
2026-10-16 05:03:29 +02:00
Skyfuller1303 a52e2bb6b3 M1: distinguish unresolved vector segment style metadata in .fig import
The vector network decoder now keeps each segment's style id. Import reports a segment style that names a row of the layer's style table as metadata that was not interpreted, and one that names no row as a reference to a missing style entry, each at most once per file. The earlier note claimed that stroke styles were left out, which overstated what is known about the metadata. Segment geometry, paint and width are unchanged, and the style ids are not kept in the native vector model.
2026-10-16 05:02:27 +02:00
Skyfuller1303 8408cc72b8 M1: import component slot content from .fig files
Slot properties of a component, the frames that reference them and the content that instances assign to them now come across from .fig files. A slot definition becomes a native slot property. The referencing frame of an expanded instance keeps following the frame of its main, and when the instance assigns slot content (including an explicitly empty slot) the frame carries that content as its own, independently owned children. Nested instances inside assigned content keep their own source links. An assignment on an outer instance wins over one made by an instance nested in the main, and a slot without an assignment keeps the default content of its main.

Assignments that are malformed, use the empty-GUID sentinel or point at a layer that is not slot content are skipped with an import note. Preferred component keys cannot be mapped to nodes of the file and are dropped with a note, as are slot settings the editor cannot represent. Public REST snapshots, the experimental .fig writer and property resources are not changed.
2026-10-16 04:59:11 +02:00
Skyfuller1303 0b40964202 M1: keep grid track sizes in .fig import
Grid columns and rows keep fixed pixel sizes, Hug sizing and fractional Fill weights when the minimum and maximum sizing definitions agree. Track GUIDs join the sizing records to the ordered tracks, so reordered records do not change cell placement. Unsupported min/max pairs, invalid sizes and duplicate records fall back with a reported note, and imports are bounded to 1,000 tracks per axis. Public REST snapshots ignore the private track metadata.
2026-10-16 04:58:11 +02:00
Dylan M. Taylor 3489e01a22 ci: delegate release, freebsd, windows-arm64, packaging-lint to craft-common-actions and add nix check 2026-10-15 21:39:02 -04:00
NetterSilverandClaude Haiku 5.5 8538dd740f Refuse edits that leave a layer with non-finite geometry
Found while testing crates/engine/src/cmd/arrange.rs:132

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 03:38:36 +02:00
Skyfuller1303 d2606b3f51 M1: let local text values and instance overrides win over a shared text style in .fig import
A text layer that links a shared text style keeps its own, possibly stale, copy
of the typography. The style's typography now beats that copy, a case,
decoration, paragraph spacing or paragraph indent set on the layer itself beats
the style (so "original", "none" and zero clear the style's value), and an
override row of an instance beats both. An explicit "no style" on a text layer
unlinks it even when the older style field still names one.

Letter spacing given as a percentage of the font size stays a percentage. A text
property value on an instance no longer inherits the character styles written
for the main's text, which count other characters.
2026-10-16 03:32:39 +02:00
Skyfuller1303 4b29d8c34d M1: apply instance overrides by key and order, settle bound paints, keep stroke outline styles
Override rows of an instance can name a layer of its main by the key the layer
had before the main was edited. The key now resolves to the layer's current id
when it is unambiguous and the current id does not exist already. Rows of an
enclosing instance win over those of the instances nested in it, whatever order
the wire lists them in, and an explicit "no style" on an override detaches the
style instead of falling back to the older field.

Paints bound to variables take the colours of the active modes when a document
loads, before the first edit, without touching other pending changes.

A stroke-only vector that imports as the outline of its stroke now moves the
stroke's shared style and colour variables to the fill it becomes, and vectors
whose single segments carry their own stroke style say so in a warning.
2026-10-16 03:32:38 +02:00
Skyfuller1303 d331db00aa M1: keep explicit instance identity and cached library components in .fig import
A .fig import now links the layers of an instance to the layers of the
component they came from by the ids the file stores, instead of leaving it to
the engine to guess from names and places. Renamed slots and layers, siblings
that share a name and swapped mains keep their source link and the override
they carry.

Components that live only on the file's hidden internal pages (cached copies of
library components) are kept in the hidden library store when a visible layer
uses them, so their instances stay instances. The read-only guard for library
copies now compares the copies themselves rather than the dirty set, which an
import leaves full until the first edit.

Rebasing an instance (after detaching an outer instance or restoring a main)
follows the source chain inside the right enclosing instance, so contextual
links survive it. The last change of a GUID wins and a removed change deletes
it. Ordinary file JSON never reads the private provenance.

Importing such a file into an open document, or pasting its layers into another one, copies the cached components along (import) or leaves them behind as a missing main (paste), so no link points at a layer of the target.
2026-10-16 03:32:36 +02:00
BFlat f40c600e67 Docs: roadmap rows for the community fixes from #478 up (#591, #598, #602) and #589 (#605) 2026-10-16 07:55:52 +07:00
BFlat 9c2d61f6ae Merge pull request #601 from Skyfuller1303/feat/drop-to-open
M7: drop files anywhere to open them
2026-10-16 07:41:02 +07:00
skylar taylor-barrick e4fe308cdf Merge pull request #608 from Silvertree2010/fix/h1871-file-close-with-a-negative-index
Reject negative or fractional index in file.close
2026-10-15 18:28:25 -06:00
Marco Arnulfo 5d1c24d061 Contributors: add my name for the About window 2026-10-16 02:19:42 +02:00
Skyfuller1303 01daf92fc3 M1: show an import report after opening a file
Opening or importing a .fig or REST JSON file, or an SVG that has notes,
from the app (File > Open or Import, a recent file, a drop, a paste, the
system's open event) now shows an Import report window with the
document's name, its page, layer and image counts and the compatibility
notes the importer collected, instead of leaving them only in the command
result. Commands sent over the control channel or MCP never open it.

file.open and file.import return the counts as `summary`; ui.inspect
reports the open report as `importReport`. The fixed notes the importers
emit are translated (German and Spanish) and shown with a capital first
letter; other notes, such as SVG's, show as written. The report goes
away with its document. Escape or Close dismisses it.
2026-10-16 02:18:50 +02:00
Skyfuller1303 d4394e87cc M1: save a copy as .fig
Save as now offers .figcraft and .fig as separate file types. A .fig
destination, chosen in the dialog or passed to file.saveAs and file.save,
writes the existing experimental .fig export of the document that is shown
and reports how much was left out. The file's own path, unsaved changes,
versions, branches and undo history are not touched, and a shown earlier
version stays shown while the copy is written or a Save as choice is
pending.

Plain Save no longer writes over a file whose saved path ends in .fig;
earlier builds could write native data under that name. It asks for a
destination instead.
2026-10-16 02:03:46 +02:00
vxMacDev c56a65f58b fix(format): keep vertical trim on .fig import (#466)
Figma's schema has leadingTrim on NodeChange (NONE, CAP_HEIGHT), and
the REST importer already reads style.leadingTrim, but the .fig mapping
never copied it, so text set to "Cap height to baseline" imported
untrimmed in its trimmed box. text_style now copies it with the other
text fields, from the layer or its text style.
2026-10-15 18:45:06 -05:00
Skyfuller1303 924310c124 M1: keep per-point stroke joins on .fig import
Vector points carry their own stroke join in a .fig file. Import now keeps each point's join as the point's own override instead of dropping it for the layer's join.

Joins equal to the layer join no longer force styled stroke runs, and an absent layer join counts as miter. Unresolved point styles, unknown joins, and joins that dashed or variable-width strokes cannot draw each produce a warning.
2026-10-16 01:34:52 +02:00
Skyfuller1303 0404448f74 M1: keep imported file bytes out of the command journal
Pasting or dropping a file runs file.import with the whole file as Base64
data, and every successful command was journaled with its parameters, so
each imported file stayed in memory a second time for the session. Byte
imports, including ones wrapped in command.batch, are no longer journaled.
Imports by path and all other commands are unchanged.
2026-10-16 01:34:29 +02:00
BFlat 4ee413acde Merge pull request #606 from storytold/community/batch-8
Community batch 8: drop files anywhere, duplicate fill tests
2026-10-16 06:18:07 +07:00
Skyfuller1303 1f6010e07c M7: open dropped files that have no file on disk
A home-screen drop that carries only bytes queued file.open with data and name, which file.open rejected because it requires a path. file.open now also takes base64 data with a name and opens it as a new document without a save path.
2026-10-16 00:22:14 +02:00
NetterSilverandClaude Haiku 5.5 90d7521840 Reject negative or fractional index in file.close
Found while testing crates/engine/src/cmd/file.rs:1283

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 00:19:49 +02:00
bflatastic 665a578957 Merge branch 'fix-601' into batch/t8 2026-10-16 05:04:14 +07:00
bflatastic 44ac32db0c Merge branch 'pr-600' into batch/t8 2026-10-16 05:04:13 +07:00
bflatastic f948e08fbc Merge remote-tracking branch 'origin/main' into fix-601
# Conflicts:
#	ROADMAP.md
2026-10-16 05:04:09 +07:00
bflatastic 65f5771a6e Home: describe the drop target without naming other software 2026-10-16 05:04:00 +07:00
BFlat 33d59932f9 Merge pull request #602 from storytold/community/batch-7
Community batch 7: Czech plurals and bounded canvas overscan
2026-10-16 04:51:25 +07:00
Barzabel d27ae11d15 M1: a duplicate's fill stays its own (#586)
Regression test for #586: with the colour picker open on a red rectangle or
pen path, duplicating it (edit.duplicate, or copy and paste) and recolouring
the copy leaves the original red. The cause was the picker staying bound to
the original after the selection moved to the copy, fixed by #523; without
that fix this test fails. The duplicated paint is already independent data.
2026-10-15 23:06:31 +03:00
Patrik LermonandClaude Opus 5.5 0a5eceedf1 i18n(sv): translate 480 new strings
Brings the Swedish catalog up to date with upstream main (eb741a3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:05:47 +02:00
Skyfuller1303 b640069961 M7: drop files anywhere to open them
- Files dragged over the window show a "Drop to open" (home screen) or
  "Drop to import" (canvas) overlay.
- On the home screen each dropped file the app opens becomes a document of its
  own; any other file is turned away with a toast.
- On the canvas dropped files import as before, at the drop point.
- A small card ("Opening as a new file", "Adding to {document}") is on screen
  before a large file blocks the window; the files then open a frame later.
- A canvas import stays with the document and page that received the drop,
  keeps its placement, restores the page and selection chosen meanwhile, and is
  cancelled with a message if that document or page is gone.
- ui.inspect reports pendingOpens; every new string, including the cancel
  messages, goes through tr() with en, de and es entries.
2026-10-15 21:48:25 +02:00
152 changed files with 13631 additions and 1268 deletions

No files matched your search

+11 -40
View File
@@ -1,13 +1,12 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners).
# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the
# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is
# uploaded and no secrets are used.
# (GitHub has no FreeBSD runners). It then runs packaging/freebsd/package.sh, the script
# the release job runs, and checks the tarball, so a broken FreeBSD package fails here
# before it can fail a release. Nothing is uploaded and no secrets are used.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
# FreeBSD packaging itself still run it.
on:
schedule:
- cron: "17 2 * * *"
@@ -29,36 +28,8 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test + package)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build, test and package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
# dejavu: the text tests need one real font, as the Ubuntu runner has.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 dejavu
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
set -e
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
# The release package, as release.yml builds it. Free the debug build's disk first.
rm -rf target/debug
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt"
for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do
grep -E "$f" "$HOME/freebsd-contents.txt"
done
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
run-package-check: true
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
+23
View File
@@ -0,0 +1,23 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+4 -55
View File
@@ -19,58 +19,7 @@ concurrency:
jobs:
lint:
timeout-minutes: 15
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: Server install script (Caddyfile handling)
run: bash packaging/server/install-test.sh
- name: AppImage AppRun desktop integration
run: bash packaging/linux/apprun-test.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
./packaging/windows/check-icons.ps1
- name: FreeBSD package layout (dry run, no build)
run: bash packaging/freebsd/package.sh --dry-run
- name: No template leftovers from other apps in package metadata
run: |
if grep -rniE 'photoshop|psd|photocraft|vectorcraft|illustrator|adobe|figma' packaging/ .github/ --exclude=packaging-lint.yml; then
echo "::error::package metadata mentions another product (see above)"
exit 1
fi
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/figcraft.wxs packaging/windows/figcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.figcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.figcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.figcraft" and m["command"] == "figcraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
with:
extra-checks-script: packaging/check-app-specific.sh
+24 -541
View File
@@ -1,11 +1,3 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "FigCraft v<version>". See docs/releasing.md.
#
# Only the jobs that read secrets (macOS and Windows signing, and the draft release) run in the
# `release` environment (restricted to the `release` branch), which holds the signing secrets; the
# rest build on any branch, so `gh workflow run release.yml --ref <branch>` is a real dry run.
# Every secret is optional: missing ones produce unsigned artifacts and a warning, never a failed
# build. Playbook: storytold/craftrules release/playbook.md.
name: Release
on:
@@ -19,543 +11,34 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
FIGCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
# embeds (Inter and the Latin presentation fonts from fonts/latin-manifest.txt; the web build
# embeds Inter only, see crates/text/build.rs). An optional build input for everyone else.
# crates/text/build.rs needs fonts/latin-manifest.txt, so the pin must include it. Bump
# deliberately; every build job checks out this commit.
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
jobs:
version:
timeout-minutes: 10
name: version
runs-on: ubuntu-latest
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### FigCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
timeout-minutes: 90
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
- name: Verify signatures and notarization
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/verify.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
timeout-minutes: 90
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build, sign, package
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
timeout-minutes: 90
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/figcraft$|metainfo|applications'
ldd target/release/figcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.figcraft.yml')); assert m['id'] == 'ai.storyteller.figcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
server:
timeout-minutes: 90
name: Server Linux ${{ matrix.arch }}
needs: version
# The headless collaboration server (docs/server.md): figcraft-server-<v>-linux-<arch>.tar.gz
# with the binary, licences, docs/server.md and the deployment examples. Same runners as the
# app, so the same glibc floor; no craft-fonts (the server draws no text).
# shortcut: Linux only (where servers run); add macOS/Windows archives, signed like figcraft-cli,
# when people host there.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04 }
- { arch: aarch64, runner: ubuntu-22.04-arm }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-server-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Build and package (tar.gz)
run: packaging/server/package.sh
- name: Check the archive
run: |
tar -tzf "dist/release/figcraft-server-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | tee "$RUNNER_TEMP/server-contents.txt"
for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/install\.sh$' '/figcraft-backup\.timer$' '/server\.md$' '/LICENSE-MIT$'; do
grep -E "$f" "$RUNNER_TEMP/server-contents.txt"
done
ldd target/release/figcraft-server
- uses: actions/upload-artifact@v4
with:
name: release-server-linux-${{ matrix.arch }}
path: dist/release/*.tar.gz
if-no-files-found: error
flatpak:
timeout-minutes: 90
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/figcraft-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
# .github/workflows/freebsd.yml. A private repo's ubuntu-latest has 2 vCPUs and 7 GB, too small
# for this VM (QEMU "cannot set up guest memory"), so it runs on the org's 16-core larger runner.
runs-on: github-ubuntu-16core
timeout-minutes: 180
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "FIGCRAFT_VERSION FIGCRAFT_BUILD_DATE FIGCRAFT_BUILD_SHA INPUT_VERSION"
# The FreeBSD CI packages, plus bash for the packaging script.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/figcraft-target CARGO_BUILD_JOBS=4
if [ -n "$INPUT_VERSION" ]; then cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"; fi
# craft-fonts was checked out next to the sources and synced into the VM with them.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
bash packaging/freebsd/package.sh
tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt"
for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do
grep -E "$f" "$HOME/freebsd-contents.txt"
done
- uses: actions/upload-artifact@v4
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
web:
timeout-minutes: 90
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
# The wasm32 build embeds only Inter from craft-fonts (crates/text/build.rs): browsers have
# no system fonts to fall back on, and the rest would push the wasm towards
# packaging/web/package.sh's 24 MiB gate.
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
linux-riscv64:
timeout-minutes: 90
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU.
runs-on: ubuntu-24.04
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- uses: actions/upload-artifact@v4
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
release:
timeout-minutes: 10
name: Draft GitHub Release
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="FigCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo run -p xtask -- version set X.Y.Z) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: figcraft
app-title: FigCraft
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
has-server: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+6 -73
View File
@@ -24,77 +24,10 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: figcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: figcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'FigCraft'
# figcraft-cli.exe ships once apps/figcraft-cli exists; check it when installed.
foreach ($exe in 'figcraft.exe', 'figcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) {
if ($exe -eq 'figcraft-cli.exe') { Write-Output "skip $exe (not in this build)"; continue }
throw "$exe not installed in $dir"
}
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed binary runs natively on ARM64 (the CLI when present, else the app's --version).
$cli = Join-Path $dir 'figcraft-cli.exe'
if (-not (Test-Path $cli)) { $cli = Join-Path $dir 'figcraft.exe' }
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "$cli --version exited $LASTEXITCODE" }
Write-Output "ok $(Split-Path -Leaf $cli) --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'figcraft.exe')) { throw 'uninstall left figcraft.exe behind' }
Write-Output 'ok uninstalled'
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: figcraft
app-dir: FigCraft
Generated
+66
View File
@@ -259,6 +259,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1734,6 +1735,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -2820,6 +2827,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3204,6 +3220,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -3264,6 +3290,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -4574,6 +4611,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -5726,6 +5774,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "write-fonts"
version = "0.48.1"
+1 -1
View File
@@ -228,7 +228,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
+7 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **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
@@ -131,6 +131,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
@@ -313,6 +315,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
+83 -10
View File
@@ -333,17 +333,32 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
}
None => Session::new(),
};
for (i, (cmd, params)) in steps.iter().enumerate() {
let r = s.execute(cmd, params);
s.ui_requests.clear();
match r {
Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})),
Err(e) => {
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
return Ok(ExitCode::from(1));
}
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
// (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
// a step that fails takes the whole script back instead of leaving the earlier steps applied.
// The result lines are held back until the script is through, so none of them reports work that
// was rolled back.
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
let mut failed_at = 0;
let outcome = s.grouped(&format!("run {script}"), |s| {
for (i, (cmd, params)) in steps.iter().enumerate() {
failed_at = i;
let v = s.batch_step(&results, i, cmd, params)?;
s.ui_requests.clear();
results.push(v);
}
Ok(())
});
if let Err(e) = outcome {
let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
let i = failed_at;
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
eprintln!("the script's document changes were rolled back");
return Ok(ExitCode::from(1));
}
for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
}
if let Some(out) = out {
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
@@ -422,4 +437,62 @@ mod tests {
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
std::fs::remove_dir_all(dir).unwrap();
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
dir
}
/// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
/// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
/// as "no parent" and the layer landed on the page — with every step reporting success.
#[test]
fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() {
let dir = temp_dir("run-step");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}
]"#,
)
.unwrap();
let out = dir.join("out.figcraft");
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
let mut s = Session::empty();
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
let doc = s.doc().unwrap();
let page = s.page().unwrap();
let top = doc.node(page).unwrap().children.clone();
assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too");
assert_eq!(doc.node(top[0]).unwrap().name, "Card");
let inner = doc.node(top[0]).unwrap().children.clone();
assert_eq!(inner.len(), 1, "the rectangle went inside the frame");
assert_eq!(doc.node(inner[0]).unwrap().name, "child");
std::fs::remove_dir_all(dir).unwrap();
}
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
#[test]
fn a_failed_step_leaves_no_output() {
let dir = temp_dir("run-rollback");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}}
]"#,
)
.unwrap();
let out = dir.join("out.figcraft");
// The failure is the script's, not the call's: the CLI reports it and exits non-zero.
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
assert!(!out.exists(), "a script that failed writes nothing");
std::fs::remove_dir_all(dir).unwrap();
}
}
+29 -11
View File
@@ -135,9 +135,23 @@ impl Config {
/// abuse contact, or one whose clients would all count as its proxy).
pub fn warnings(&self) -> Vec<String> {
let mut out = Vec::new();
let client_max = NetLimits::default().max_server_message >> 20;
if usize::try_from(self.file_mb()).is_ok_and(|mb| mb >= client_max) {
out.push(format!(
"max_file_mb is {}: the app opens files of up to {} MiB, so a file that large can't be opened",
self.file_mb(),
client_max - 1
));
}
if !self.open_publish {
return out;
}
if self.file_mb() > 64 {
out.push(format!(
"open_publish is on with max_file_mb = {}: anyone may send messages that large, and the memory set aside for reading and queueing them grows to eight times it; keep it at 64 or less on a public server",
self.file_mb()
));
}
if self.operator_contact.trim().is_empty() {
out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into());
}
@@ -253,8 +267,9 @@ impl Config {
}
}
/// Largest message a client may send when open to the public: a whole file (or plugin bundle)
/// and a margin, not the core's 64 MiB.
/// Largest message a client may send: a whole file (or plugin bundle) and a margin. Open to the
/// public this is also the cap (strangers' files stay small); a private server never goes below
/// the core's 64 MiB, and a `max_file_mb` above it raises the cap with it.
fn open_message(&self) -> usize {
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
}
@@ -265,9 +280,9 @@ impl Config {
fn core(&self) -> figcraft_collab::Limits {
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
if !self.open_publish {
return base;
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
}
let max = self.open_message().min(base.max_message_bytes);
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 }
}
@@ -279,21 +294,24 @@ impl Config {
pub fn net(&self) -> NetLimits {
let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() };
if !self.open_publish {
return base;
let max = base.max_client_message.max(self.open_message());
return NetLimits { max_client_message: max, max_queued_bytes: base.max_queued_bytes.max(max.saturating_add(1 << 20)), ..base };
}
let max = self.open_message().min(base.max_client_message);
let max = self.open_message();
// Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an
// eighth of each, and its biggest consumers are dropped first.
let (queued, reading) = (256usize << 20, 192usize << 20);
// eighth of each, and its biggest consumers are dropped first. Each still fits one whole
// message, or a file as large as `max_file_mb` could never arrive.
let one = max.saturating_add(1 << 20);
let (queued, reading) = ((256usize << 20).max(one.saturating_mul(8)), (192usize << 20).max(one.saturating_mul(8)));
NetLimits {
max_per_address: self.max_connections_per_ip,
max_client_message: max,
max_queued_bytes: max.saturating_add(1 << 20),
max_queued_bytes: one,
max_queued_block: queued / 8,
max_queued_total: queued,
max_reading_block: max.saturating_add(1 << 20).min(reading / 8),
max_reading_block: one.min(reading / 8),
max_reading_total: reading,
max_in_flight: 128 << 20,
max_in_flight: (128usize << 20).max(one),
reserve_for_sessions: self.max_connections / 8,
request_idle: std::time::Duration::from_secs(20),
..base
+4 -2
View File
@@ -8,8 +8,10 @@
//! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding.
//!
//! Compaction writes the next generation's snapshot, then an empty log of that generation. A log
//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it
//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short).
//! whose generation isn't the snapshot's is usually from before a compaction that was cut short
//! (what it holds is in the snapshot already), but no checksum covers either generation, so the
//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its
//! first damaged record (a write cut short).
use std::fs::{self, File};
use std::io::{self, Read, Write};
+32 -1
View File
@@ -413,7 +413,7 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() {
assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly");
assert_eq!(open.net().reserve_for_sessions, 512 / 8);
assert_eq!(open.net().max_client_message, 17 << 20);
assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private");
assert_eq!(private.net().max_client_message, 65 << 20, "private: a 64 MiB file and a margin");
let w = open.warnings().join("\n");
assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}");
let set = Config { operator_contact: "[email protected]".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open };
@@ -493,3 +493,34 @@ fn open_connections_never_reach_what_needs_the_key() {
assert!(!format!("{info:?}").contains(&k));
server.stop();
}
#[test]
fn files_above_64_mib_fit_every_limit() {
// `max_file_mb` may go up to 4096: every cap a whole file's message meets on its way in must
// follow it, or the upload is dropped by the transport before the server can say why.
for open_publish in [false, true] {
let cfg = Config { open_publish, max_file_mb: Some(256), public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() };
let (net, core, file) = (cfg.net(), cfg.store().core, 256usize << 20);
assert!(net.max_client_message > file, "open={open_publish}: {}", net.max_client_message);
assert!(net.max_queued_bytes > file && net.max_reading_block > file && net.max_in_flight > file, "open={open_publish}: {net:?}");
assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block);
assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes);
}
// A client takes such a file too, but caps what any server may make it buffer at 1 GiB.
assert_eq!(NetLimits::default().max_server_message, 1 << 30);
}
#[test]
fn large_files_warn_the_operator() {
let public_url = Some("wss://figcraft.example.com/".into());
let warns = |open_publish, mb| {
Config { open_publish, max_file_mb: Some(mb), public_url: public_url.clone(), ..Config::default() }.warnings().join(
"
",
)
};
assert!(!warns(true, 64).contains("max_file_mb"), "{}", warns(true, 64));
assert!(warns(true, 65).contains("open_publish is on with max_file_mb = 65"), "{}", warns(true, 65));
assert!(!warns(false, 1023).contains("max_file_mb"), "{}", warns(false, 1023));
assert!(warns(false, 1024).contains("can't be opened"), "{}", warns(false, 1024));
}
+27 -3
View File
@@ -440,7 +440,7 @@ impl Store {
&& let Ok(log) = disk::read_log(&bytes)
&& log.generation != generation
{
log::info!("file at {}: its log predates the last compaction; it is ignored", path.display());
log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display());
}
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum();
Ok(Entry { meta, links, log: None, bytes, forced: 0 })
@@ -1126,8 +1126,32 @@ impl Store {
fresh_log = true;
}
}
// From before a compaction that was cut short: the snapshot has it all.
Ok(_) => {}
// From before a compaction that was cut short, the snapshot has it all; or a generation
// field damaged, which no checksum covers. Replaying tells them apart without trusting
// it: entries the snapshot holds are skipped, so only edits it lacks change anything.
Ok(log) => {
let before = server.seq();
let mut stopped = None;
for entry in log.entries {
if let Err(e) = server.replay(entry) {
stopped = Some(e);
break;
}
}
let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before));
if recovered > 0 {
issues.push(format!(
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \
{recovered} changes the snapshot didn't; they were recovered"
));
}
if let Some(e) = stopped {
issues.push(format!(
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \
making sense after {recovered} newer changes ({e}); the rest was dropped"
));
}
}
Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
}
for i in issues {
@@ -0,0 +1,139 @@
//! A generation field damaged after an abrupt stop must not make the server drop a log that still
//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
//! still restores silently.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_collab::message::{Access, ClientMsg};
use figcraft_collab::{Client, Outgoing, Replica, Server};
use figcraft_collab_net::hub::Backend;
use figcraft_doc::Document;
use figcraft_server::config::Config;
use figcraft_server::disk::{self, LOG, SNAPSHOT};
use figcraft_server::store::{FILES, Store};
use std::fs;
use std::path::{Path, PathBuf};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap()));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn page_name(server: &Server) -> String {
let doc = server.document();
doc.node(doc.first_page().unwrap()).unwrap().name.clone()
}
/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then
/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state.
fn renamed_and_stopped(data: &Path) -> (String, Server) {
let mut store = Store::open(data, Config::default().store()).unwrap();
let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap();
let (info, _, _) = store.publish("Fixture", &snapshot).unwrap();
let id = info.id;
let mut server = store.open(&id).unwrap();
let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") };
let (_, messages) = server.join_as(hello, Access::Edit).unwrap();
let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") };
let mut client = Client::join(welcome).unwrap();
store.changed(&id, &mut server);
let mut edited = client.document().clone();
let page = edited.first_page().unwrap();
edited.node_mut(page).unwrap().name = "Kept".into();
let change = client.edit(&edited).unwrap().unwrap();
assert!(!server.receive(client.id(), change).is_empty());
store.changed(&id, &mut server);
assert!(store.flush().is_empty(), "the edit must reach disk");
assert_eq!(page_name(&server), "Kept");
let dir = data.join(FILES).join(&id);
assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty());
assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log");
drop(store);
(id, server)
}
/// Reopen the data directory: the file's first page name and the problems reported.
fn reopen(data: &Path, id: &str) -> (String, Vec<String>) {
let mut store = Store::open(data, Config::default().store()).unwrap();
let server = store.open(id).unwrap();
(page_name(&server), store.problems().to_vec())
}
fn flip(path: &Path, at: usize) {
let mut bytes = fs::read(path).unwrap();
bytes[at] ^= 1;
fs::write(path, bytes).unwrap();
}
/// The generation field's first byte in both headers (after the magic and the format byte).
const GENERATION_AT: usize = 5;
#[test]
fn an_intact_log_restores_its_edits_without_a_report() {
let fixture = Fixture::new();
let (id, _) = renamed_and_stopped(&fixture.0);
let (name, problems) = reopen(&fixture.0, &id);
assert_eq!(name, "Kept");
assert!(problems.is_empty(), "{problems:?}");
}
#[test]
fn a_damaged_log_generation_keeps_the_edits_and_is_reported() {
let fixture = Fixture::new();
let (id, _) = renamed_and_stopped(&fixture.0);
let log = fixture.0.join(FILES).join(&id).join(LOG);
flip(&log, GENERATION_AT);
// The records themselves still read: only the header's generation changed.
let read = disk::read_log(&fs::read(&log).unwrap()).unwrap();
assert!(read.damaged.is_none() && !read.entries.is_empty());
let (name, problems) = reopen(&fixture.0, &id);
assert_eq!(name, "Kept", "the flushed rename must survive");
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
// And it stays: the recovered edit is now in the file's snapshot.
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
}
#[test]
fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() {
let fixture = Fixture::new();
let (id, _) = renamed_and_stopped(&fixture.0);
let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT);
flip(&snapshot, GENERATION_AT);
assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum");
let (name, problems) = reopen(&fixture.0, &id);
assert_eq!(name, "Kept", "the flushed rename must survive");
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
}
#[test]
fn a_damaged_log_format_is_still_reported() {
let fixture = Fixture::new();
let (id, _) = renamed_and_stopped(&fixture.0);
flip(&fixture.0.join(FILES).join(&id).join(LOG), 4);
let (_, problems) = reopen(&fixture.0, &id);
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}");
}
#[test]
fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() {
let fixture = Fixture::new();
let (id, server) = renamed_and_stopped(&fixture.0);
// The next snapshot was written (it holds the rename); the old log wasn't replaced yet.
let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap();
fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap();
let (name, problems) = reopen(&fixture.0, &id);
assert_eq!(name, "Kept");
assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}");
}
+9
View File
@@ -14,7 +14,16 @@
//! join from other machines.
//!
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
//!
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
//! The link carries the session's secret; a fragment is never sent to the web host, but the address
//! bar and the browser history keep it, so the URL is as private as the link itself.
#[cfg(any(target_arch = "wasm32", test))]
mod query;
#[cfg(test)]
mod query_tests;
#[cfg(target_arch = "wasm32")]
mod web;
+15
View File
@@ -0,0 +1,15 @@
//! The page's URL: the invite link its fragment names.
/// The invite link in `#join=<link>` (`fragment` is `location.hash`, with or without its `#`), still
/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out
/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted
/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite
/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is
/// opened with.
pub fn join_link(fragment: &str) -> Option<&str> {
// An invite link is at most 2 KiB; every byte of it escaped is three.
const MAX: usize = 3 * 2048;
let fragment = fragment.strip_prefix('#').unwrap_or(fragment);
let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 };
fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX)
}
+29
View File
@@ -0,0 +1,29 @@
//! `#join=` in the page's URL.
use crate::query::join_link;
#[test]
fn join_takes_the_rest_of_the_fragment() {
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s");
assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries");
assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later");
assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #");
}
#[test]
fn no_join_without_the_entry() {
assert_eq!(join_link(""), None);
assert_eq!(join_link("#"), None);
assert_eq!(join_link("#join="), None);
assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name");
assert_eq!(join_link("#xjoin=x&a=1"), None);
assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host");
}
#[test]
fn an_overlong_link_is_ignored() {
let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000));
assert_eq!(join_link(&long), None);
assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes");
}
+7 -1
View File
@@ -40,7 +40,13 @@ pub fn start() {
// on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
if let Some(link) = crate::query::join_link(&hash) {
let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string());
figcraft_ui_egui::live::open_invite(&mut fig, &link);
}
Ok(Box::new(WebShell { fig }))
}),
)
+1 -1
View File
@@ -21,7 +21,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
webbrowser = { workspace = true }
image = { workspace = true }
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) {
/// Open `paths` as File › Open does; a file that fails shows its error and the others still open.
pub fn open_documents(fig: &mut FigApp, paths: Vec<PathBuf>) {
for p in paths {
if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() {
if fig.run_user("file.open", json!({"path": p.to_string_lossy()})).is_some() {
fig.ui.home = false;
}
}
+5 -1
View File
@@ -160,7 +160,11 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
assert_eq!(workers.len(), 1);
client.write_all(b"invalid\n").unwrap();
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
client.shutdown(Shutdown::Both).unwrap();
// The server closes this connection after answering an invalid request, and shutting down
// a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux
// returns Ok. Closing the client is what the worker is waiting for, so the result is moot:
// the other shutdowns in this file run while the peer is still open and stay checked.
let _ = client.shutdown(Shutdown::Both);
workers.pop().unwrap().join().unwrap();
}
+62 -3
View File
@@ -223,12 +223,47 @@ fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> Dial
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
}
/// How long a clipboard read may wait on the app that copied (Linux and the BSDs).
#[cfg(all(unix, not(target_os = "macos")))]
const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// Read the clipboard. On Linux and the BSDs the read runs on a helper thread with its own handle
/// and gives up after [`CLIPBOARD_TIMEOUT`]: a Wayland read waits on the app that copied with no
/// timeout of its own, so an app that hangs must not hang the window too.
#[cfg(all(unix, not(target_os = "macos")))]
fn read_clipboard<T: Send + 'static>(_: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option<T>) -> Option<T> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(arboard::Clipboard::new().ok().and_then(|mut c| read(&mut c)));
});
rx.recv_timeout(CLIPBOARD_TIMEOUT).ok().flatten()
}
/// Read the clipboard (the system's own reads don't wait on the app that copied).
#[cfg(not(all(unix, not(target_os = "macos"))))]
fn read_clipboard<T>(c: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option<T>) -> Option<T> {
read(c)
}
/// Native Save as filters; other callers keep their existing combined filter.
fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
if exts == ["figcraft", "fig"] {
vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])]
} else {
vec![("File", exts)]
}
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).add_filter("Supported files", exts).pick_file())
}
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file())
let mut dialog = dialog(title).set_file_name(name);
for (label, extensions) in save_filters(exts) {
dialog = dialog.add_filter(label, extensions);
}
picked(dialog.save_file())
}
fn pick_folder(&mut self, title: &str) -> DialogFuture {
picked(dialog(title).pick_folder())
@@ -239,7 +274,10 @@ impl Services for Desktop {
}
}
fn clipboard_get_text(&mut self) -> Option<String> {
self.clipboard.as_mut()?.get_text().ok()
read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok())
}
fn clipboard_get_html(&mut self) -> Option<String> {
read_clipboard(self.clipboard.as_mut()?, |c| c.get().html().ok())
}
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
if let Some(c) = self.clipboard.as_mut() {
@@ -247,7 +285,7 @@ impl Services for Desktop {
}
}
fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec<u8>)> {
let img = self.clipboard.as_mut()?.get_image().ok()?;
let img = read_clipboard(self.clipboard.as_mut()?, |c| c.get_image().ok())?;
let (w, h) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?);
// Refuse absurd sizes (and mismatched buffers) rather than allocate.
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > 100_000_000 || img.bytes.len() != (w as usize) * (h as usize) * 4 {
@@ -297,3 +335,24 @@ pub fn window_icon() -> Option<egui::IconData> {
let (w, h) = img.dimensions();
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })
}
#[cfg(test)]
mod save_filter_tests {
use super::save_filters;
#[test]
fn native_and_fig_save_filters_are_separate_with_native_first() {
let extensions = ["figcraft", "fig"];
assert_eq!(
save_filters(&extensions),
vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])]
);
}
#[test]
fn other_save_filters_keep_one_combined_group() {
for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] {
assert_eq!(save_filters(extensions), vec![("File", extensions)]);
}
}
}
+5
View File
@@ -44,3 +44,8 @@ sync_github_name = true
[people.volkovskey]
real_name = "Andrii Volkov"
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
+4 -2
View File
@@ -12,7 +12,9 @@ pub struct NetLimits {
/// before: its hello.
pub max_client_message: usize,
pub max_hello: usize,
/// Largest message a client accepts from the host (a welcome carries the whole document).
/// Largest message a client accepts from the host (a welcome carries the whole document): 1 GiB,
/// room for any file up to 1023 MiB and well past the servers' defaults, while a hostile server
/// (any link can name one) can't make the app or a browser tab buffer more than that.
pub max_server_message: usize,
/// Messages a client may send per second on average, and in a burst; beyond that it is
/// disconnected.
@@ -61,7 +63,7 @@ impl Default for NetLimits {
max_connections: 64,
max_client_message: figcraft_collab::Limits::default().max_message_bytes,
max_hello: 64 << 10,
max_server_message: figcraft_collab::Limits::default().max_message_bytes,
max_server_message: 1 << 30,
rate: 100.0,
burst: 400.0,
hello_timeout: Duration::from_secs(10),
+8 -2
View File
@@ -51,6 +51,10 @@ pub fn is_closed(n: &Node) -> bool {
}
}
/// How much further than `tol` a stroke with nothing filled reaches: a thin line or stroke-only path
/// is hard to land on with the plain tolerance, so it gets a wider band (5 screen px for the usual 2 px tolerance).
const STROKE_REACH: f64 = 2.5;
/// Whether `pt` hits `n` itself; in `outline` mode (nothing filled on the canvas) a shape only where
/// its outline is.
fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, outline: bool) -> bool {
@@ -62,7 +66,7 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Page { .. } => false,
NodeKind::Text(_) | NodeKind::Slice => n.local_rect().inflate(tol_l, tol_l).contains(lp),
NodeKind::Line => {
let half = (n.stroke.weight / 2.0).max(tol_l);
let half = (n.stroke.weight / 2.0).max(tol_l * STROKE_REACH);
lp.x >= -half && lp.x <= n.width + half && lp.y.abs() <= half
}
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
@@ -78,7 +82,9 @@ fn hits_path(n: &Node, path: &BezPath, lp: Point, tol: f64, inside: bool) -> boo
if inside && (n.fills.iter().any(|f| f.visible) || !n.has_visible_stroke()) {
return true;
}
let reach = (n.stroke.max_weight() / 2.0).max(tol);
// Only a stroke with nothing filled gets the wider band: a filled shape's edge keeps the plain one.
let stroke_only = n.has_visible_stroke() && !n.fills.iter().any(|f| f.visible);
let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol * STROKE_REACH } else { tol });
if !path.bounding_box().inflate(reach, reach).contains(lp) {
return false;
}
+44 -8
View File
@@ -215,6 +215,10 @@ pub struct ParaList {
pub list: ListStyle,
/// Nesting level (0 = outermost; plain paragraphs are always 0).
pub indent: u8,
/// A numbered list that starts counting at this paragraph counts from `start_offset + 1`
/// (items that go on counting ignore it; other paragraphs are always 0).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub start_offset: u32,
}
/// Character style. In runs, every field is an override; `None` means "inherit the base".
@@ -637,13 +641,13 @@ impl TextData {
/// Paragraph `p`'s list settings.
pub fn para_list(&self, p: usize) -> ParaList {
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 })
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() })
}
/// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries).
fn fill_paragraphs(&mut self) {
let n = self.paragraph_count();
let fill = ParaList { list: self.list, indent: 0 };
let fill = ParaList { list: self.list, ..ParaList::default() };
self.paragraphs.resize(n, fill);
}
@@ -657,17 +661,23 @@ impl TextData {
}
/// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain
/// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with
/// no nesting folds back into `list`.
/// paragraphs stay at level 0, only numbered ones keep a start, levels stop at
/// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`.
pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) {
let (a, b) = self.para_span(start, end);
self.fill_paragraphs();
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
f(pl);
pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) };
if pl.list == ListStyle::None {
pl.indent = 0;
}
if pl.list != ListStyle::Numbered {
pl.start_offset = 0;
}
pl.indent = pl.indent.min(MAX_LIST_INDENT);
}
if let Some(first) = self.paragraphs.first().copied()
&& first.indent == 0
&& first == (ParaList { list: first.list, ..ParaList::default() })
&& self.paragraphs.iter().all(|p| *p == first)
{
self.list = first.list;
@@ -675,11 +685,12 @@ impl TextData {
}
}
/// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ.
/// The list style and level shared by the paragraphs chars `start..end` touch (the first
/// one's settings), or None when they differ. Start numbers don't count.
pub fn common_para_list(&self, start: usize, end: usize) -> Option<ParaList> {
let (a, b) = self.para_span(start, end);
let first = self.para_list(a);
(a..=b).all(|p| self.para_list(p) == first).then_some(first)
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
}
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
@@ -782,4 +793,29 @@ mod case_tests {
assert_eq!(st.variations.keys().collect::<Vec<_>>(), ["wdth"]);
assert!(valid_axis_tag("wght") && !valid_axis_tag("weight"));
}
#[test]
fn list_starts_load_from_old_files_and_follow_edits() {
use ListStyle::{Bulleted as B, None as P, Numbered as N};
// Files written before start numbers load with none, and write the same bytes.
let old = r#"{"list":"numbered","indent":1}"#;
let pl: ParaList = serde_json::from_str(old).unwrap();
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 });
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
let mut t = TextData { characters: "Next".into(), ..Default::default() };
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 });
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 });
// Enter copies it to the new item; different starts don't make the list mixed.
t.replace_range(4, 4, "\nMore");
assert_eq!(t.para_list(1).start_offset, 1);
t.set_para_lists(5, 5, |p| p.start_offset = 0);
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
// Bullets have no number, so no start.
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 });
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 });
// A plain paragraph has none.
t.set_para_lists(0, 0, |p| p.list = P);
assert_eq!(t.para_list(0), ParaList::default());
}
}
+3 -3
View File
@@ -468,9 +468,9 @@ impl VectorNetwork {
start_dir(&self.outward(seg, at_a)?)
}
/// Whether any point has a stroke cap or join of its own.
pub fn has_point_styles(&self) -> bool {
self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some())
/// Whether any point has a stroke cap of its own or a join other than the layer's `layer_join`.
pub fn has_point_styles(&self, layer_join: StrokeJoin) -> bool {
self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|j| j != layer_join))
}
/// The fill path: every region's loops.
+1 -1
View File
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
let cmd = "instance.create";
let c = req_id(cmd, p, "component")?;
let page = s.page()?;
let parent = id(p, "parent").unwrap_or(page);
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
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 (x, y) = match (num(p, "x"), num(p, "y")) {
+3 -3
View File
@@ -167,7 +167,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?;
let page = s.page()?;
let parent = id(p, "parent").unwrap_or(page);
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let doc = s.doc()?;
let pn = doc.get(parent)?;
if !pn.kind.is_container() {
@@ -255,7 +255,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
node.width = tw;
node.height = th;
}
let index = num(p, "index").map(|i| i.max(0.0) as usize);
let index = crate::params::opt_index(cmd, p, "index")?;
doc.insert(node, parent, index)?;
if let Some(Value::Object(props)) = get(p, "props") {
super::props::apply(cmd, doc, nid, props)?;
@@ -340,7 +340,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
_ => ImageScaleMode::Fill,
};
let page = s.page()?;
let parent = id(p, "parent").unwrap_or(page);
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let ds = s.ds_mut()?;
let doc = ds.doc_mut();
let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
+86 -16
View File
@@ -25,6 +25,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("edit.copy", "Copy", "{ids?}", false, copy),
spec!("edit.cut", "Cut", "{ids?}", true, cut),
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
spec!(
"edit.pasteDesignLayers",
"Paste design layers",
"{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes",
true,
paste_design_layers
),
spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over),
spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace),
spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props),
@@ -496,9 +503,27 @@ fn bump_name(n: &str) -> String {
}
/// The copied form of the selection.
pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> {
pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> {
let ds = s.ds()?;
let doc = &ds.doc;
// Pages are not layers: they can't be pasted anywhere.
let layers: Vec<NodeId> = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect();
if layers.is_empty() && !ids.is_empty() {
return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into()));
}
let (nodes, images, bounds) = copied(&ds.doc, &layers);
let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid);
s.clipboard.source = Some(source);
s.clipboard.source_path = source_path;
s.clipboard.source_doc = uid;
s.clipboard.nodes = nodes;
s.clipboard.images = images;
s.clipboard.bounds = bounds;
Ok(())
}
type Copied = (Vec<(NodeId, Vec<Node>)>, BTreeMap<String, ImageData>, Option<figcraft_geom::Rect>);
fn copied(doc: &Document, ids: &[NodeId]) -> Copied {
let ids = z_sorted(doc, &roots(doc, ids));
let mut nodes = Vec::new();
let mut images = BTreeMap::new();
@@ -521,28 +546,73 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> {
nodes.push((*i, sub));
}
let bounds = doc.bounds_of(&ids);
let uid = ds.uid;
let source = Some(ds.doc.clone());
let source_path = ds.path.clone();
s.clipboard.source = source;
s.clipboard.source_path = source_path;
s.clipboard.nodes = nodes;
s.clipboard.images = images;
s.clipboard.bounds = bounds;
s.clipboard.source_doc = uid;
Ok(())
(nodes, images, bounds)
}
/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them.
pub const CLIP_OPEN: &str = "(figma)";
pub const CLIP_CLOSE: &str = "(/figma)";
/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its
/// base64 is checked first, so neither the text nor the decoded bytes can pass it.
const MAX_CLIP_DATA: usize = 256 * 1024 * 1024;
const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4;
/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`].
fn clip_base64(text: &str) -> Option<Vec<u8>> {
use base64::Engine as _;
if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 {
return None;
}
let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect();
base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok()
}
/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in
/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML
/// holds no readable design data (it may just mention the marker).
pub fn design_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len();
let len = html.get(start..)?.find(CLIP_CLOSE)?;
let bytes = clip_base64(html.get(start..start + len)?)?;
figcraft_format::fig::is_fig(&bytes).then_some(bytes)
}
fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.pasteDesignLayers";
let bytes = match (string(p, "html"), string(p, "data")) {
(Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?,
(None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?,
(None, None) => return Err(bad(CMD, "pass `html` or `data`")),
};
let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??;
let doc = std::sync::Arc::new(other.doc);
let tops: Vec<NodeId> = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect();
if tops.is_empty() {
return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into()));
}
let (nodes, images, bounds) = copied(&doc, &tops);
let props = s.clipboard.props.take();
// No open file has id 0. So paste treats this copy as a copy from a different file.
s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None };
let mut out = paste(s, p)?;
if !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
}
Ok(out)
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("edit.copy", s, p)?;
copy_to_clipboard(s, &ids)?;
copy_to_clipboard(s, "edit.copy", &ids)?;
Ok(json!({"copied": s.clipboard.nodes.len()}))
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("edit.cut", s, p)?;
copy_to_clipboard(s, &ids)?;
delete(s, &json!({"ids": ids}))
copy_to_clipboard(s, "edit.cut", &ids)?;
let doc = s.doc()?;
let layers: Vec<NodeId> = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect();
delete(s, &json!({"ids": layers}))
}
/// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords).
@@ -1177,7 +1247,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result<Valu
fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
let cmd = if fills { "fill.set" } else { "stroke.set" };
let ids = req_targets(cmd, s, p)?;
let idx = num(p, "index").map(|x| x.max(0.0) as usize);
let idx = opt_index(cmd, p, "index")?;
let new_paint = match get(p, "paint") {
Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?),
None => None,
+181 -13
View File
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.open",
"Open",
"{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`",
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
false,
open
),
@@ -41,8 +41,20 @@ pub fn specs() -> Vec<CommandSpec> {
false,
open_recent
),
spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save),
spec!("file.saveAs", "Save as", "{path}", false, save_as),
spec!(
"file.save",
"Save",
"{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
false,
save
),
spec!(
"file.saveAs",
"Save as",
"{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
false,
save_as
),
spec!(
"file.export",
"Export",
@@ -74,7 +86,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 and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`)",
"{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 and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
true,
import
),
@@ -461,6 +473,11 @@ fn file_stem(path: &str) -> String {
std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string()
}
/// The counts the import report shows for a freshly loaded document.
fn import_summary(doc: &Document) -> Value {
json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()})
}
/// Load a document from any supported file (REST API file JSON images come from an `images`
/// folder next to the file).
pub fn load(path: &str) -> Result<(Document, Vec<String>)> {
@@ -506,10 +523,8 @@ fn load_with(
Ok((doc.into(), false))
}
_ => {
if let Some(r) = figcraft_format::fig::try_import(bytes, &file_stem(name_hint)) {
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
link_instances(&mut doc);
return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
if let Some(r) = load_fig(bytes, &file_stem(name_hint)) {
return Ok((r?, true));
}
if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) {
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
@@ -525,10 +540,20 @@ fn load_with(
}
}
/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try.
pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
let r = figcraft_format::fig::try_import(bytes, name)?;
Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| {
link_instances(&mut doc);
figcraft_format::Loaded { issues: warnings, ..doc.into() }
}))
}
/// 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) {
crate::style_vars::after_load(doc);
super::styles::apply_paint_bindings_on_load(doc);
let mut insts: Vec<(usize, NodeId)> =
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect();
insts.sort_by_key(|a| std::cmp::Reverse(a.0));
@@ -653,8 +678,13 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = req_string("file.open", p, "path")?;
let variables = variables_param("file.open", p)?;
if string(p, "path").is_none()
&& let Some(d) = string(p, "data")
{
return open_data(s, &d, p, variables.as_ref());
}
let path = req_string("file.open", p, "path")?;
// Already open: switch to it.
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
s.active = i;
@@ -672,9 +702,26 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]),
});
}
let summary = import_summary(&loaded.doc);
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
s.active = s.docs.len() - 1;
let mut out = json!({"index": s.active});
let mut out = json!({"index": s.active, "summary": summary});
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
Ok(out)
}
/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells
/// the format. It opens as a new document with no path, so the first save asks where to put it.
fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result<Value> {
let name = req_string("file.open", p, "name")?;
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 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;
let mut out = json!({"index": s.active, "summary": summary});
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
Ok(out)
}
@@ -775,8 +822,20 @@ fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"profile": profile.id(), "changed": changed}))
}
/// Save the file of the active tab (the main file for a branch tab, with its branches).
/// Native saves act on the current document; a `.fig` copy exports the version shown, and a
/// pending Save As choice must leave it shown until its destination is known.
pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool {
let path =
string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None });
path.is_some_and(|path| ext(&path) != "fig")
}
/// Save the file of the active tab (the main file for a branch tab, with its branches), or export
/// the active document as an experimental `.fig` copy without changing its native save state.
fn save_to(s: &mut Session, path: String) -> Result<Value> {
if ext(&path) == "fig" {
return export_fig(s, &json!({"path": path}));
}
let i = crate::branches::file_tab(s);
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
let bytes = file_bytes(ds)?;
@@ -795,6 +854,11 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
return save_to(s, path);
}
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
// Earlier builds wrote native data under a .fig name: never overwrite that file with an export.
Some(path) if ext(&path) == "fig" => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))
}
Some(path) => save_to(s, path),
None => {
s.request_ui("dialog.saveAs", json!({}));
@@ -1215,8 +1279,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
};
let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None };
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)?;
let mut out = json!({"pages": pages});
let mut out = json!({"pages": pages, "summary": summary});
if rest {
out["warnings"] = json!(other.issues);
}
@@ -1252,6 +1317,19 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
map.insert(id, doc.alloc_id());
}
}
// Library copies a .fig import keeps (cached definitions) have no library record to follow:
// the layers of its store come along into the store of `doc`. Recorded copies are left to
// `merge_store`, which shares them with copies `doc` already has.
let cached: Vec<NodeId> = other
.library_store()
.and_then(|s| other.node(s))
.map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect())
.unwrap_or_default();
for root in &cached {
for id in other.subtree(*root) {
map.insert(id, doc.alloc_id());
}
}
let mut new_pages = Vec::new();
for pg in &other.pages {
let Some(pn) = other.node(*pg) else { continue };
@@ -1269,6 +1347,13 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
}
new_pages.push(np);
}
if !cached.is_empty() {
let store = doc.ensure_library_store();
for root in &cached {
let nodes: Vec<Node> = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect();
doc.paste_nodes(&nodes, &map, *root, store, None)?;
}
}
for new in map.values() {
let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied());
if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) {
@@ -1280,7 +1365,7 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
}
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let i = num(p, "index").map(|x| x as usize).unwrap_or(s.active);
let i = if get(p, "index").is_none() { s.active } else { req_index("file.close", p, "index")? };
let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) };
// A branch tab's edits are in its main file already (which then has unsaved changes).
if ds.branch_of.is_none() && ds.is_dirty() && !boolean(p, "force").unwrap_or(false) {
@@ -1334,6 +1419,70 @@ mod rest_import_tests {
doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap()
}
/// What a .fig import with a cached library component leaves: the main in the hidden store, a
/// visible instance of it and a layer linked to the main's layer.
fn cached_import() -> Document {
let mut doc = Document::new("Cached");
let page = doc.first_page().unwrap();
let store = doc.ensure_library_store();
let main = doc
.create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0)
.unwrap();
let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap();
let inst =
doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap();
let mut copy = doc.node(body).unwrap().clone();
copy.id = doc.alloc_id();
copy.parent = None;
copy.source = Some(body);
let copy_id = copy.id;
doc.insert(copy, inst, None).unwrap();
assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body));
doc
}
/// Pasting layers that use a cached main into another document leaves the main behind, as it
/// does for other mains that are not published: no link points at a layer of the target.
#[test]
fn pasting_instances_of_cached_components_leaves_no_dangling_links() {
let src = cached_import();
let mut doc = Document::new("Target");
let page = doc.first_page().unwrap();
for i in 0..8 {
doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap();
}
let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id;
let ids = src.subtree(inst);
let map: std::collections::BTreeMap<NodeId, NodeId> = ids.iter().map(|i| (*i, doc.alloc_id())).collect();
let nodes: Vec<Node> = ids.iter().filter_map(|i| src.node(*i).cloned()).collect();
doc.paste_nodes(&nodes, &map, inst, page, None).unwrap();
crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap();
let pasted = doc.node(map[&inst]).unwrap();
let NodeKind::Instance { component, .. } = pasted.kind else { panic!() };
assert!(!doc.contains(component), "the main stays behind: {component:?}");
assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s)));
}
#[test]
fn appending_pages_with_cached_library_components_brings_the_components() {
let other = cached_import();
let mut doc = Document::new("Target");
let page = doc.first_page().unwrap();
// Ids of the target that would collide with the other document's store.
for i in 0..8 {
doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap();
}
append_pages(&mut doc, &other).unwrap();
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc));
let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap();
let NodeKind::Instance { component, .. } = inst.kind else { panic!() };
assert_eq!(doc.node(component).unwrap().name, "Cached main");
assert!(doc.is_remote(component), "the main comes along into the store");
let layer = doc.node(inst.children[0]).unwrap();
assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied());
}
#[test]
fn rest_json_loads_with_linked_instances() {
let bytes = serde_json::to_vec(&kit()).unwrap();
@@ -1351,6 +1500,25 @@ mod rest_import_tests {
assert!(thumbnail(&doc).is_some());
}
#[test]
fn open_takes_data_and_a_name_as_a_new_unsaved_document() {
let mut saved = Session::new();
saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let bytes = file_bytes(saved.ds().unwrap()).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
let mut s = Session::new();
let recent = s.prefs.recent_files.clone();
let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap();
let open = s.docs.len();
assert_eq!(r["index"], open - 1, "a document of its own, made active");
assert_eq!(s.ds().unwrap().path, None, "no path to save over");
assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)));
assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember");
assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format");
assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err());
assert_eq!(s.docs.len(), open, "a refused open adds nothing");
}
#[test]
fn open_and_import_rest_json() {
let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id()));
+95 -24
View File
@@ -26,10 +26,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"object.convertToFrame",
"Convert to frame",
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)",
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping; sections become frames that keep their fill and don't clip. Frames are left alone; instances, main components, layers inside instances and sections holding sections are refused)",
true,
|s, p| convert(s, p, "object.convertToFrame", true)
),
spec!(
"object.convertToSection",
"Convert to section",
"{ids?} (frames on the page or in a section become sections in place, keeping their fill and layers; what only a frame has goes: auto layout, clipping, layout grids, guides and scrolling. Sections are left alone; nested frames, instances and main components are refused)",
true,
convert_to_section
),
spec!(
"object.wrapInSection",
"Wrap in new section",
@@ -199,6 +206,13 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
let n = doc.get(i)?;
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
match (&n.kind, to_frame) {
// A frame can't hold sections.
(NodeKind::Section { .. }, true)
if n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Section { .. }))) =>
{
return refuse("holds sections, which can't go inside a frame: convert those first");
}
(NodeKind::Section { .. }, true) => todo.push(i),
(NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"),
(NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"),
(NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => {
@@ -212,12 +226,15 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
}
for i in &todo {
if to_frame {
// The group's box as it is; nothing painted or clipped, so the look stays.
// The box as it is and nothing clipped, so the look stays: a group has nothing painted, a
// section keeps its fill.
let n = doc.get_mut(*i)?;
if matches!(n.kind, NodeKind::Group) {
n.fills.clear();
n.strokes.clear();
}
n.kind = NodeKind::Frame;
n.clip = false;
n.fills.clear();
n.strokes.clear();
continue;
}
let n = doc.get_mut(*i)?;
@@ -242,6 +259,43 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
Ok(json!({"ids": todo}))
}
/// Frames on the page or in a section become sections in place (sections only sit there).
fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "object.convertToSection";
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let mut todo = Vec::new();
for i in ids {
let n = doc.get(i)?;
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
let on_board = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }));
match &n.kind {
NodeKind::Section { .. } => {}
NodeKind::Instance { .. } => return refuse("is an instance: its layers come from its main component (detach it first)"),
NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => return refuse("is a main component, which stays a frame"),
NodeKind::Frame if !on_board => return refuse("is inside a frame or group: only frames on the page or in a section become sections"),
NodeKind::Frame => todo.push(i),
_ => return refuse("is not a frame"),
}
}
for i in &todo {
let n = doc.get_mut(*i)?;
n.kind = NodeKind::Section { contents_hidden: false };
(n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None);
n.grids.clear();
n.guides.clear();
n.styles.grid = None;
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
let kids = n.children.clone();
for k in kids {
// Out of the flow there is nothing to fill or to be absolute against.
doc.get_mut(k)?.child_layout.absolute = false;
}
figcraft_layout::coherent_sizing(doc, *i);
}
Ok(json!({"ids": todo}))
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.ungroup", s, p)?;
let doc = s.doc_mut()?;
@@ -309,33 +363,46 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
let index = doc.index_in_parent(top).map(|i| i + 1);
let pw = doc.world_transform(parent);
let mut path = BezPath::new();
let mut even = false;
let mut net = VectorNetwork::default();
for i in &ids {
let Some(n) = doc.node(*i) else { continue };
// Booleans flatten to their result; strokes don't get outlined.
let Some((mut op, rule)) = (match &n.kind {
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0),
_ => figcraft_render::node_outline(doc, n),
}) else {
continue;
// Vectors keep their own network (open paths and unfilled loops have no fill outline);
// booleans flatten to their result; strokes don't get outlined.
let mut part = match &n.kind {
NodeKind::Vector { network } => network.clone(),
_ => {
let Some((op, rule)) = (match &n.kind {
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
figcraft_render::subtree_outline(doc, n, 0)
}
_ => figcraft_render::node_outline(doc, n),
}) else {
continue;
};
let mut part = VectorNetwork::from_path(&op);
if rule == figcraft_render::FillRule::EvenOdd {
for r in &mut part.regions {
r.winding = WindingRule::EvenOdd;
}
}
part
}
};
even |= rule == figcraft_render::FillRule::EvenOdd;
// Into the common parent's space.
let w = doc.world_transform(*i);
op.apply_affine(pw.inverse() * w);
path.extend(op.elements().iter().copied());
let all: Vec<usize> = (0..part.vertices.len()).collect();
crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i));
let (nv, ns) = (net.vertices.len(), net.segments.len());
net.vertices.extend(part.vertices);
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
net.regions.extend(
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
);
}
if path.elements().is_empty() {
let Some(bb) = net.bounds() else {
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
}
};
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
let bb = path.bounding_box();
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let mut net = VectorNetwork::from_path(&path);
for r in &mut net.regions {
r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero };
}
net.translate(-bb.x0, -bb.y0);
let vid = doc.alloc_id();
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
@@ -556,6 +623,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
for i in req_targets("vector.offset", s, p)? {
let doc = s.doc_mut()?;
let n = doc.get(i)?.clone();
// A vector can't hold children: leave containers (frames, groups, instances) alone.
if !n.children.is_empty() {
continue;
}
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
+21
View File
@@ -913,6 +913,24 @@ pub fn apply_bindings(doc: &mut Document) {
/// [`apply_bindings`] for the layers `only` (every bound layer when `None`).
pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>) {
apply_bindings_matching(doc, only, |_| true);
}
/// Settle imported paint colours before the document is first shown. Other bindings can affect
/// layout or component structure, which must be resolved through the normal edit pipeline.
pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
let pending = doc.take_dirty();
apply_bindings_matching(doc, None, |field| {
super::lists::list_binding(field).is_some_and(|(list, _, prop)| {
matches!(list, super::lists::BoundList::Fills | super::lists::BoundList::Strokes)
&& (prop == "color" || super::lists::stop_binding(prop).is_some())
})
});
doc.take_dirty();
doc.mark_dirty(pending);
}
fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>, include: impl Fn(&str) -> bool) {
use super::lists::{BoundList, list_binding};
if doc.variables.variables.is_empty() {
return;
@@ -932,6 +950,9 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
let mut grid_sets: Vec<(usize, String, f64)> = Vec::new();
let mut prop_sets: Vec<(String, VariableValue)> = Vec::new();
for (field, var) in binds {
if !include(&field) {
continue;
}
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
// One list item's value: a paint's colour, an effect's or a layout grid's field.
if let Some((list, i, prop)) = list_binding(&field) {
+1 -1
View File
@@ -332,7 +332,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"text.preview",
"Preview text change",
"{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step",
"{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step",
false,
|s, p| {
let c = req_string("text.preview", p, "command")?;
+203 -23
View File
@@ -1710,6 +1710,165 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
None
}
/// Bounded source and parent lookups while a rebase reads an unchanged document.
struct RebaseSources<'a> {
doc: &'a Document,
chains: BTreeMap<NodeId, Option<Vec<NodeId>>>,
parents: BTreeMap<(NodeId, NodeId), Option<bool>>,
main_changes: BTreeMap<NodeId, bool>,
}
impl<'a> RebaseSources<'a> {
const PARENTS: usize = 512;
const CACHE: usize = 8192;
fn new(doc: &'a Document) -> Self {
Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() }
}
fn chain(&mut self, source: NodeId) -> Option<Vec<NodeId>> {
if let Some(chain) = self.chains.get(&source) {
return chain.clone();
}
let read = || {
let mut chain = Vec::new();
let mut seen = BTreeSet::new();
let mut current = Some(source);
for _ in 0..MAX_DEPTH {
let Some(id) = current else { return Some(chain) };
if !seen.insert(id) {
return None;
}
let n = self.doc.node(id)?;
chain.push(id);
current = n.source;
}
current.is_none().then_some(chain)
};
let chain = read();
if self.chains.len() < Self::CACHE {
self.chains.insert(source, chain.clone());
}
chain
}
/// Validate the complete parent chain, even if `main` was found before a cycle.
fn in_main(&mut self, id: NodeId, main: NodeId) -> Option<bool> {
if let Some(found) = self.parents.get(&(id, main)) {
return *found;
}
let read = || {
let mut current = Some(id);
let mut seen = BTreeSet::new();
let mut found = false;
for _ in 0..Self::PARENTS {
let Some(id) = current else { return Some(found) };
if !seen.insert(id) {
return None;
}
found |= id == main;
current = self.doc.node(id)?.parent;
}
current.is_none().then_some(found)
};
let found = read();
if self.parents.len() < Self::CACHE {
self.parents.insert((id, main), found);
}
found
}
fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option<NodeId> {
let chain = self.chain(source)?;
if chain.contains(&target) {
return None;
}
let mut member = None;
for id in chain {
if self.in_main(id, main)? && id != main && member.is_none() {
member = Some(id);
}
}
member
}
/// Nearest enclosing instances and their current mains. Slots with own content end scope.
fn owners(&self, id: NodeId) -> Option<Vec<(NodeId, NodeId)>> {
let mut owners = Vec::new();
let mut current = self.doc.node(id)?.parent;
let mut seen = BTreeSet::from([id]);
for _ in 0..Self::PARENTS {
let Some(id) = current else { return Some(owners) };
if !seen.insert(id) {
return None;
}
let n = self.doc.node(id)?;
if holds_content(n) {
return Some(owners);
}
if let NodeKind::Instance { component, .. } = &n.kind {
owners.push((id, *component));
}
current = n.parent;
}
current.is_none().then_some(owners)
}
fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main)
}
/// A contextual child belongs to the same placed slot as its enclosing instance.
fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
let Some((owner, main)) = owners.first() else { return false };
if self.member(source, *main, target).is_none() {
return false;
}
if self.in_main(source, *main) == Some(true) {
return true;
}
let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false };
matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main)
&& self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner)
}
fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option<NodeId> {
let source = self.doc.node(id)?.source?;
if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame)
|| !self.valid(source, id, owners)
|| !self.contextual(source, owners)
{
return None;
}
Some(source)
}
fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool {
for (index, (id, main)) in owners.iter().enumerate() {
if *id == stop {
break;
}
let changed = if let Some(changed) = self.main_changes.get(id) {
*changed
} else {
let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) {
Some(NodeKind::Instance { component, .. }) => component != main,
Some(NodeKind::Frame) => true,
_ => false,
});
if self.main_changes.len() < Self::CACHE {
self.main_changes.insert(*id, changed);
}
changed
};
if changed {
return true;
}
}
false
}
}
/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing
/// values (after detaching an outer instance, restoring a main, or copying a nested instance out):
/// the look is kept exactly. Values a component property sets aren't overrides.
@@ -1722,22 +1881,28 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect()
};
let mut updates = Vec::new();
let mut sources = RebaseSources::new(doc);
let root_owners = sources.owners(inst).unwrap_or_default();
let placed_source = sources.placed(inst, &root_owners);
let mut contexts = vec![(inst, component)];
contexts.extend_from_slice(&root_owners);
for (id, path) in &mine {
let Some(n) = doc.node(*id) else { continue };
// Follow the source chain into the main first (exact), then fall back to the name path.
let mut main = None;
let mut cur = n.source;
for _ in 0..MAX_DEPTH {
let Some(c) = cur else { break };
if c != component && doc.contains(c) && doc.is_ancestor(component, c) {
main = Some(c);
break;
}
cur = doc.node(c).and_then(|x| x.source);
let owners = sources.owners(*id).unwrap_or_default();
let nearest_main = owners.first().map(|(_, main)| *main);
let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners));
let mut main = valid_source.and_then(|source| sources.member(source, component, *id));
// A swapped or Frame-to-Instance subtree is compared to its current main by the inner
// rebase. An outer name path must not rebind those children to the old expanded copies.
if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) {
updates.push((*id, valid_source, n.overrides.clone()));
continue;
}
let main = main.or_else(|| theirs.get(path).copied());
main = main
.or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some())));
let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main);
let mut overrides: BTreeSet<String> = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect();
if let Some(m) = main.and_then(|m| doc.node(m)) {
if let Some(m) = source.and_then(|source| doc.node(source)) {
let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect();
bound.extend(variable_groups(n, m));
overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str())));
@@ -1745,36 +1910,51 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
overrides.insert("component".into());
}
}
updates.push((*id, main, overrides));
updates.push((*id, source, overrides));
}
for (id, main, overrides) in updates {
if let Some(n) = doc.node_mut(id) {
n.source = main;
n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
}
}
let root_groups = match (doc.node(component), doc.node(inst)) {
// A placed nested root compares to its actual slot. A detached root compares to its own main.
let root_base = placed_source.or_else(|| {
let chain = sources.chain(component)?;
(!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component)
});
let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) {
(Some(m), Some(n)) => {
let from_variables = variable_groups(n, m);
let mut groups: Vec<String> = value_groups(m, n)
.into_iter()
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
.collect();
// A layer name of its own (a nested instance renamed in its old main) stays.
if n.name != default_instance_name(doc, component) {
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
if named {
groups.push("name".into());
}
if placed_source.is_some()
&& matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b)
{
groups.push("component".into());
}
groups
}
_ => Vec::new(),
};
drop(sources);
for (id, main, overrides) in updates {
if let Some(n) = doc.node_mut(id) {
n.source = main;
n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
}
}
if let Some(n) = doc.node_mut(inst) {
n.source = Some(component);
n.source = root_base;
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position"));
n.overrides.extend(root_groups);
}
}
#[cfg(test)]
#[path = "components_rebase_source_tests.rs"]
mod rebase_source_tests;
/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
/// the left-most).
pub fn default_variant(doc: &Document, set: NodeId) -> Option<NodeId> {
@@ -0,0 +1,244 @@
//! Contextual import sources survive rebasing; detached and swapped subtrees use their current mains.
use super::*;
fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId {
doc.create(parent, kind, name, 0.0, 0.0, 20.0, 20.0).unwrap()
}
fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId {
add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name)
}
fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId {
let mut n = doc.node(source).unwrap().clone();
let children = std::mem::take(&mut n.children);
n.id = doc.alloc_id();
n.parent = None;
n.source = Some(source);
let id = n.id;
doc.insert(n, parent, None).unwrap();
for child in children {
mirror(doc, child, id);
}
id
}
fn instance(doc: &mut Document, main: NodeId, parent: NodeId, name: &str) -> NodeId {
let mut n = doc.node(main).unwrap().clone();
let children = std::mem::take(&mut n.children);
n.id = doc.alloc_id();
n.kind = NodeKind::Instance { component: main, props: BTreeMap::new() };
n.name = name.into();
n.parent = None;
n.source = None;
let id = n.id;
doc.insert(n, parent, None).unwrap();
for child in children {
mirror(doc, child, id);
}
id
}
struct Scene {
doc: Document,
inner: NodeId,
base: NodeId,
slot: NodeId,
context: NodeId,
root: NodeId,
nested: NodeId,
child: NodeId,
}
fn scene() -> Scene {
let mut doc = Document::new("Source context");
let page = doc.first_page().unwrap();
let inner = component(&mut doc, page, "Inner");
let base = add(&mut doc, inner, NodeKind::Rectangle, "Body");
doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0));
let outer = component(&mut doc, page, "Outer");
let slot = instance(&mut doc, inner, outer, "Nested");
doc.node_mut(slot).unwrap().source = Some(inner);
let context = doc.node(slot).unwrap().children[0];
doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0));
doc.node_mut(context).unwrap().opacity = 0.3;
let root = instance(&mut doc, outer, page, "Outer copy");
let nested = doc.node(root).unwrap().children[0];
let child = doc.node(nested).unwrap().children[0];
doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0));
doc.node_mut(child).unwrap().opacity = 0.2;
Scene { doc, inner, base, slot, context, root, nested, child }
}
fn both(s: &mut Scene) {
rebase_instance(&mut s.doc, s.nested);
rebase_instance(&mut s.doc, s.root);
}
#[test]
fn rebase_default_only_sources_promote_to_the_actual_outer_context() {
let mut s = scene();
s.doc.node_mut(s.nested).unwrap().source = None;
s.doc.node_mut(s.child).unwrap().source = None;
both(&mut s);
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
}
#[test]
fn rebase_rejects_a_context_source_from_another_nested_slot() {
let mut s = scene();
let outer = s.doc.node(s.slot).unwrap().parent.unwrap();
let sibling = instance(&mut s.doc, s.inner, outer, "Other nested slot");
s.doc.node_mut(sibling).unwrap().source = Some(s.inner);
let sibling_child = s.doc.node(sibling).unwrap().children[0];
mirror(&mut s.doc, sibling, s.root);
s.doc.node_mut(s.child).unwrap().source = Some(sibling_child);
both(&mut s);
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
assert_eq!(s.doc.node(sibling_child).unwrap().transform.translation().x, 2.0);
assert_eq!(s.doc.node(s.context).unwrap().transform.translation().x, 3.0);
}
#[test]
fn rebase_does_not_mark_inherited_context_values_as_local_overrides() {
let mut s = scene();
s.doc.node_mut(s.child).unwrap().opacity = 0.3;
both(&mut s);
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
assert!(!s.doc.node(s.child).unwrap().overrides.contains("opacity"));
}
#[test]
fn rebase_preserves_multilevel_context_without_shortening_source_chains() {
let mut doc = Document::new("Nested source contexts");
let page = doc.first_page().unwrap();
let inner = component(&mut doc, page, "Inner");
let base = add(&mut doc, inner, NodeKind::Rectangle, "Body");
doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0));
let wrapper = component(&mut doc, page, "Wrapper");
let default_slot = instance(&mut doc, inner, wrapper, "Nested");
doc.node_mut(default_slot).unwrap().source = Some(inner);
let outer = component(&mut doc, page, "Outer");
let wrapper_slot = instance(&mut doc, wrapper, outer, "Wrapper slot");
doc.node_mut(wrapper_slot).unwrap().source = Some(wrapper);
let source_slot = doc.node(wrapper_slot).unwrap().children[0];
let context = doc.node(source_slot).unwrap().children[0];
doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0));
let root = instance(&mut doc, outer, page, "Outer copy");
let wrapper_copy = doc.node(root).unwrap().children[0];
let nested = doc.node(wrapper_copy).unwrap().children[0];
let child = doc.node(nested).unwrap().children[0];
doc.node_mut(nested).unwrap().name = "Renamed nested".into();
doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0));
for id in [nested, wrapper_copy, root] {
rebase_instance(&mut doc, id);
assert_eq!(doc.node(nested).unwrap().source, Some(source_slot));
assert_eq!(doc.node(child).unwrap().source, Some(context));
}
assert_eq!(doc.node(context).unwrap().source, Some(doc.node(default_slot).unwrap().children[0]));
assert_eq!(doc.node(context).unwrap().transform.translation().x, 3.0);
assert_eq!(doc.node(child).unwrap().transform.translation().x, 2.0);
}
#[test]
fn rebase_preserves_placed_frame_to_instance_and_current_main_children() {
let mut doc = Document::new("Typed nested source");
let page = doc.first_page().unwrap();
let inner = component(&mut doc, page, "Inner");
let body = add(&mut doc, inner, NodeKind::Rectangle, "Body");
let outer = component(&mut doc, page, "Outer");
let slot = add(&mut doc, outer, NodeKind::Frame, "Empty slot");
let root = instance(&mut doc, outer, page, "Outer copy");
let nested = doc.node(root).unwrap().children[0];
doc.node_mut(nested).unwrap().kind = NodeKind::Instance { component: inner, props: BTreeMap::new() };
let child = mirror(&mut doc, body, nested);
rebase_instance(&mut doc, nested);
rebase_instance(&mut doc, root);
assert_eq!(doc.node(nested).unwrap().source, Some(slot));
assert_eq!(doc.node(child).unwrap().source, Some(body));
assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component, .. } if component == inner));
}
#[test]
fn rebase_keeps_current_main_sources_and_component_delta_beneath_swap() {
let mut s = scene();
let page = s.doc.first_page().unwrap();
let replacement = component(&mut s.doc, page, "Replacement");
let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body");
s.doc.node_mut(body).unwrap().transform = Affine::translate((7.0, 0.0));
s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() };
s.doc.remove(s.child).unwrap();
s.child = mirror(&mut s.doc, body, s.nested);
s.doc.node_mut(s.child).unwrap().transform = Affine::translate((8.0, 0.0));
s.doc.node_mut(s.nested).unwrap().name = "Renamed swap".into();
rebase_instance(&mut s.doc, s.nested);
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
assert!(s.doc.node(s.nested).unwrap().overrides.contains("component"));
rebase_instance(&mut s.doc, s.root);
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
assert_eq!(s.doc.node(s.child).unwrap().source, Some(body));
assert!(s.doc.node(s.nested).unwrap().overrides.contains("component"));
assert_eq!(s.doc.node(s.child).unwrap().transform.translation().x, 8.0);
}
#[test]
fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() {
let mut s = scene();
let page = s.doc.first_page().unwrap();
let detached = mirror(&mut s.doc, s.nested, page);
let child = s.doc.node(detached).unwrap().children[0];
// The old source references still exist, but their Outer context is absent here.
s.doc.node_mut(detached).unwrap().source = Some(s.slot);
s.doc.node_mut(child).unwrap().source = Some(s.context);
s.doc.node_mut(child).unwrap().transform = Affine::translate((3.0, 0.0));
rebase_instance(&mut s.doc, detached);
assert_eq!(s.doc.node(detached).unwrap().source, Some(s.inner));
assert_eq!(s.doc.node(child).unwrap().source, Some(s.base));
assert_eq!(s.doc.node(child).unwrap().transform.translation().x, 3.0);
assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0);
}
#[test]
fn rebase_does_not_preserve_existing_foreign_or_stale_sources() {
let mut s = scene();
let page = s.doc.first_page().unwrap();
let foreign = component(&mut s.doc, page, "Foreign");
let foreign_body = add(&mut s.doc, foreign, NodeKind::Rectangle, "Body");
s.doc.node_mut(s.child).unwrap().source = Some(foreign_body);
both(&mut s);
assert_ne!(s.doc.node(s.child).unwrap().source, Some(foreign_body));
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
let mut s = scene();
let page = s.doc.first_page().unwrap();
let replacement = component(&mut s.doc, page, "Replacement");
let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body");
s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() };
s.doc.node_mut(s.child).unwrap().source = Some(s.context);
both(&mut s);
assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context), "the old default-main child is stale after a swap");
assert!(s.doc.node(s.child).unwrap().source.is_none() || s.doc.node(s.child).unwrap().source == Some(body));
}
#[test]
fn rebase_rejects_source_and_parent_cycles_with_bounded_lookups() {
let mut s = scene();
s.doc.node_mut(s.base).unwrap().source = Some(s.context);
both(&mut s);
assert_eq!(s.doc.node(s.child).unwrap().source, None);
let mut s = scene();
s.doc.node_mut(s.context).unwrap().parent = Some(s.context);
both(&mut s);
assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context));
let mut s = scene();
s.doc.node_mut(s.nested).unwrap().parent = Some(s.child);
s.doc.node_mut(s.child).unwrap().parent = Some(s.nested);
let sources = RebaseSources::new(&s.doc);
assert!(sources.owners(s.child).is_none());
let mut s = scene();
s.doc.node_mut(s.root).unwrap().kind = NodeKind::Instance { component: s.root, props: BTreeMap::new() };
rebase_instance(&mut s.doc, s.root);
assert_ne!(s.doc.node(s.root).unwrap().source, Some(s.root));
}
+49 -3
View File
@@ -1,5 +1,5 @@
//! Frames and groups converted into each other in place: the layers stay where they are on the
//! page, what only a frame has goes with it, what can't convert is refused, one undo step.
//! Frames, groups and sections converted into each other in place: the layers stay where they are on
//! the page, what only a frame has goes with it, what can't convert is refused, one undo step.
use serde_json::{Value, json};
@@ -136,7 +136,7 @@ fn what_cannot_convert_is_refused_and_nothing_changes() {
#[test]
fn junk_parameters_are_refused_not_fatal() {
let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])];
for cmd in ["object.convertToGroup", "object.convertToFrame"] {
for cmd in ["object.convertToGroup", "object.convertToFrame", "object.convertToSection"] {
for j in &junk {
let mut s = Session::new();
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50})));
@@ -148,3 +148,49 @@ fn junk_parameters_are_refused_not_fatal() {
}
}
}
#[test]
fn a_frame_on_the_page_becomes_a_section_and_back() {
let mut s = Session::new();
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Flow", "props": {"fill": "#FFEEDD"}})));
let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 400.0, 90.0));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
ex(&mut s, "node.set", json!({"id": f, "props": {"width": 300, "height": 200}}));
let before = [page_box(&s, a), page_box(&s, b), page_box(&s, f)];
assert_eq!(ex(&mut s, "object.convertToSection", json!({"ids": [f]}))["ids"], json!([f]));
let n = s.doc().unwrap().node(f).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Section { contents_hidden: false }));
assert_eq!((n.name.as_str(), n.children.clone(), n.fills.len()), ("Flow", vec![a, b], 1), "same name, layers and fill");
assert!(n.layout.is_none() && !n.clip, "no auto layout or clipping on a section");
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before, "nothing moves");
// And back: a frame that keeps the fill and doesn't clip, so the look stays.
assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [f]}))["ids"], json!([f]));
let n = s.doc().unwrap().node(f).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && !n.clip);
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before);
// Each conversion is one undo step.
ex(&mut s, "edit.undo", json!({}));
assert!(matches!(kind(&s, f), NodeKind::Section { .. }));
ex(&mut s, "edit.undo", json!({}));
assert!(matches!(kind(&s, f), NodeKind::Frame));
assert!(s.doc().unwrap().node(f).unwrap().layout.is_some(), "the frame's auto layout is back");
}
#[test]
fn only_frames_on_the_page_or_in_a_section_become_sections() {
let mut s = Session::new();
let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300})));
let inner = id(ex(&mut s, "frame.create", json!({"parent": outer, "x": 10, "y": 10, "width": 100, "height": 100})));
let err = s.execute("object.convertToSection", &json!({"ids": [inner]})).unwrap_err().to_string();
assert!(err.contains("inside a frame"), "{err}");
let r = rect(&mut s, outer, 150.0, 150.0);
assert!(s.execute("object.convertToSection", &json!({"ids": [r]})).is_err(), "a rectangle isn't a frame");
// A frame inside a section converts; a section holding a section can't become a frame.
let sec = id(ex(&mut s, "node.create", json!({"type": "section", "x": 500, "y": 0, "width": 400, "height": 400})));
let in_sec = id(ex(&mut s, "frame.create", json!({"parent": sec, "x": 20, "y": 20, "width": 100, "height": 100})));
ex(&mut s, "object.convertToSection", json!({"ids": [in_sec]}));
assert!(matches!(kind(&s, in_sec), NodeKind::Section { .. }));
let err = s.execute("object.convertToFrame", &json!({"ids": [sec]})).unwrap_err().to_string();
assert!(err.contains("holds sections"), "{err}");
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
}
+108
View File
@@ -0,0 +1,108 @@
//! Paste of layers copied from another design app: the clipboard's design data (a `.fig` canvas in
//! its HTML) read through the `.fig` import and pasted as Paste pastes our own layers.
use base64::Engine as _;
use serde_json::json;
use super::*;
use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer};
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
fn id(v: &Value) -> NodeId {
params::id_of(&v["id"]).unwrap()
}
fn copied_file() -> Vec<u8> {
let mut s = Session::new();
let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap());
s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 40, "height": 20, "parent": frame, "name": "Badge"})).unwrap();
s.execute("node.create", &json!({"type": "ellipse", "x": 300, "y": 0, "width": 50, "height": 50, "name": "Dot"})).unwrap();
let r = s.execute("file.export", &json!({"format": "fig"})).unwrap();
B64.decode(r["data"].as_str().unwrap()).unwrap()
}
fn clipboard_html(buffer: &[u8]) -> String {
let mut wrapped = B64.encode(buffer);
wrapped.insert(8, '\n');
format!(r#"<meta charset="utf-8"><span data-buffer="<!--{CLIP_OPEN}{wrapped}{CLIP_CLOSE}-->"></span>"#)
}
fn names(s: &Session, ids: &[NodeId]) -> Vec<String> {
let doc = s.doc().unwrap();
ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect()
}
#[test]
fn buffer_is_found_in_the_html() {
let file = copied_file();
assert_eq!(design_clipboard_buffer(&clipboard_html(&file)), Some(file));
assert_eq!(design_clipboard_buffer("<p>plain</p>"), None);
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}not base64!{CLIP_CLOSE}")), None);
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{}{CLIP_CLOSE}", B64.encode(b"hello"))), None);
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}ZmlnLQ")), None);
// Web text that only mentions the markers holds no design data.
assert_eq!(design_clipboard_buffer(&format!("<p>see {CLIP_OPEN} and {CLIP_CLOSE} in the docs</p>")), None);
}
#[test]
fn oversized_design_data_is_refused_before_decoding() {
// Just past the cap (256 MB of data, as base64), made of valid base64 characters.
let big = "A".repeat((256 * 1024 * 1024usize).div_ceil(3) * 4 + 4);
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{big}{CLIP_CLOSE}")), None);
let mut s = Session::new();
let e = s.execute("edit.pasteDesignLayers", &json!({ "data": big })).unwrap_err();
assert!(e.to_string().contains("256 MB"), "{e}");
assert!(s.clipboard.nodes.is_empty());
}
#[test]
fn layers_paste_into_the_page_and_undo() {
let mut s = Session::new();
let page = s.page().unwrap();
let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap();
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect();
assert_eq!(names(&s, &ids), ["Card", "Dot"]);
assert_eq!(s.selection(), ids);
let doc = s.doc().unwrap();
assert!(ids.iter().all(|i| doc.node(*i).unwrap().parent == Some(page)));
let card = doc.node(ids[0]).unwrap();
assert_eq!(names(&s, &card.children), ["Badge"]);
assert_eq!((card.width, card.height), (200.0, 100.0));
assert!(s.undo().unwrap());
assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i)));
}
#[test]
fn a_second_paste_repeats_the_copied_layers() {
let mut s = Session::new();
// `data` may be wrapped and unpadded, like the HTML's base64.
let mut data = B64.encode(copied_file()).trim_end_matches('=').to_string();
data.insert(8, '\n');
s.execute("edit.pasteDesignLayers", &json!({ "data": data })).unwrap();
let r = s.execute("edit.paste", &json!({})).unwrap();
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect();
assert_eq!(names(&s, &ids), ["Card", "Dot"]);
}
#[test]
fn pastes_into_the_selected_frame() {
let mut s = Session::new();
let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap());
s.execute("select.set", &json!({"ids": [host]})).unwrap();
let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap();
let first = params::id_of(&r["ids"][0]).unwrap();
assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host));
}
#[test]
fn other_content_is_refused_without_changes() {
let mut s = Session::new();
let before = s.doc().unwrap().nodes().count();
assert!(s.execute("edit.pasteDesignLayers", &json!({})).is_err());
assert!(s.execute("edit.pasteDesignLayers", &json!({"html": "<b>hi</b>"})).is_err());
assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"PK not a zip")})).is_err());
assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err());
assert_eq!(s.doc().unwrap().nodes().count(), before);
assert!(s.clipboard.nodes.is_empty());
}
+40
View File
@@ -50,3 +50,43 @@ fn local_samples_match_their_exports() {
}
}
}
/// Layers of an instance copy keep their link to the layer they stand for when the file is
/// opened, also after they were renamed in the copy (a name is no identity), and the override
/// that a rename is stays an override.
#[test]
fn opened_fig_links_instance_layers_by_identity_not_by_name() {
use base64::Engine as _;
let mut s = Session::new();
let make = |s: &mut Session, cmd: &str, p: serde_json::Value| s.execute(cmd, &p).unwrap()["id"].as_u64().map(figcraft_doc::NodeId).unwrap();
let body = make(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Inner"}));
let label = make(&mut s, "text.create", json!({"text": "Label", "x": 2, "y": 2, "parent": body, "props": {"name": "Label"}}));
let inner = make(&mut s, "component.create", json!({"ids": [body]}));
let frame = make(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 200, "height": 60, "name": "Outer"}));
let outer = make(&mut s, "component.create", json!({"ids": [frame]}));
let slot = make(&mut s, "instance.create", json!({"component": inner, "x": 0, "y": 0, "parent": outer}));
s.execute("node.set", &json!({"id": slot, "props": {"name": "Slot"}})).unwrap();
let copy = make(&mut s, "instance.create", json!({"component": outer, "x": 0, "y": 200}));
let copy_slot = s.doc().unwrap().node(copy).unwrap().children[0];
let copy_label = s.doc().unwrap().node(copy_slot).unwrap().children[0];
s.execute("node.set", &json!({"id": copy_slot, "props": {"name": "Renamed slot"}})).unwrap();
s.execute("node.set", &json!({"id": copy_label, "props": {"name": "Renamed label"}})).unwrap();
let _ = label;
let exported = s.execute("file.export", &json!({"format": "fig"})).unwrap();
let path = std::env::temp_dir().join(format!("figcraft-slots-{}.fig", std::process::id()));
std::fs::write(&path, base64::engine::general_purpose::STANDARD.decode(exported["data"].as_str().unwrap()).unwrap()).unwrap();
let mut opened = Session::new();
let result = opened.execute("file.open", &json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_file(&path);
result.unwrap();
let doc = opened.doc().unwrap();
let outer = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Component { .. })).unwrap();
let copy = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Instance { .. })).unwrap();
let slot = doc.node(copy.children[0]).unwrap();
let label = doc.node(slot.children[0]).unwrap();
assert_eq!((slot.name.as_str(), label.name.as_str()), ("Renamed slot", "Renamed label"));
assert_eq!(slot.source, Some(outer.children[0]));
assert_eq!(label.source, Some(doc.node(outer.children[0]).unwrap().children[0]));
assert!(slot.overrides.contains("name") && label.overrides.contains("name"));
}
@@ -0,0 +1,40 @@
//! Commands never leave a layer with a non-finite position or size: such a file saves but can't be
//! reopened (JSON writes NaN and infinity as null).
use serde_json::json;
use crate::Session;
fn rect(s: &mut Session) -> crate::NodeId {
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 10, "width": 100, "height": 50})).unwrap();
crate::params::id_of(&r["id"]).unwrap()
}
fn assert_roundtrips(s: &Session) {
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
assert!(figcraft_format::load_bytes(&bytes).is_ok(), "the saved file reopens");
}
#[test]
fn huge_moves_are_refused_and_the_file_still_reopens() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 0})).ok();
let second = s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 1e308}));
assert!(second.is_err(), "a move past the f64 range is refused");
let n = s.doc().unwrap().node(r).unwrap();
assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{:?}", n.transform);
assert_roundtrips(&s);
}
#[test]
fn huge_scale_is_refused_and_the_file_still_reopens() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})).ok();
let second = s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300}));
assert!(second.is_err(), "a size past the f64 range is refused");
let n = s.doc().unwrap().node(r).unwrap();
assert!(n.width.is_finite() && n.height.is_finite(), "{} x {}", n.width, n.height);
assert_roundtrips(&s);
}
+28
View File
@@ -0,0 +1,28 @@
//! An absurd stroke weight must not take the renderer down with it: saving (thumbnail) and exporting still work.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::NodeId;
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
#[test]
fn huge_stroke_weight_with_round_caps_still_saves_and_exports() {
for weight in [1e6, 1e7, 1e10, 1e30] {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let v = id(s.execute("vector.create", &json!({"parent": f, "path": "M0 0.8333 L7.5 6.25 L15 0.8333"})).unwrap());
s.execute("node.set", &json!({"id": v, "props": {"stroke": "#ff0000", "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round"}}))
.unwrap();
let path = std::env::temp_dir().join(format!("figcraft-huge-stroke-{}-{weight}.figcraft", std::process::id()));
let saved = s.execute("file.saveAs", &json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_file(&path);
assert!(saved.is_ok(), "saveAs at weight {weight}: {:?}", saved.err());
let exported = s.execute("file.export", &json!({"ids": [f], "format": "png"}));
assert!(exported.is_ok(), "export at weight {weight}: {:?}", exported.err());
}
}
@@ -0,0 +1,161 @@
//! Layer ids in params: naming something that isn't a layer is a mistake, and says so, rather than
//! quietly meaning "none".
//!
//! `node.create`'s `parent` was `id(p, "parent").unwrap_or(page)`, so any value that isn't an id —
//! a `{"$step": n}` nothing had resolved, a name, a negative number — became "no parent" and the
//! layer landed on the page while the call reported success. The same shape sat on `image.place`
//! and `instance.create`'s parents, on `index`, and on the `ids` of every command that targets
//! layers (an entry that wasn't an id was dropped and the rest ran).
use crate::params::id_of;
use crate::{EngineError, Session};
use figcraft_doc::*;
use serde_json::{Value, json};
/// The page's children, by name (bottom first).
fn top(s: &Session) -> Vec<String> {
let doc = s.doc().unwrap();
let page = s.page().unwrap();
doc.node(page).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.clone())).collect()
}
/// The message of a failure, or `None` when the call went through.
fn failed(r: std::result::Result<Value, EngineError>) -> Option<String> {
match r {
Ok(_) => None,
Err(e) => Some(e.to_string()),
}
}
fn rect(parent: Value) -> Value {
json!({"type": "rectangle", "name": "child", "parent": parent, "x": 0, "y": 0, "width": 10, "height": 10})
}
fn card(s: &mut Session) -> NodeId {
let v = s.execute("node.create", &json!({"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
id_of(&v["id"]).unwrap()
}
#[test]
fn a_parent_that_is_not_an_id_is_refused_rather_than_becoming_the_page() {
let mut s = Session::new();
let card = card(&mut s);
for parent in [json!({"$step": 0}), json!("Card"), json!(-1), json!(true)] {
let msg = failed(s.execute("node.create", &rect(parent.clone()))).unwrap_or_else(|| panic!("{parent} must be refused"));
assert!(msg.contains("`parent` must be a node id"), "{parent}: {msg}");
}
assert_eq!(top(&s), ["Card"], "nothing landed on the page");
let doc = s.doc().unwrap();
assert!(doc.node(card).unwrap().children.is_empty(), "nor inside the frame");
}
#[test]
fn leaving_the_parent_out_or_null_still_means_the_page() {
let mut s = Session::new();
let card = card(&mut s);
for parent in [None, Some(json!(null))] {
let mut p = rect(json!(0));
match parent {
None => _ = p.as_object_mut().unwrap().remove("parent"),
Some(v) => p["parent"] = v,
}
s.execute("node.create", &p).unwrap();
}
assert_eq!(top(&s), ["Card", "child", "child"]);
let doc = s.doc().unwrap();
assert!(doc.node(card).unwrap().children.is_empty());
}
#[test]
fn a_parent_id_that_names_nothing_is_still_reported() {
let mut s = Session::new();
card(&mut s);
let msg = failed(s.execute("node.create", &rect(json!(99999)))).unwrap_or_else(|| panic!("a missing layer must fail"));
assert!(msg.contains("no such node 99999"), "{msg}");
}
#[test]
fn an_index_that_is_not_a_whole_non_negative_number_is_refused() {
let mut s = Session::new();
let card = card(&mut s);
// (A boolean is a number to `f64_of` — `true` reads as 1 — as for `x`, `width` and every
// other number, so it is deliberately not in this list.)
for index in [json!("last"), json!(1.5), json!(-1), json!(-0.5), json!(1e12)] {
let mut p = rect(json!(card));
p["index"] = index.clone();
let msg = failed(s.execute("node.create", &p)).unwrap_or_else(|| panic!("index {index} must be refused"));
assert!(msg.contains("`index`"), "{index}: {msg}");
}
let doc = s.doc().unwrap();
assert!(doc.node(card).unwrap().children.is_empty(), "nothing was inserted");
}
#[test]
fn a_whole_index_still_inserts_where_it_says() {
let mut s = Session::new();
let card = card(&mut s);
for name in ["a", "b"] {
s.execute("node.create", &json!({"type": "rectangle", "name": name, "parent": card, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
}
s.execute("node.create", &json!({"type": "rectangle", "name": "mid", "parent": card, "index": 1, "x": 0, "y": 0, "width": 10, "height": 10}))
.unwrap();
let doc = s.doc().unwrap();
let names: Vec<&str> = doc.node(card).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.as_str())).collect();
assert_eq!(names, ["a", "mid", "b"]);
}
#[test]
fn ids_entries_that_are_not_ids_are_refused_instead_of_being_dropped() {
let mut s = Session::new();
let card = card(&mut s);
// The junk used to be dropped and the rest applied, so `["nope", card]` renamed the frame.
for ids in [json!(["nope"]), json!(["nope", card]), json!([{"$step": 0}, card]), json!([null])] {
let msg =
failed(s.execute("node.set", &json!({"ids": ids, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("{ids} must be refused"));
assert!(msg.contains("`ids`"), "{ids}: {msg}");
}
let doc = s.doc().unwrap();
assert_eq!(doc.node(card).unwrap().name, "Card", "nothing was renamed");
}
#[test]
fn an_ids_that_is_not_an_array_is_refused_but_null_means_the_selection() {
let mut s = Session::new();
let card = card(&mut s);
let msg =
failed(s.execute("node.set", &json!({"ids": "nope", "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a non-array ids must fail"));
assert!(msg.contains("`ids` must be an array"), "{msg}");
// An explicit null says the same as leaving it out: the selection.
s.execute("select.set", &json!({"ids": [card]})).unwrap();
s.execute("node.set", &json!({"ids": null, "props": {"name": "Selected"}})).unwrap();
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Selected");
}
#[test]
fn a_single_id_that_is_not_an_id_is_refused() {
let mut s = Session::new();
card(&mut s);
let msg =
failed(s.execute("node.set", &json!({"id": {"$step": 0}, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a bad id must fail"));
assert!(msg.contains("`id` must be a node id"), "{msg}");
}
#[test]
fn an_image_and_an_instance_parent_are_checked_the_same_way() {
let mut s = Session::new();
card(&mut s);
let img = failed(s.execute("image.place", &json!({"data": "aGk=", "parent": {"$step": 0}})));
assert!(img.unwrap_or_default().contains("`parent` must be a node id"), "image.place");
let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}})));
assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent");
}
#[test]
fn a_huge_bad_value_is_cut_short_in_the_error() {
let mut s = Session::new();
card(&mut s);
let junk = "é".repeat(10_000);
let msg = failed(s.execute("node.set", &json!({"ids": junk, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("must fail"));
assert!(msg.contains("`ids` must be an array") && msg.ends_with("…)"), "{msg}");
assert!(msg.chars().count() < 200, "{} chars", msg.chars().count());
}
+51
View File
@@ -0,0 +1,51 @@
//! Imported file bytes already live in the document: the command log keeps no second copy.
use base64::Engine as _;
use serde_json::{Value, json};
use crate::Session;
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#3366cc"/></svg>"##;
fn payload() -> String {
base64::engine::general_purpose::STANDARD.encode(SVG)
}
fn holds_payload(journal: &[(String, Value)]) -> bool {
let payload = payload();
journal.iter().any(|(_, params)| params.to_string().contains(&payload))
}
#[test]
fn byte_imports_keep_no_payload_in_the_journal() {
let mut s = Session::new();
let before = s.doc().unwrap().node_count();
s.execute("file.import", &json!({"data": payload(), "name": "pasted.svg"})).unwrap();
assert!(!holds_payload(&s.journal), "the journal must not retain the Base64 file");
// Not journaling is no change to the edit itself: still one undo step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().node_count(), before);
}
#[test]
fn batch_wrappers_do_not_retain_import_payloads() {
let mut s = Session::new();
let nested =
json!({"command": "command.batch", "params": {"commands": [{"command": "file.import", "params": {"data": payload(), "name": "a.svg"}}]}});
s.execute("command.batch", &json!({"commands": [nested]})).unwrap();
assert!(!holds_payload(&s.journal));
}
#[test]
fn path_imports_and_ordinary_edits_are_still_journaled() {
let dir = std::env::temp_dir().join(format!("figcraft-import-journal-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("kit.svg");
std::fs::write(&path, SVG).unwrap();
let mut s = Session::new();
s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let ids: Vec<_> = s.journal.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, ["file.import", "shape.rectangle"]);
std::fs::remove_dir_all(&dir).ok();
}
+4 -1
View File
@@ -191,7 +191,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
}
// Lists: one style for the layer, or each paragraph's type and nesting level.
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
if t.paragraphs.is_empty() {
if t.list != figcraft_doc::ListStyle::None {
o.insert("listStyle".into(), json!(t.list));
@@ -199,6 +199,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
} else {
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
}
}
if t.list_spacing != 0.0 {
o.insert("listSpacing".into(), json!(t.list_spacing));
+85 -10
View File
@@ -357,8 +357,8 @@ pub struct DocState {
versions_changed: bool,
/// A version shown in place of the document (read-only).
pub preview: Option<VersionPreview>,
/// The document under a live text preview (a hovered text style or font instance, a dragged
/// axis slider): `doc` shows the preview until [`textpreview::end`] puts this back.
/// The document under a live text preview (a hovered font family or style, text style or
/// font instance, a dragged axis slider): `doc` shows the preview until [`textpreview::end`] puts this back.
pub text_preview: Option<Arc<Document>>,
/// The document as of the last version saved or restored (none yet): autosaves, restores and
/// merges only add a checkpoint for changes since.
@@ -390,7 +390,8 @@ pub struct VersionPreview {
/// Why edits are refused while a version is shown.
pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore it or go back to the current version to edit";
/// Commands that first end a version preview: they act on the document itself.
/// Commands that first end a version preview: they act on the document itself. Saving does so
/// only with a native destination; export copies and pending format choices keep the preview.
const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"];
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -401,6 +402,8 @@ impl DocState {
doc.repair();
}
doc.link_layer_text_styles();
cmd::styles::apply_paint_bindings_on_load(&mut doc);
// Initial import settlement belongs to the saved baseline, not the user's first edit.
let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0));
let doc = Arc::new(doc);
let mut st = Self {
@@ -652,8 +655,16 @@ pub struct Prefs {
pub home_sort: String,
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
pub home_order: String,
/// Recently run actions (the Actions palette's Recents), newest first, at most
/// [`MAX_RECENT_ACTIONS`]. Entries are `command:<id>`, `plugin:<plugin>/<command>` or
/// `widget:<plugin>`.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub recent_actions: Vec<String>,
}
/// Recently run actions kept for the Actions palette (newest first).
pub const MAX_RECENT_ACTIONS: usize = 5;
impl Default for Prefs {
fn default() -> Self {
Self {
@@ -681,6 +692,7 @@ impl Default for Prefs {
home_views: BTreeMap::new(),
home_sort: "modified".into(),
home_order: "desc".into(),
recent_actions: Vec::new(),
}
}
}
@@ -721,7 +733,8 @@ pub struct Session {
/// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no
/// theme, so what scripts make is the same everywhere.
pub ui_dark: Option<bool>,
/// Command log (id + params) for actions/automation.
/// Command log (id + params) for actions/automation. File imports that carry the file bytes
/// (`file.import` with `data`) are not logged, so the log never holds a copy of a pasted file.
pub journal: Vec<(String, Value)>,
/// Recently used colours.
pub recent_colors: Vec<figcraft_color::Color>,
@@ -746,6 +759,19 @@ impl Default for Session {
}
}
/// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`.
fn command_carries_import_bytes(id: &str, params: &Value) -> bool {
match id {
"file.import" => params::get(params, "data").is_some(),
"command.batch" => params.get("commands").and_then(Value::as_array).is_some_and(|commands| {
commands.iter().any(|c| {
c.get("command").and_then(Value::as_str).zip(c.get("params")).is_some_and(|(id, params)| command_carries_import_bytes(id, params))
})
}),
_ => false,
}
}
impl Session {
/// A session with one new document.
pub fn new() -> Self {
@@ -870,6 +896,7 @@ impl Session {
// Every other command acts on the document itself, not on a live text preview.
textpreview::end(self);
if ENDS_PREVIEW.contains(&id)
&& (!matches!(id, "file.save" | "file.saveAs") || cmd::file::save_ends_preview(self, id, params))
&& let Ok(ds) = self.ds_mut()
{
ds.end_preview();
@@ -878,16 +905,21 @@ impl Session {
let result = guard::run(id, || (spec.run)(self, params))
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
.and_then(|v| self.check_preview(id, &before).map(|()| v))
.and_then(|v| self.check_live_access(id, &before).map(|()| v));
.and_then(|v| self.check_live_access(id, &before).map(|()| v))
.and_then(|v| self.check_finite_geometry(id, &before).map(|()| v));
match &result {
Ok(_) => {
self.after_command(spec, before);
if spec.history && id != "edit.repeat" {
self.last_command = Some((id.to_string(), params.clone()));
}
self.journal.push((id.to_string(), params.clone()));
if self.journal.len() > 5000 {
self.journal.drain(..1000);
// Imported bytes already live in the document: a Base64 copy in the log would stay
// for the whole session.
if !command_carries_import_bytes(id, params) {
self.journal.push((id.to_string(), params.clone()));
if self.journal.len() > 5000 {
self.journal.drain(..1000);
}
}
}
Err(_) => {
@@ -916,6 +948,22 @@ impl Session {
Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into()))
}
/// A layer's position and size stay finite: infinity and NaN are written as `null`, so a file
/// holding them saves but can't be reopened. Refuse such an edit (it is rolled back).
fn check_finite_geometry(&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(()) };
if Arc::ptr_eq(&ds.doc, bdoc) {
return Ok(());
}
let broken = |n: &Node| !(n.transform.as_coeffs().iter().all(|c| c.is_finite()) && n.width.is_finite() && n.height.is_finite());
let untouched = |n: &Node| bdoc.node_arc(n.id).zip(ds.doc.node_arc(n.id)).is_some_and(|(a, b)| Arc::ptr_eq(a, b));
match ds.doc.nodes().find(|n| broken(n) && !untouched(n)) {
Some(n) => Err(bad(id, format!("\"{}\" would get a position or size beyond the number range", n.name))),
None => Ok(()),
}
}
/// A live session's document you may only view (or that is on its way to a server) is
/// read-only: refuse an edit (it is rolled back).
fn check_live_access(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
@@ -1135,13 +1183,22 @@ impl Session {
self.grouped(label, |s| {
let mut results: Vec<Value> = Vec::with_capacity(cmds.len());
for (step, (id, p)) in cmds.iter().enumerate() {
let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? };
results.push(s.execute(id, &p)?);
let v = s.batch_step(&results, step, id, p)?;
results.push(v);
}
Ok(results)
})
}
/// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of
/// the steps before it), then the command runs. [`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 `$step` the same way, inside its own [`Session::grouped`].
pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result<Value> {
let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? };
self.execute(cmd, &p)
}
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
/// 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> {
@@ -1500,6 +1557,8 @@ mod cursor_tests;
#[cfg(test)]
mod dark_page_text_tests;
#[cfg(test)]
mod design_paste_tests;
#[cfg(test)]
mod dev_resources_tests;
#[cfg(test)]
mod devcompare_tests;
@@ -1528,6 +1587,8 @@ mod fig_export_tests;
#[cfg(test)]
mod fig_import_tests;
#[cfg(test)]
mod finite_geometry_tests;
#[cfg(test)]
mod frame_guides_tests;
#[cfg(test)]
mod frame_label_tests;
@@ -1548,8 +1609,14 @@ mod history_behaviour_tests;
#[cfg(test)]
mod html_codegen_tests;
#[cfg(test)]
mod huge_stroke_tests;
#[cfg(test)]
mod id_params_behaviour_tests;
#[cfg(test)]
mod import_export_tests;
#[cfg(test)]
mod import_journal_tests;
#[cfg(test)]
mod inspect_behaviour_tests;
#[cfg(test)]
mod interaction_rollback_tests;
@@ -1590,10 +1657,14 @@ mod open_issues_tests;
#[cfg(test)]
mod outline_options_tests;
#[cfg(test)]
mod page_clipboard_tests;
#[cfg(test)]
mod page_copy_navigation_tests;
#[cfg(test)]
mod page_target_tests;
#[cfg(test)]
mod paint_index_tests;
#[cfg(test)]
mod paint_reorder_tests;
#[cfg(test)]
mod paint_tests;
@@ -1642,6 +1713,8 @@ mod rotated_select_tests;
#[cfg(test)]
mod rotation_origin_tests;
#[cfg(test)]
mod save_format_tests;
#[cfg(test)]
mod scale_tool_tests;
#[cfg(test)]
mod section_contents_tests;
@@ -1710,6 +1783,8 @@ mod vector_behaviour_tests;
#[cfg(test)]
mod vector_multi_behaviour_tests;
#[cfg(test)]
mod vector_offset_tests;
#[cfg(test)]
mod version_preview_tests;
#[cfg(test)]
mod version_tests;
+10 -3
View File
@@ -1221,9 +1221,16 @@ pub fn check_read_only(cmd: &str, before: &Document, after: &Document) -> std::r
if !variables_unchanged(before, after) {
return Err(READ_ONLY.into());
}
let Some(store) = after.library_store().or_else(|| before.library_store()) else { return Ok(()) };
let touched = |d: &Document, id: NodeId| d.contains(id) && d.page_of(id) == Some(store);
if after.dirty().iter().any(|id| touched(before, *id) || touched(after, *id)) {
let changed_remote = |id| {
if !before.is_remote(id) && !after.is_remote(id) {
return false;
}
match (before.node_arc(id), after.node_arc(id)) {
(Some(old), Some(new)) => !Arc::ptr_eq(old, new),
_ => true,
}
};
if after.dirty().iter().any(|id| changed_remote(*id)) {
return Err(READ_ONLY.into());
}
Ok(())
+46
View File
@@ -45,6 +45,38 @@ fn path_str(p: &std::path::Path) -> String {
p.to_string_lossy().into_owned()
}
fn dirty_library_document() -> (Document, NodeId) {
let mut doc = Document::new("Dirty library cache");
let store = doc.ensure_library_store();
let remote = doc.alloc_id();
doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap();
(doc, remote)
}
/// An imported cache is dirty until the first edit; that alone is no edit of the library copies.
#[test]
fn read_only_allows_unchanged_predirty_remote_nodes() {
let (before, remote) = dirty_library_document();
assert!(before.dirty().contains(&remote));
let mut after = before.clone();
after.add_page("Unrelated page");
assert!(after.dirty().contains(&remote), "the imported remote node remains in the pending dirty set");
check_read_only("page.add", &before, &after).unwrap();
let mut edited = before.clone();
edited.get_mut(remote).unwrap().opacity = 0.5;
assert_eq!(check_read_only("node.set", &before, &edited), Err(READ_ONLY.into()));
let mut moved = before.clone();
moved.reparent(remote, before.first_page().unwrap(), None).unwrap();
assert_eq!(check_read_only("node.reparent", &before, &moved), Err(READ_ONLY.into()));
let mut deleted = before.clone();
deleted.remove(remote).unwrap();
assert_eq!(check_read_only("edit.delete", &before, &deleted), Err(READ_ONLY.into()));
}
/// The library file's layers.
struct Kit {
button: NodeId,
@@ -179,8 +211,16 @@ fn add_place_and_update_keep_overrides() {
NodeKind::Instance { component, .. } => component,
_ => panic!("not an instance"),
};
let unchanged = s.doc().unwrap().clone();
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
assert!(e.to_string().contains("can't be edited"), "{e}");
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
for (cmd, args) in
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
{
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
}
let style = s.doc().unwrap().styles[0].id.clone();
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
@@ -281,10 +321,16 @@ fn library_styles_apply_and_import_with_pages() {
let copy = doc.nodes().find(|n| n.name == node(&s, inst).name && matches!(n.kind, NodeKind::Instance { .. }) && !doc.is_remote(n.id)).unwrap();
let NodeKind::Instance { component, .. } = copy.kind else { panic!("not an instance") };
assert!(doc.is_remote(component));
let store_roots = |s: &Session| {
let doc = s.doc().unwrap();
doc.library_store().and_then(|id| doc.node(id)).map_or(0, |n| n.children.len())
};
assert_eq!(store_roots(&t), 2, "recorded copies are not copied twice");
// A second import shares the copies it already has.
ex(&mut t, "file.import", json!({"path": path}));
healthy(&t);
assert_eq!(t.doc().unwrap().libraries.components.len(), 2);
assert_eq!(store_roots(&t), 2);
let _ = std::fs::remove_dir_all(&d);
}
+13
View File
@@ -49,3 +49,16 @@ fn an_intact_file_opens_without_a_message() {
assert!(s.message.is_none());
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn file_close_rejects_a_bad_index_instead_of_closing_another_document() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.new", &json!({})).unwrap();
let docs = s.docs.len();
for index in [json!(-1), json!(1.5), json!(99)] {
let r = s.execute("file.close", &json!({"index": index, "force": true}));
assert!(r.is_err(), "index {index} must be rejected, got {r:?}");
assert_eq!(s.docs.len(), docs, "index {index} closed a document");
}
}
+36
View File
@@ -0,0 +1,36 @@
//! `edit.copy` / `edit.cut` skip pages (they aren't layers and can't be pasted), and refuse a
//! selection of only pages under their own command id.
use serde_json::json;
use crate::{EngineError, Session};
#[test]
fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() {
for cmd in ["edit.copy", "edit.cut"] {
let mut s = Session::new();
let page = s.page().unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
match s.execute(cmd, &json!({"ids": [page.0]})) {
Err(EngineError::Disabled(id, _)) => assert_eq!(id, cmd),
r => panic!("{cmd} on a page: {r:?}"),
}
let _ = s.execute("edit.paste", &json!({}));
let doc = s.doc().unwrap();
assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate());
assert!(doc.node(page).is_some(), "{cmd} removed the page");
}
}
#[test]
fn cut_of_a_page_and_a_layer_moves_only_the_layer() {
let mut s = Session::new();
let page = s.page().unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let rect = crate::params::id_of(&r["id"]).unwrap();
s.execute("edit.cut", &json!({"ids": [page.0, rect.0]})).unwrap();
assert!(s.doc().unwrap().node(page).is_some());
assert!(s.doc().unwrap().node(rect).is_none());
s.execute("edit.paste", &json!({})).unwrap();
assert!(s.doc().unwrap().validate().is_empty());
}
+39
View File
@@ -0,0 +1,39 @@
//! `fill.set` / `stroke.set` reject a negative, fractional or non-finite `index` instead of
//! clamping or truncating it onto another paint (like `fill.remove` / `stroke.remove`).
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn paints(s: &Session, id: NodeId) -> String {
let n = s.doc().unwrap().node(id).unwrap();
format!("{:?} {:?}", n.fills, n.strokes)
}
#[test]
fn paint_set_rejects_bad_index_and_leaves_the_paints_alone() {
let mut s = Session::new();
let r = NodeId(ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap());
for add in ["fill.add", "stroke.add"] {
for c in ["#0000FF", "#FFFF00"] {
ex(&mut s, add, json!({"ids": [r], "color": c}));
}
}
let before = paints(&s, r);
for cmd in ["fill.set", "stroke.set"] {
for bad in [json!(-1), json!(0.5), json!(1.9), json!("-1"), json!("1.5"), json!("NaN"), json!("inf"), json!("abc")] {
let res = s.execute(cmd, &json!({"ids": [r], "index": bad, "color": "#FF0000"}));
assert!(res.is_err(), "{cmd} index {bad} was accepted: {res:?}");
assert_eq!(paints(&s, r), before, "{cmd} index {bad} changed the document");
}
ex(&mut s, cmd, json!({"ids": [r], "index": 1, "color": "#FF0000"}));
assert_ne!(paints(&s, r), before, "{cmd} with a valid index still edits");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(paints(&s, r), before);
}
}
+48 -1
View File
@@ -88,6 +88,36 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result<NodeId> {
id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id")))
}
/// The offending value for an error message, cut to a few dozen characters so a huge param
/// doesn't end up in the error whole.
fn shown(v: &Value) -> String {
const MAX: usize = 40;
let text = v.to_string();
match text.char_indices().nth(MAX) {
Some((cut, _)) => format!("{}…", &text[..cut]),
None => text,
}
}
/// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a
/// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" —
/// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't
/// resolved yet silently becomes "no parent" and the layer lands on the page.
pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result<Option<NodeId>> {
match get(p, key) {
None | Some(Value::Null) => Ok(None),
Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {})", shown(v)))),
}
}
/// `key` when it is there: a whole, non-negative number, or an error. Missing means "none".
pub fn opt_index(cmd: &str, p: &Value, key: &str) -> Result<Option<usize>> {
match get(p, key) {
None | Some(Value::Null) => Ok(None),
Some(_) => req_index(cmd, p, key).map(Some),
}
}
/// `ids` (array), `id` (single), or the current selection.
pub fn targets(s: &Session, p: &Value) -> Vec<NodeId> {
if let Some(Value::Array(a)) = get(p, "ids") {
@@ -99,8 +129,25 @@ pub fn targets(s: &Session, p: &Value) -> Vec<NodeId> {
s.selection()
}
/// Like [`targets`] but an empty result is an error.
/// Like [`targets`] but an unparseable `ids` entry, an unparseable `id`, or an empty result is an
/// error: naming something that isn't a layer is a mistake, not an empty selection. An explicit
/// `null` says the same as leaving the key out.
pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match get(p, "ids") {
Some(Value::Array(a)) => {
if let Some(v) = a.iter().find(|v| id_of(v).is_none()) {
return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {})", shown(v))));
}
}
Some(Value::Null) | None => {
if let Some(v) = get(p, "id").filter(|v| !v.is_null())
&& id_of(v).is_none()
{
return Err(bad(cmd, format!("`id` must be a node id (got {})", shown(v))));
}
}
Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {})", shown(v)))),
}
let t = targets(s, p);
if t.is_empty() {
return Err(EngineError::Disabled(cmd.to_string(), "nothing is selected (pass `ids`)".into()));
+15
View File
@@ -111,6 +111,10 @@ const DENIED: &[&str] = &[
"edit.redo",
"edit.repeat",
"command.batch",
// Network sessions, online files and their recovery copies on disk.
"collab.",
// Opens files in new windows.
"window.",
"view.theme",
];
/// Read-only exceptions to [`DENIED`].
@@ -128,6 +132,17 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
if command == "image.place" && get(params, "path").is_some() {
return Some("plugins can't read files: pass the image as `data`".into());
}
// `path` here is a file on disk (elsewhere it is a vector path or an action's place).
if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() {
return Some("plugins can't write files: leave out `path` to get the result back".into());
}
if command == "codeConnect.import" && get(params, "path").is_some() {
return Some("plugins can't read files: pass the connections as `json`".into());
}
// Variables JSON may be given as a path: only objects and JSON text for plugins.
if command == "variable.import" && get(params, "variables").and_then(Value::as_str).is_some_and(|t| !t.trim_start().starts_with('{')) {
return Some("plugins can't read files: pass the variables JSON itself".into());
}
if !m.permissions.allows(command) {
return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name));
}
+7
View File
@@ -169,6 +169,13 @@ fn files_dialogs_history_and_plugins_are_off_limits() {
("plugin.remove", json!({"id": "org.test.deny"})),
("edit.undo", json!({})),
("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})),
("variable.export", json!({"format": "css", "path": target})),
("codeConnect.export", json!({"path": target})),
("codeConnect.import", json!({"path": "/etc/passwd"})),
("component.playground", json!({"id": a, "path": target})),
("variable.import", json!({"variables": "/etc/passwd"})),
("collab.recoverFile", json!({"path": "copy.figcraft", "file": "x", "session": 1, "client": 1, "seq": 0})),
("window.new", json!({"path": "C:/Windows/win.ini"})),
("doc.switch", json!({"index": 0})),
("pref.keepTool", json!({})),
// Without a name it would open inline renaming in the layers panel: a UI request.
+366
View File
@@ -0,0 +1,366 @@
//! Save As can write an experimental `.fig` copy without replacing native save state.
use std::path::PathBuf;
use std::sync::Arc;
use figcraft_format::Version;
use serde_json::{Value, json};
use crate::{DocState, EngineError, Session};
struct SaveFiles {
dir: PathBuf,
files: Vec<PathBuf>,
}
impl SaveFiles {
fn new() -> Self {
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
Self { dir, files: Vec::new() }
}
fn path(&mut self, name: &str) -> PathBuf {
let path = self.dir.join(name);
self.files.push(path.clone());
path
}
}
impl Drop for SaveFiles {
fn drop(&mut self) {
for path in &self.files {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_dir(path);
}
let _ = std::fs::remove_dir(&self.dir);
}
}
fn rect(s: &mut Session, name: &str) {
s.execute("shape.rectangle", &json!({"name": name, "x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
}
fn assert_versions_unchanged(after: &[Version], before: &[Version]) {
assert_eq!(after.len(), before.len());
for (after, before) in after.iter().zip(before) {
assert_eq!(
(&after.id, &after.name, &after.description, after.time, after.auto),
(&before.id, &before.name, &before.description, before.time, before.auto)
);
assert_eq!(*after.document().unwrap(), *before.document().unwrap());
}
}
fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String]) {
let after = s.ds().unwrap();
assert_eq!(after.path, before.path);
assert_eq!(after.is_dirty(), before.is_dirty());
assert_eq!(after.versions_changed, before.versions_changed);
assert!(Arc::ptr_eq(&after.doc, &before.doc));
assert!(Arc::ptr_eq(&after.saved_doc, &before.saved_doc));
assert_eq!(
after.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)),
before.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page))
);
if let (Some(after), Some(before)) = (&after.preview, &before.preview) {
assert!(Arc::ptr_eq(&after.doc, &before.doc));
}
assert_eq!((&after.selection, after.page), (&before.selection, before.page));
assert_versions_unchanged(&after.versions, &before.versions);
assert_eq!(after.branches.len(), before.branches.len());
for (after, before) in after.branches.iter().zip(&before.branches) {
assert_eq!(
(&after.id, &after.name, after.time, after.client, after.state),
(&before.id, &before.name, before.time, before.client, before.state)
);
assert_eq!(*after.base, *before.base);
assert_eq!(*after.head, *before.head);
assert_versions_unchanged(&after.versions, &before.versions);
}
for (after, before) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] {
assert_eq!(after.len(), before.len());
for (after, before) in after.iter().zip(before) {
assert_eq!((&after.label, &after.selection, after.page), (&before.label, &before.selection, before.page));
assert!(Arc::ptr_eq(&after.doc, &before.doc));
}
}
assert_eq!(s.prefs.recent_files, recent);
}
fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document {
let bytes = std::fs::read(path).unwrap();
assert_eq!(result["format"], "fig");
assert_eq!(result["bytes"], bytes.len());
assert!(result["warnings"].is_array());
assert!(figcraft_format::load_bytes(&bytes).is_err(), "a .fig copy must not contain native FigCraft data");
figcraft_format::fig::import(&bytes, "Copy").unwrap().0
}
#[test]
fn fig_save_as_keeps_native_path_dirty_state_and_history() {
let mut files = SaveFiles::new();
let native = files.path("design.figcraft");
let copy = files.path("copy.fig");
let mut s = Session::new();
rect(&mut s, "Saved rectangle");
s.execute("file.saveAs", &json!({"path": native.to_string_lossy()})).unwrap();
let saved = std::fs::read(&native).unwrap();
rect(&mut s, "New rectangle");
s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Copy warning"})).unwrap();
s.execute("file.saveVersion", &json!({"name": "Before copy"})).unwrap();
rect(&mut s, "Undone rectangle");
s.undo().unwrap();
let before = s.ds().unwrap().clone();
let recent = s.prefs.recent_files.clone();
assert!(before.is_dirty());
assert!(!before.history.redo.is_empty());
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
let imported = read_fig(&copy, &result);
assert!(imported.nodes().any(|n| n.name == "New rectangle"));
assert!(!imported.nodes().any(|n| n.name == "Undone rectangle"));
assert!(result["warnings"].as_array().unwrap().iter().any(|w| w == "comments were left out"));
assert_save_state_unchanged(&s, &before, &recent);
assert_eq!(std::fs::read(&native).unwrap(), saved);
s.execute("file.save", &json!({})).unwrap();
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
assert!(persisted.doc.nodes().any(|n| n.name == "New rectangle"));
assert_eq!(persisted.versions[0].name, "Before copy");
assert_eq!(std::fs::read(&copy).unwrap().len(), result["bytes"].as_u64().unwrap() as usize);
}
#[test]
fn unsaved_fig_copy_remains_unsaved() {
let mut files = SaveFiles::new();
let copy = files.path("unsaved.fig");
let mut s = Session::new();
rect(&mut s, "Unsaved rectangle");
let before = s.ds().unwrap().clone();
let recent = s.prefs.recent_files.clone();
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
assert!(read_fig(&copy, &result).nodes().any(|n| n.name == "Unsaved rectangle"));
assert_save_state_unchanged(&s, &before, &recent);
assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..))));
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
}
#[test]
fn empty_document_fig_copy_keeps_clean_state() {
let mut files = SaveFiles::new();
let copy = files.path("empty.fig");
let mut s = Session::new();
let before = s.ds().unwrap().clone();
assert!(!before.is_dirty());
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
let imported = read_fig(&copy, &result);
assert_eq!(imported.pages.len(), 1);
assert_eq!(imported.node_count(), 1);
assert_save_state_unchanged(&s, &before, &[]);
}
#[test]
fn fig_save_commands_accept_uppercase_extension() {
let mut files = SaveFiles::new();
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
let copy = files.path(&format!("copy-{index}.FIG"));
let mut s = Session::new();
rect(&mut s, command);
let before = s.ds().unwrap().clone();
let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap();
assert!(read_fig(&copy, &result).nodes().any(|n| n.name == command));
assert_save_state_unchanged(&s, &before, &[]);
}
}
#[test]
fn fig_copy_from_branch_exports_active_document_without_saving_main() {
let mut files = SaveFiles::new();
let native = files.path("main.figcraft");
let copy = files.path("branch.fig");
let mut s = Session::new();
rect(&mut s, "Main rectangle");
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
let saved = std::fs::read(&native).unwrap();
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
rect(&mut s, "Branch rectangle");
s.execute("file.saveVersion", &json!({"name": "Branch version"})).unwrap();
// Branch edits normally sync to main before commands; capture that state before exporting.
s.execute("branch.list", &json!({})).unwrap();
let branch = s.ds().unwrap().clone();
let main = s.docs[0].clone();
let recent = s.prefs.recent_files.clone();
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
assert!(read_fig(&copy, &result).nodes().any(|n| n.name == "Branch rectangle"));
assert_save_state_unchanged(&s, &branch, &recent);
assert_eq!(s.docs[0].path, main.path);
assert_eq!(s.docs[0].is_dirty(), main.is_dirty());
assert!(Arc::ptr_eq(&s.docs[0].saved_doc, &main.saved_doc));
assert!(Arc::ptr_eq(&s.docs[0].doc, &main.doc));
assert_eq!(std::fs::read(&native).unwrap(), saved);
s.execute("file.save", &json!({})).unwrap();
let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle"));
assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle"));
assert_eq!(persisted.branches[0].versions[0].name, "Branch version");
assert!(!s.ds().unwrap().is_dirty());
assert!(!s.docs[0].is_dirty());
}
#[test]
fn fig_copy_write_failure_keeps_save_state() {
let mut files = SaveFiles::new();
let blocked = files.path("blocked.fig");
std::fs::create_dir(&blocked).unwrap();
let missing = files.path("missing/copy.fig");
let mut s = Session::new();
rect(&mut s, "Kept rectangle");
s.execute("file.saveVersion", &json!({"name": "Kept version"})).unwrap();
rect(&mut s, "Later rectangle");
s.execute("version.preview", &json!({"index": 0})).unwrap();
let before = s.ds().unwrap().clone();
for command in ["file.save", "file.saveAs"] {
for path in [&blocked, &missing] {
assert!(s.execute(command, &json!({"path": path.to_string_lossy()})).is_err());
assert_save_state_unchanged(&s, &before, &[]);
assert!(!PathBuf::from(format!("{}.tmp-figcraft", path.display())).exists());
}
}
}
#[test]
fn save_as_without_extension_stays_native() {
let mut files = SaveFiles::new();
let requested = files.dir.join("native");
let native = files.path("native.figcraft");
let mut s = Session::new();
rect(&mut s, "Native rectangle");
let result = s.execute("file.saveAs", &json!({"path": requested.to_string_lossy()})).unwrap();
assert_eq!(result["path"], native.to_string_lossy().as_ref());
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
assert!(!s.ds().unwrap().is_dirty());
assert!(!requested.exists());
let loaded = figcraft_format::load_bytes(&std::fs::read(native).unwrap()).unwrap();
assert!(loaded.0.nodes().any(|n| n.name == "Native rectangle"));
}
#[test]
fn fig_copy_of_version_preview_keeps_visible_version_and_save_state() {
let mut files = SaveFiles::new();
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
let native = files.path(&format!("current-{index}.figcraft"));
let copy = files.path(&format!("version-{index}.FIG"));
let mut s = Session::new();
rect(&mut s, "A");
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
let saved = std::fs::read(&native).unwrap();
rect(&mut s, "B");
s.execute("version.preview", &json!({"index": 0})).unwrap();
let before = s.ds().unwrap().clone();
let recent = s.prefs.recent_files.clone();
assert!(before.preview.is_some());
assert!(before.is_dirty());
let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap();
let imported = read_fig(&copy, &result);
assert!(imported.nodes().any(|n| n.name == "A"));
assert!(!imported.nodes().any(|n| n.name == "B"), "the copy must export the version shown");
assert_save_state_unchanged(&s, &before, &recent);
assert_eq!(std::fs::read(&native).unwrap(), saved);
s.execute("version.exitPreview", &json!({})).unwrap();
assert!(s.doc().unwrap().nodes().any(|n| n.name == "B"));
}
}
#[test]
fn pending_save_choice_keeps_version_preview() {
let mut files = SaveFiles::new();
for (index, (command, native_path)) in [("file.saveAs", false), ("file.saveAs", true), ("file.save", false)].into_iter().enumerate() {
let mut s = Session::new();
rect(&mut s, "A");
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
if native_path {
let native = files.path(&format!("saved-{index}.figcraft"));
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
}
rect(&mut s, "B");
s.execute("version.preview", &json!({"index": 0})).unwrap();
let before = s.ds().unwrap().clone();
let recent = s.prefs.recent_files.clone();
assert!(matches!(s.execute(command, &json!({})), Err(EngineError::Disabled(..))));
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
assert_save_state_unchanged(&s, &before, &recent);
}
}
#[test]
fn native_save_from_version_preview_writes_current_document() {
let mut files = SaveFiles::new();
for (index, (command, explicit)) in [("file.save", false), ("file.save", true), ("file.saveAs", true)].into_iter().enumerate() {
let native = files.path(&format!("native-version-{index}.figcraft"));
let mut s = Session::new();
rect(&mut s, "A");
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
rect(&mut s, "B");
s.execute("version.preview", &json!({"index": 0})).unwrap();
let undo = s.ds().unwrap().history.undo.len();
let params = if explicit { json!({"path": native.to_string_lossy()}) } else { json!({}) };
s.execute(command, &params).unwrap();
assert!(s.ds().unwrap().preview.is_none());
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
assert!(loaded.doc.nodes().any(|n| n.name == "A"));
assert!(loaded.doc.nodes().any(|n| n.name == "B"));
assert!(!loaded.versions[0].document().unwrap().nodes().any(|n| n.name == "B"));
}
}
#[test]
fn implicit_save_protects_legacy_fig_destination() {
let mut files = SaveFiles::new();
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
let legacy = files.path(&format!("legacy-{index}.FIG"));
let native = files.path(&format!("corrected-{index}.figcraft"));
let mut s = Session::new();
rect(&mut s, "A");
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
// Earlier builds could write native data under .fig; keep those bytes safe on plain Save.
let sentinel = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap();
std::fs::write(&legacy, &sentinel).unwrap();
s.ds_mut().unwrap().path = Some(legacy.to_string_lossy().into_owned());
s.ds_mut().unwrap().mark_saved();
rect(&mut s, "B");
s.execute("version.preview", &json!({"index": 0})).unwrap();
let before = s.ds().unwrap().clone();
let recent = s.prefs.recent_files.clone();
assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..))));
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
assert_save_state_unchanged(&s, &before, &recent);
assert_eq!(std::fs::read(&legacy).unwrap(), sentinel);
s.execute(command, &json!({"path": native.to_string_lossy()})).unwrap();
assert!(s.ds().unwrap().preview.is_none());
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
assert!(loaded.doc.nodes().any(|n| n.name == "A"));
assert!(loaded.doc.nodes().any(|n| n.name == "B"));
assert_eq!(loaded.versions[0].name, "A only");
rect(&mut s, "C");
s.execute("file.save", &json!({})).unwrap();
let loaded = figcraft_format::load_bytes(&std::fs::read(&native).unwrap()).unwrap();
assert!(loaded.0.nodes().any(|n| n.name == "C"));
assert_eq!(std::fs::read(&legacy).unwrap(), sentinel);
}
}
@@ -275,3 +275,92 @@ fn locked_layers_selected_in_the_layers_panel_stay_put() {
ex(&mut s, "object.toggleLock", json!({"ids": [k.g], "locked": true}));
assert_eq!(handle_at(s.doc().unwrap(), &[k.a], Point::new(60.0, 60.0), 1.0), None);
}
#[test]
fn shift_drag_moves_the_selection() {
let mut s = Session::new();
let k = scene(&mut s);
let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin();
let home = (Point::new(500.0, 0.0), Point::new(600.0, 0.0));
// (selection before, Shift-drag to (from r at 520,20), selection after, where r and empty end up, derivation)
let rows: Vec<(Vec<NodeId>, (f64, f64), Vec<NodeId>, (Point, Point), &str)> = vec![
(
vec![k.r],
(560.0, 22.0),
vec![k.r],
(Point::new(540.0, 0.0), Point::new(600.0, 0.0)),
"Shift-drag on the selected layer moves it along one axis and keeps it selected",
),
(
vec![k.r, k.empty],
(560.0, 22.0),
vec![k.r, k.empty],
(Point::new(540.0, 0.0), Point::new(640.0, 0.0)),
"Shift-drag on one of several selected layers moves them all",
),
(
vec![k.empty],
(560.0, 22.0),
vec![k.r, k.empty],
(Point::new(540.0, 0.0), Point::new(640.0, 0.0)),
"Shift-drag on an unselected layer adds it and moves the selection",
),
(vec![k.r, k.empty], (521.0, 21.0), vec![k.empty], home, "a Shift-press that wobbles under the drag threshold is a click: the layer leaves"),
];
for (before, to, after, end, why) in rows {
ex(&mut s, "select.set", json!({"ids": before}));
drag(&mut s, (520.0, 20.0), to, SHIFT);
assert_eq!(sorted(s.selection()), sorted(after), "{why}");
assert_eq!((at(&s, k.r), at(&s, k.empty)), end, "{why}");
if end != home {
assert!(s.undo().unwrap(), "{why}: one undo step");
assert_eq!((at(&s, k.r), at(&s, k.empty)), home, "{why}: undone");
}
}
// Shift let go before the button: still a Shift-click.
ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]}));
s.pointer(&ev(PointerKind::Down, 520.0, 20.0, SHIFT)).unwrap();
s.pointer(&ev(PointerKind::Up, 520.0, 20.0, NONE)).unwrap();
assert_eq!(s.selection(), vec![k.empty], "Shift let go before the button still takes the layer out");
}
#[test]
fn alt_shift_drag_on_a_selected_layer_duplicates_it() {
let mut s = Session::new();
let k = scene(&mut s);
let layers = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
let n = layers(&s);
let alt_shift = |kind, x, y| {
let mut e = ev(kind, x, y, SHIFT);
e.alt = true;
e
};
ex(&mut s, "select.set", json!({"ids": [k.r]}));
s.pointer(&alt_shift(PointerKind::Down, 520.0, 20.0)).unwrap();
s.pointer(&alt_shift(PointerKind::Up, 520.0, 20.0)).unwrap();
assert_eq!(s.selection(), Vec::<NodeId>::new(), "an Alt+Shift-click is a Shift-click: the layer leaves the selection");
assert_eq!(layers(&s), n, "and nothing is copied");
ex(&mut s, "select.set", json!({"ids": [k.r]}));
s.pointer(&alt_shift(PointerKind::Down, 520.0, 20.0)).unwrap();
s.pointer(&alt_shift(PointerKind::Move, 560.0, 22.0)).unwrap();
s.pointer(&alt_shift(PointerKind::Up, 560.0, 22.0)).unwrap();
let sel = s.selection();
assert_eq!(layers(&s), n + 1, "an Alt+Shift-drag copies the layer");
assert!(sel.len() == 1 && sel[0] != k.r, "the copy is selected");
let doc = s.doc().unwrap();
assert_eq!(doc.world_bounds(sel[0]).unwrap().origin(), Point::new(540.0, 0.0), "the copy moves along one axis");
assert_eq!(doc.world_bounds(k.r).unwrap().origin(), Point::new(500.0, 0.0), "the original stays");
}
#[test]
fn shift_click_takes_a_layer_out_where_nothing_can_move() {
let mut s = Session::new();
let k = scene(&mut s);
// A version being viewed is read-only: the press can't start a move.
let v = ex(&mut s, "file.saveVersion", json!({"name": "v"}));
ex(&mut s, "version.preview", json!({"id": v["id"]}));
ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]}));
assert!(s.pointer(&ev(PointerKind::Down, 520.0, 20.0, SHIFT)).is_err(), "a version being viewed can't be edited");
s.pointer(&ev(PointerKind::Up, 520.0, 20.0, SHIFT)).unwrap();
assert_eq!(s.selection(), vec![k.empty], "Shift-click still takes the layer out");
}
@@ -396,6 +396,30 @@ fn boolean_results_and_the_look_they_take() {
assert!(path.area().abs() < 1e-6, "no overlap, nothing to show");
}
#[test]
fn flatten_keeps_open_paths() {
// #377: open paths have no fill outline, so two pen paths flattened to "nothing to flatten"
// and next to a closed shape they disappeared.
let mut s = Session::new();
let a = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 50], [200, 0]]}));
let b = create(&mut s, "vector.create", json!({"points": [[0, 100], [100, 150], [200, 100]]}));
let tri = create(&mut s, "vector.create", json!({"points": [[300, 0], [400, 0], [400, 100]], "closed": true}));
let v = create(&mut s, "vector.flatten", json!({"ids": [a, b, tri]}));
let NodeKind::Vector { network } = node(&s, v).kind else { panic!("flatten makes one vector") };
assert_eq!((network.vertices.len(), network.segments.len()), (9, 7), "both open paths and the triangle");
assert_eq!(network.regions.len(), 1, "only the triangle is filled");
let bb = s.doc().unwrap().world_bounds(v).unwrap();
assert_eq!((bb.x0, bb.y0, bb.x1, bb.y1), (0.0, 0.0, 400.0, 150.0), "nothing moves");
// Joining two open paths goes the same way.
let mut s = Session::new();
let a = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]]}));
let b = create(&mut s, "vector.create", json!({"points": [[0, 50], [100, 50]]}));
let v = create(&mut s, "vector.join", json!({"ids": [a, b]}));
let NodeKind::Vector { network } = node(&s, v).kind else { panic!("join makes one vector") };
assert_eq!(network.segments.len(), 2);
}
#[test]
fn flatten_and_outline_stroke() {
// Flatten: one vector layer from the selection, in one step; Alt+Shift+F does it.
@@ -212,6 +212,31 @@ fn previews_while_editing_keep_the_edit() {
assert!(links(&s, t).iter().all(Option::is_none));
}
#[test]
fn previews_show_a_hovered_font_family() {
let mut s = new_session();
let t = text(&mut s, "Preview me");
s.ds_mut().unwrap().mark_saved();
let before = s.doc().unwrap().clone();
let undo = s.ds().unwrap().history.undo.len();
// Hovering a font family shows it on the canvas: no undo step, not dirty.
s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontFamily": "Arial"}})).unwrap();
assert_eq!(data(&s, t).style_at(0).font_family, "Arial");
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
assert!(!s.ds().unwrap().is_dirty());
// Moving to another family replaces the preview, from the document itself.
s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontFamily": "Georgia"}})).unwrap();
assert_eq!(data(&s, t).style_at(0).font_family, "Georgia");
// Closing without picking puts the document back as it was.
assert_eq!(s.execute("text.endPreview", &json!({})).unwrap()["ended"], json!(true));
assert_eq!(*s.doc().unwrap(), before);
// Clicking commits it as one undo step.
s.execute("text.style", &json!({"ids": [t], "fontFamily": "Georgia"})).unwrap();
assert_eq!(data(&s, t).style_at(0).font_family, "Georgia");
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1);
assert!(s.ds().unwrap().text_preview.is_none());
}
#[test]
fn previews_refuse_what_they_cant_show() {
let mut s = new_session();
+21 -3
View File
@@ -57,6 +57,24 @@ fn list_buttons_act_on_the_selected_paragraphs() {
assert_eq!(data(&s, t).list, P, "toggled off");
}
#[test]
fn a_list_with_a_start_number_toggles_off() {
// An imported list that starts at 2 has the start on its first item only; it is still one
// numbered list, so the button turns it back to plain (#686).
let mut s = Session::new();
let t = text(&mut s, "two\nthree\nfour");
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
let n = |start_offset| ParaList { list: N, indent: 0, start_offset };
td.paragraphs = vec![n(1), n(0), n(0)];
}
let j = crate::inspect::node_json(s.doc().unwrap(), t, 0);
assert_eq!(j["listStartOffsets"], json!([1, 0, 0]), "inspect reports the start");
s.execute("text.numberList", &json!({"ids": [t]})).unwrap();
assert_eq!(lists(&s, t), [(P, 0); 3]);
assert_eq!(data(&s, t).para_list(0).start_offset, 0, "a plain paragraph has no start");
assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("listStartOffsets").is_none());
}
#[test]
fn typed_list_starts() {
// Typing "- " or "* " at the start of a paragraph starts a bulleted list, "1. " a numbered one
@@ -117,7 +135,7 @@ fn list_keys_at_list_items() {
for (chars, before, caret, k, shift, after, want, why) in cases {
let mut s = Session::new();
let t = text(&mut s, chars);
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent }).collect();
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect();
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
td.paragraphs = ps;
}
@@ -141,7 +159,7 @@ fn paragraph_lists_follow_edits() {
// A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy
// the one they split from; joined paragraphs keep the first one's.
let mut t = TextData::new("ab\ncd\nef");
t.paragraphs = vec![ParaList { list: B, indent: 1 }, ParaList::default(), ParaList { list: N, indent: 0 }];
t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }];
let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::<Vec<_>>();
t.replace_range(1, 1, "x\ny");
assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it");
@@ -156,7 +174,7 @@ fn paragraph_lists_follow_edits() {
(seed >> 33) as usize % n.max(1)
};
let mut t = TextData::new("a\nb\nc\nd");
t.paragraphs = vec![ParaList { list: B, indent: 2 }; 4];
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4];
for _ in 0..500 {
let n = t.char_count();
let (a, b) = (rnd(n + 2), rnd(n + 2));
+2 -2
View File
@@ -1,6 +1,6 @@
//! Live text previews: what a text command would do, shown on the canvas without an undo step —
//! while the pointer is over a text style in the style browser or a font's named instance, or
//! while a variable font axis slider is dragged. The document under the preview is kept and comes
//! while the pointer is over a font family or style, a text style in the style browser or a
//! font's named instance, or while a variable font axis slider is dragged. The document under the preview is kept and comes
//! back when the preview ends ([`end`], `text.endPreview`). Every other command ends it first, so
//! committing (running the command for real) applies to the document itself as one undo step, and
//! Escape just ends the preview.
+26 -14
View File
@@ -344,6 +344,9 @@ pub(crate) enum Gesture {
targets: Targets,
duplicate: bool,
moved: bool,
/// A Shift-press on this selected layer: it leaves the selection on release, unless the
/// press became a drag.
deselect: Option<NodeId>,
},
Resize {
start: Point,
@@ -1194,15 +1197,15 @@ impl Session {
Some(target) => {
// Clicking inside the current selection's box keeps it (drag them all).
let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift;
// Shift on a selected layer takes it out on release if the press doesn't become a
// drag; a drag moves the selection with it. Where no drag can start, it goes now.
let deselect = (ev.shift && sel.contains(&target)).then_some(target);
let without_target = || sel.iter().copied().filter(|x| *x != target).collect::<Vec<_>>();
let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) {
sel.clone()
} else if ev.shift {
} else if ev.shift && deselect.is_none() {
let mut s2 = sel.clone();
if let Some(pos) = s2.iter().position(|x| *x == target) {
s2.remove(pos);
} else {
s2.push(target);
}
s2.push(target);
s2
} else if sel.contains(&target) {
sel.clone()
@@ -1210,12 +1213,12 @@ impl Session {
vec![target]
};
self.set_selection(new_sel.clone());
if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) {
return Ok(());
}
// Locked layers selected in the layers panel stay put.
let ids: Vec<NodeId> = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect();
if ids.is_empty() {
if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() {
if deselect.is_some() {
self.set_selection(without_target());
}
return Ok(());
}
let mut targets = Targets::gather(&doc, page, &ids);
@@ -1225,8 +1228,13 @@ impl Session {
{
targets.add_box(b);
}
self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?;
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false };
if let Err(e) = self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" }) {
if deselect.is_some() {
self.set_selection(without_target());
}
return Err(e);
}
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect };
self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default };
}
None => {
@@ -1735,7 +1743,7 @@ impl Session {
self.tool.overlay.drop_cell = None;
let drop_target = self.tool.overlay.drop_target.take();
match g {
Gesture::Move { ids, moved, duplicate, start, .. } => {
Gesture::Move { ids, moved, duplicate, start, deselect, .. } => {
if moved {
// A free move lands in the frame under the pointer (or the page) where it was
// dropped; layers in an auto layout frame already sit in their slot.
@@ -1777,7 +1785,11 @@ impl Session {
}
} else {
self.cancel_interaction()?;
if !(ev.shift || ev.cmd || duplicate) {
if let Some(d) = deselect {
let sel = self.selection().into_iter().filter(|x| *x != d).collect();
self.set_selection(sel);
}
if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) {
self.widget_click(start, ev.tol() * 2.0)?;
}
}
+73
View File
@@ -5,6 +5,79 @@ use serde_json::json;
use crate::Session;
use crate::params::id_of;
fn stale_bound_color_document() -> (figcraft_doc::Document, figcraft_doc::NodeId) {
let mut s = Session::new();
let id = id_of(&s.execute("shape.rectangle", &json!({"x": 4000, "y": 4000, "width": 23, "height": 17})).unwrap()["id"]).unwrap();
let page = s.doc().unwrap().first_page().unwrap();
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FFFFFF"})).unwrap();
s.execute("variable.create", &json!({"collection": "Theme", "name": "size", "type": "number", "value": 23})).unwrap();
s.execute("variable.set", &json!({"variable": "surface", "mode": "Dark", "value": "#202124"})).unwrap();
s.execute("variable.set", &json!({"variable": "size", "mode": "Dark", "value": 99})).unwrap();
s.execute("variable.bind", &json!({"ids": [id], "field": "fill", "variable": "surface"})).unwrap();
s.execute("variable.bind", &json!({"ids": [id], "field": "width", "variable": "size"})).unwrap();
s.execute("variable.setMode", &json!({"collection": "Theme", "mode": "Dark"})).unwrap();
let mut doc = s.doc().unwrap().clone();
let node = doc.get_mut(id).unwrap();
node.name = "load-binding-sentinel".into();
node.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)];
node.width = 5.0;
node.source = Some(page);
node.overrides.insert("sentinel".into());
doc.take_dirty();
(doc, id)
}
fn assert_dark_bound_color(doc: &figcraft_doc::Document, transform: figcraft_doc::geom::Affine) -> figcraft_doc::NodeId {
let node = doc.nodes().find(|n| n.name == "load-binding-sentinel").unwrap();
assert_eq!(node.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()), Some("202124".into()));
assert_eq!((node.transform, node.width, node.height), (transform, 5.0, 17.0));
assert!(node.bound.contains_key("fills/0/color") || node.bound.contains_key("fill"));
node.id
}
#[test]
fn initial_color_bindings_settle_in_constructor_and_loaders() {
let (stale, _id) = stale_bound_color_document();
let name_transform = stale.nodes().find(|n| n.name == "load-binding-sentinel").unwrap().transform;
let mut state = Session::with_document(stale.clone());
let id = assert_dark_bound_color(state.doc().unwrap(), name_transform);
assert_eq!(state.doc().unwrap().node(id).unwrap().source, stale.first_page());
assert!(state.doc().unwrap().node(id).unwrap().overrides.contains("sentinel"));
assert!(!state.ds().unwrap().is_dirty());
assert!(state.doc().unwrap().dirty().is_empty());
assert_eq!(state.undo_depth(), (0, 0));
state.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
assert_dark_bound_color(state.doc().unwrap(), name_transform);
let native = figcraft_format::save_bytes(&stale, None).unwrap();
let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap();
// A native file settles when it opens as a document, not in the loader.
let opened = Session::with_document(loaded);
let loaded = opened.doc().unwrap();
let loaded_id = assert_dark_bound_color(loaded, name_transform);
assert_eq!(loaded_id, id);
assert_eq!(loaded.node(id).unwrap().source, stale.first_page());
assert!(loaded.node(id).unwrap().overrides.contains("sentinel"));
assert!(loaded.dirty().is_empty());
let (fig, _) = figcraft_format::fig::export(&stale, None).unwrap();
let (loaded, _) = crate::cmd::file::load_bytes(&fig, "bindings.fig").unwrap();
assert_dark_bound_color(&loaded, name_transform);
}
#[test]
fn initial_paint_settlement_preserves_existing_dirty_nodes() {
let (mut doc, _) = stale_bound_color_document();
let page = doc.first_page().unwrap();
doc.mark_dirty([page]);
crate::cmd::styles::apply_paint_bindings_on_load(&mut doc);
assert_eq!(doc.dirty().iter().copied().collect::<Vec<_>>(), [page]);
}
#[test]
fn bound_layers_follow_variable_values_and_unbind() {
let mut s = Session::new();
@@ -133,6 +133,50 @@ fn entering_and_leaving_vector_edit_mode() {
assert!(matches!(s.doc().unwrap().node(id).unwrap().kind, NodeKind::Rectangle), "converting is one undo step");
}
#[test]
fn thin_strokes_are_easy_to_enter_and_bend() {
use crate::tools::ToolId;
// A diagonal line from (100, 100) to (200, 50); `off` steps away from its middle, square to it.
let at = |off: f64| (150.0 + off * 50.0 / 125f64.sqrt() / 10.0, 75.0 + off * 100.0 / 125f64.sqrt() / 10.0);
// (layer, screen px off the stroke, in edit mode after a double-click, derivation)
let rows: Vec<(&str, f64, bool, &str)> = vec![
("line", 0.0, true, "double-clicking a selected line edits it as a vector"),
("line", 4.0, true, "a 1 px line reaches a few pixels, so a double-click near it still lands (#646)"),
("line", 8.0, false, "a double-click well away from it does not"),
("path", 4.0, true, "a stroke-only path reaches as far as a line does"),
("path", 8.0, false, "and no further"),
];
for (layer, off, after, why) in rows {
let mut s = Session::new();
let id = if layer == "line" {
s.set_tool(ToolId::Line);
drag(&mut s, (100.0, 100.0), (200.0, 50.0), NONE);
*s.selection().first().unwrap()
} else {
let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [200, 50]], "closed": false}))["id"]).unwrap();
// Styled like a pen stroke: 1 px, no fill.
ex(&mut s, "node.set", json!({"ids": [id], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
s.set_selection(vec![id]);
id
};
assert!(!s.doc().unwrap().node(id).unwrap().fills.iter().any(|f| f.visible), "{why}: the {layer} only has a stroke");
let p = at(off);
click(&mut s, p, NONE);
s.pointer(&ev(PointerKind::Down, p, NONE)).unwrap();
s.pointer(&ev(PointerKind::DoubleClick, p, NONE)).unwrap();
s.pointer(&ev(PointerKind::Up, p, NONE)).unwrap();
assert_eq!(editing(&s).is_some(), after, "{why}");
if after {
// Cmd/Ctrl then bends it through the pointer.
drag(&mut s, at(0.0), (170.0, 115.0), Mods { cmd: true, ..NONE });
let w = s.doc().unwrap().world_transform(id);
let n = net(&s, id);
assert!(!n.segments[0].is_straight(), "{why}: Cmd/Ctrl bends it");
assert!((w * n.cubic(0).unwrap().eval(0.5) - Point::new(170.0, 115.0)).hypot() < 1.0, "{why}: through the pointer");
}
}
}
#[test]
fn the_pen_follows_the_documented_rules() {
let mut s = Session::new();
+49
View File
@@ -0,0 +1,49 @@
//! `vector.offset` on layers with children: containers are left alone.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{NodeId, NodeKind};
fn id(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
/// No node that holds children may be a non-container.
fn assert_children_only_in_containers(s: &Session, ids: &[NodeId]) {
let doc = s.doc().unwrap();
for &i in ids {
let n = doc.node(i).unwrap();
assert!(n.kind.is_container() || n.kind.is_frame_like() || n.children.is_empty(), "a non-container layer now holds children");
}
}
#[test]
fn offset_path_leaves_containers_with_children_alone() {
let mut s = Session::new();
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let inner = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap());
let a = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap());
let b = id(&s.execute("shape.rectangle", &json!({"x": 220, "y": 0, "width": 10, "height": 10})).unwrap());
let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap());
for target in [f, g] {
s.execute("vector.offset", &json!({"ids": [target], "offset": 5})).unwrap();
assert_children_only_in_containers(&s, &[f, g, inner, a, b]);
}
let doc = s.doc().unwrap();
assert!(doc.node(f).unwrap().kind.is_container());
assert_eq!(doc.node(f).unwrap().children, vec![inner]);
assert!(doc.node(g).unwrap().kind.is_container());
assert_eq!(doc.node(g).unwrap().children.len(), 2);
}
#[test]
fn offset_path_still_converts_a_plain_rectangle() {
let mut s = Session::new();
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
s.execute("vector.offset", &json!({"ids": [r], "offset": 5})).unwrap();
let n = s.doc().unwrap().node(r).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Vector { .. }));
assert!(n.width > 10.0);
}
+42 -1
View File
@@ -996,8 +996,13 @@ impl Session {
self.tool.overlay.guides.clear();
let Gesture::Vector(g) = g else { return Ok(()) };
match g {
VGesture::Points { moved, split, click_clears, .. } => {
VGesture::Points { verts, others, moved, split, click_clears, .. } => {
if moved || split.is_some() {
if let ([v], None) = (verts.as_slice(), split)
&& others.iter().all(|(_, o)| o.is_empty())
{
self.merge_dropped_point(ve.id, *v, 6.0 / ev.zoom.max(1e-6))?;
}
self.commit_interaction()?;
} else {
self.cancel_interaction()?;
@@ -1057,4 +1062,40 @@ impl Session {
}
Ok(())
}
/// Point `v` of vector `id`, dragged and dropped within `tol` (page units) of another of its
/// points, merges into that point (in the same undo step as the move), which stays selected.
fn merge_dropped_point(&mut self, id: NodeId, v: usize, tol: f64) -> Result<()> {
let moved = self.ds()?.doc.get(id)?.clone();
let w = self.ds()?.doc.world_transform(id);
let NodeKind::Vector { network: net } = &moved.kind else { return Ok(()) };
let Some(p) = net.vertices.get(v).map(|x| w * x.point()) else { return Ok(()) };
let Some(keep) = net
.vertices
.iter()
.enumerate()
.filter(|(i, _)| *i != v)
.map(|(i, x)| (i, (w * x.point() - p).hypot()))
.filter(|(_, d)| *d <= tol)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)
else {
return Ok(());
};
let mut merged = net.clone();
ops::merge_vertices(&mut merged, keep, v);
// A merge that would leave no segment (a 2-point line's ends dropped together) only moves.
if merged.segments.is_empty() {
return Ok(());
}
// The preview starts again from the document before the drag: bring the moved layer.
self.preview(|doc, _| {
*doc.get_mut(id)? = moved;
edit(doc, id, |net| *net = merged)
})?;
set_state(self, |e| {
e.vertices = vec![if keep > v { keep - 1 } else { keep }];
e.segments.clear();
})
}
}
+30
View File
@@ -280,6 +280,36 @@ fn mirroring_position_and_join_commands() {
assert!(s.execute("vector.join", &json!({"ids": [id]})).is_ok());
}
#[test]
fn a_point_dropped_on_another_merges_into_it() {
// #401: an open path's end dragged onto its start closes the path at that point.
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]]}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
drag(&mut s, (0.0, 100.0), (2.0, 3.0));
let n = net(&s, id);
assert_eq!(n.vertices.len(), 3, "the dropped point merged");
assert!((0..3).all(|v| n.incident(v).len() == 2), "a closed path: {n:?}");
assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 0.0, 0.0)], "the point it merged into stays selected");
s.undo().unwrap();
let n = net(&s, id);
assert_eq!(n.vertices.len(), 4, "one undo step takes back the move and the merge");
vertex_at(&n, 0.0, 100.0);
// A drop away from every point only moves it.
ex(&mut s, "vector.edit.select", json!({"vertices": []}));
drag(&mut s, (0.0, 100.0), (0.0, 50.0));
assert_eq!(net(&s, id).vertices.len(), 4);
// A 2-point line's end dropped on its other end only moves: no segment would be left.
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]]}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
drag(&mut s, (100.0, 0.0), (2.0, 0.0));
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len()), (2, 1), "still a line: {n:?}");
}
#[test]
fn undo_back_past_the_conversion_leaves_edit_mode() {
let mut s = Session::new();
+13
View File
@@ -248,6 +248,19 @@ fn clicking_a_widget_on_the_canvas_calls_its_plugin_back() {
assert!(after.x - before.x > 30.0 && state(&mut s, w) == json!({"n": 2}), "{before:?} → {after:?}");
}
#[test]
fn a_shift_click_on_a_selected_widget_only_deselects_it() {
let mut s = session();
let (w, _) = insert(&mut s);
let target = s.doc().unwrap().world_bounds(node(&s, w).children[1]).unwrap().center();
let mut down = PointerEvent::at(PointerKind::Down, target.x, target.y, 1.0);
down.shift = true;
// Shift let go before the button: still a Shift-click, not a click on the widget.
s.pointer(&down).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, target.x, target.y, 1.0)).unwrap();
assert_eq!((s.selection(), state(&mut s, w)), (vec![], json!({"n": 1})));
}
#[test]
fn bad_widgets_and_clicks_are_refused_and_roll_back() {
let mut s = session();
@@ -0,0 +1,243 @@
use super::{kiwi::Kv, map};
fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn scalar(value: f32) -> Kv<'static> {
Kv::Float(value)
}
fn guid(local_id: u64) -> Kv<'static> {
obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))])
}
fn record(local_id: u64, children: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut fields = vec![("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))]))];
fields.extend(children);
obj(fields)
}
fn transform(x: f32, y: f32) -> Kv<'static> {
obj(vec![("m00", scalar(1.0)), ("m01", scalar(0.0)), ("m02", scalar(x)), ("m10", scalar(0.0)), ("m11", scalar(1.0)), ("m12", scalar(y))])
}
fn point(x: f32, y: f32) -> Kv<'static> {
obj(vec![("x", scalar(x)), ("y", scalar(y))])
}
fn path_blob() -> Vec<u8> {
let mut bytes = vec![1]; // MoveTo
bytes.extend(0.0f32.to_le_bytes());
bytes.extend(0.0f32.to_le_bytes());
bytes.push(2); // LineTo
bytes.extend(20.0f32.to_le_bytes());
bytes.extend(10.0f32.to_le_bytes());
bytes
}
fn paint() -> Kv<'static> {
obj(vec![
("type", Kv::Enum("SOLID")),
("color", obj(vec![("r", scalar(0.1)), ("g", scalar(0.2)), ("b", scalar(0.8)), ("a", scalar(1.0))])),
("visible", Kv::Bool(true)),
])
}
fn synthetic_connector(version: f32, include_text: bool, text_x: f32) -> Kv<'static> {
let line_geometry = Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])]);
let line = record(0, vec![("transform", transform(7.0, 9.0)), ("size", point(20.0, 10.0)), ("fillGeometry", line_geometry)]);
let text = record(1, vec![("transform", transform(text_x, 3.0)), ("size", point(80.0, 24.0))]);
let background = record(2, vec![("transform", transform(100.0, 30.0)), ("size", point(90.0, 30.0))]);
let mut derived = vec![line];
if include_text {
derived.extend([text, background]);
}
let line_style = record(0, vec![("strokePaints", Kv::Array(vec![paint()]))]);
let text_style = record(
1,
vec![
("textData", obj(vec![("characters", Kv::Str("synthetic label".into()))])),
("fontSize", scalar(24.0)),
("fontName", obj(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into()))])),
("fillPaints", Kv::Array(vec![paint()])),
("visible", Kv::Bool(false)),
],
);
let bg_style = record(2, vec![("fillPaints", Kv::Array(vec![])), ("visible", Kv::Bool(false))]);
let mut styles = vec![line_style];
if include_text {
styles.extend([text_style, bg_style]);
}
obj(vec![
(
"nodeChanges",
Kv::Array(vec![
obj(vec![("guid", guid(0)), ("type", Kv::Enum("DOCUMENT")), ("name", Kv::Str("Doc".into()))]),
obj(vec![
("guid", guid(1)),
("parentIndex", obj(vec![("guid", guid(0)), ("position", Kv::Str("!".into()))])),
("type", Kv::Enum("CANVAS")),
("name", Kv::Str("Page".into())),
]),
obj(vec![
("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])),
("parentIndex", obj(vec![("guid", guid(1)), ("position", Kv::Str("!".into()))])),
("type", Kv::Enum("CONNECTOR")),
("name", Kv::Str("synthetic connector".into())),
("size", point(140.0, 50.0)),
("opacity", scalar(0.5)),
("connectorType", Kv::Enum("MANUAL")),
("connectorVersion", scalar(version)),
("connectorLineStyle", Kv::Enum("STRAIGHT")),
("connectorStartCap", Kv::Enum("NONE")),
("connectorEndCap", Kv::Enum("ARROW")),
("derivedImmutableFrameData", obj(vec![("version", scalar(1.0)), ("overrides", Kv::Array(derived))])),
("nodeGenerationData", obj(vec![("overrides", Kv::Array(styles))])),
]),
]),
),
("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])),
])
}
fn map_synthetic(version: f32, include_text: bool, text_x: f32) -> (serde_json::Value, Vec<String>) {
let (rest, warnings, _) = map::to_rest(&synthetic_connector(version, include_text, text_x), "synthetic", crate::rest::MAX_NODES);
(rest, warnings)
}
fn find_node<'a>(doc: &'a figcraft_doc::Document, name: &str) -> &'a figcraft_doc::Node {
doc.nodes().find(|node| node.name == name).expect("native node")
}
#[test]
fn synthetic_label_style_transform_and_container_opacity_survive_native_roundtrip() {
let (rest, warnings) = map_synthetic(1.0, true, 8.5);
let connector = &rest["document"]["children"][0]["children"][0];
assert_eq!(connector["type"], "FRAME");
assert_eq!(connector["opacity"], 0.5);
assert_eq!(connector["children"].as_array().unwrap().len(), 2, "empty/hidden background is omitted; hidden text remains explicit");
let text = &connector["children"][1];
assert_eq!(text["type"], "TEXT");
assert_eq!(text["visible"], false);
assert_eq!(text["fills"].as_array().unwrap().len(), 1);
assert!((text["fills"][0]["color"]["b"].as_f64().unwrap() - 0.8).abs() < 1e-6);
assert_eq!(text["style"]["fontSize"], 24.0);
assert_eq!(text["relativeTransform"], serde_json::json!([[1.0, 0.0, 108.5], [0.0, 1.0, 33.0]]));
assert!(warnings.iter().any(|warning| warning.contains("live endpoint bindings are not retained")));
let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap();
let bytes = crate::save_bytes(&native, None).unwrap();
let (reopened, _) = crate::load_bytes(&bytes).unwrap();
let frame = find_node(&reopened, "synthetic connector");
assert_eq!(frame.opacity, 0.5);
let text = find_node(&reopened, "synthetic label");
assert!(!text.visible);
assert_eq!(text.opacity, 1.0, "root opacity must not be copied to child");
assert_eq!(text.transform.as_coeffs()[4..], [108.5, 33.0]);
assert_eq!(text.fills.len(), 1);
let figcraft_doc::NodeKind::Text(data) = &text.kind else { panic!("text child kind") };
assert_eq!(data.style.font_size, 24.0);
}
#[test]
fn synthetic_connector_missing_optional_derived_label_keeps_vector() {
let (rest, warnings) = map_synthetic(1.0, false, 0.0);
let connector = &rest["document"]["children"][0]["children"][0];
assert_eq!(connector["type"], "VECTOR", "an unlabelled connector keeps the single-vector shape");
assert!(connector.get("children").is_none());
assert!(warnings.iter().all(|warning| !warning.contains("connector labels were skipped")));
}
#[test]
fn synthetic_unlabelled_connector_imports_as_one_vector_with_line_style() {
let (rest, warnings) = map_synthetic(1.0, false, 0.0);
let connector = &rest["document"]["children"][0]["children"][0];
assert_eq!(connector["type"], "VECTOR");
assert_eq!(connector["name"], "synthetic connector");
assert_eq!(connector["opacity"], 0.5);
assert_eq!(connector["strokes"].as_array().map(Vec::len), Some(1));
assert_eq!(connector["strokeCapEnd"], "ARROW");
assert!(warnings.iter().all(|warning| !warning.contains("connector label")), "{warnings:?}");
let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap();
let node = find_node(&native, "synthetic connector");
assert!(matches!(node.kind, figcraft_doc::NodeKind::Vector { .. }));
}
#[test]
fn synthetic_connector_unsupported_version_uses_legacy_warning() {
let (rest, warnings) = map_synthetic(2.0, true, 8.5);
let connector = &rest["document"]["children"][0]["children"][0];
assert_eq!(connector["type"], "VECTOR");
assert!(warnings.iter().any(|warning| warning == "connector labels were skipped"));
}
#[test]
fn synthetic_connector_malformed_label_bounds_or_transform_keep_vector() {
let mut malformed_bounds = synthetic_connector(1.0, true, 8.5);
set_override_field(&mut malformed_bounds, "derivedImmutableFrameData", 1, "size", point(f32::NAN, 24.0));
let (rest, warnings, _) = map::to_rest(&malformed_bounds, "synthetic", crate::rest::MAX_NODES);
let connector = &rest["document"]["children"][0]["children"][0];
assert_eq!(connector["children"].as_array().unwrap().len(), 1);
assert_eq!(connector["children"][0]["type"], "VECTOR");
assert!(warnings.iter().any(|warning| warning == "connector label was skipped: text bounds are invalid"));
let mut malformed_transform = synthetic_connector(1.0, true, 8.5);
set_override_field(&mut malformed_transform, "derivedImmutableFrameData", 1, "transform", transform(f32::NAN, 3.0));
let (rest, warnings, _) = map::to_rest(&malformed_transform, "synthetic", crate::rest::MAX_NODES);
let connector = &rest["document"]["children"][0]["children"][0];
assert_eq!(connector["children"].as_array().unwrap().len(), 1);
assert_eq!(connector["children"][0]["type"], "VECTOR");
assert!(warnings.iter().any(|warning| warning == "connector label was skipped: text transform is invalid"));
}
#[test]
fn synthetic_connector_authored_hidden_background_is_retained() {
let mut message = synthetic_connector(1.0, true, 8.5);
set_override_field(&mut message, "nodeGenerationData", 2, "fillPaints", Kv::Array(vec![paint()]));
let (rest, _, _) = map::to_rest(&message, "synthetic", crate::rest::MAX_NODES);
let connector = &rest["document"]["children"][0]["children"][0];
let background = connector["children"].as_array().unwrap().iter().find(|child| child["type"] == "RECTANGLE").expect("authored background");
assert_eq!(background["visible"], false);
assert_eq!(background["fills"].as_array().unwrap().len(), 1);
}
fn field_mut<'a, 's>(value: &'a mut Kv<'s>, key: &str) -> Option<&'a mut Kv<'s>> {
let Kv::Object(fields) = value else { return None };
fields.iter_mut().find(|(name, _)| *name == key).map(|(_, value)| value)
}
fn connector_mut<'a, 's>(message: &'a mut Kv<'s>, local_id: u64) -> Option<&'a mut Kv<'s>> {
let Kv::Object(fields) = message else { return None };
let node_changes = fields.iter_mut().find(|(name, _)| *name == "nodeChanges").map(|(_, value)| value)?;
let Kv::Array(nodes) = node_changes else { return None };
nodes.iter_mut().find(|node| {
node.get("type").and_then(Kv::str) == Some("CONNECTOR")
&& node.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(local_id as f64)
})
}
fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'static str, value: Kv<'static>) {
let connector = connector_mut(message, 123).expect("synthetic connector");
let section = field_mut(connector, field).expect("connector override section");
let overrides = field_mut(section, "overrides").expect("override list");
let Kv::Array(rows) = overrides else { panic!("override list array") };
let row = rows
.iter_mut()
.find(|row| {
row.get("guidPath")
.and_then(|path| path.get("guids"))
.and_then(|ids| ids.list().first())
.and_then(|id| id.get("localID"))
.and_then(Kv::f64)
== Some(local_id as f64)
})
.expect("override row");
let Kv::Object(fields) = row else { panic!("override row object") };
if let Some((_, current)) = fields.iter_mut().find(|(name, _)| *name == key) {
*current = value;
} else {
fields.push((key, value));
}
}
+36 -6
View File
@@ -1502,7 +1502,6 @@ impl<'d> Exporter<'d> {
("textExplicitLayoutVersion", u(1)),
("textBidiVersion", u(1)),
("textTracking", fl(0.0)),
("textDecorationSkipInk", b(true)),
("emojiImageSet", e("APPLE")),
]);
if td.auto_resize == TextAutoResize::Truncate {
@@ -1565,6 +1564,20 @@ impl<'d> Exporter<'d> {
data.extend([("characterStyleIDs", arr(ids)), ("styleOverrideTable", arr(entries))]);
}
let lists: Vec<ListStyle> = (0..td.paragraph_count()).map(|p| td.para_list(p).list).collect();
let first = |i: usize, l: &ListStyle| *l != ListStyle::None && (i == 0 || lists.get(i - 1) != Some(l));
// Where each line's numbered list starts counting, less one: its first item's start, on
// every line of the list.
let mut run = 0;
let starts: Vec<u32> = lists
.iter()
.enumerate()
.map(|(i, l)| {
if first(i, l) {
run = td.para_list(i).start_offset;
}
if *l == ListStyle::Numbered { run } else { 0 }
})
.collect();
// One entry per paragraph, each with every field a reader expects.
let line = |(i, l): (usize, &ListStyle)| {
let ty = match l {
@@ -1573,14 +1586,15 @@ impl<'d> Exporter<'d> {
ListStyle::Bulleted => "UNORDERED_LIST",
};
let listed = *l != ListStyle::None;
let first = listed && (i == 0 || lists.get(i - 1) != Some(l));
let begins = first(i, l);
let start = starts.get(i).copied().unwrap_or(0);
o(vec![
("lineType", e(ty)),
("styleId", int(0)),
("indentationLevel", int(i64::from(listed))),
("sourceDirectionality", e("AUTO")),
("listStartOffset", int(0)),
("isFirstLineOfList", b(first)),
("listStartOffset", int(i64::from(start))),
("isFirstLineOfList", b(begins)),
])
};
data.push(("lines", arr(lists.iter().enumerate().map(line).collect())));
@@ -1589,7 +1603,7 @@ impl<'d> Exporter<'d> {
self.warn("nested list levels were written as the first level");
}
let styles = std::iter::once(&td.style).chain(td.runs.iter().map(|r| &r.style));
if styles.clone().any(|st| st.decoration_look.is_some() || !st.variations.is_empty()) {
if styles.clone().any(|st| unsupported_decoration_look(st) || !st.variations.is_empty()) {
self.warn("text decoration looks and variable font settings were left out");
}
if td.path.is_some() {
@@ -1669,7 +1683,13 @@ impl<'d> Exporter<'d> {
let segments = net
.segments
.iter()
.map(|sg| network::Segment { a: sg.a, ta: (finite(sg.ta.0), finite(sg.ta.1)), b: sg.b, tb: (finite(sg.tb.0), finite(sg.tb.1)) })
.map(|sg| network::Segment {
style: 0,
a: sg.a,
ta: (finite(sg.ta.0), finite(sg.ta.1)),
b: sg.b,
tb: (finite(sg.tb.0), finite(sg.tb.1)),
})
.collect();
let regions = net
.regions
@@ -2393,6 +2413,13 @@ fn key_codes(key: &str) -> Option<(Vec<i64>, bool)> {
Some((codes, false))
}
/// Whether the decoration look has more than skip ink, which the file can't hold.
fn unsupported_decoration_look(st: &TextStyle) -> bool {
let mut look = st.look();
look.skip_ink = false;
look != Default::default()
}
/// The text style fields of a layer or of an override table entry.
fn style_fields(st: &TextStyle, f: &mut Fields) {
let line_height = match st.line_height {
@@ -2425,6 +2452,9 @@ fn style_fields(st: &TextStyle, f: &mut Fields) {
("letterSpacing", letter_spacing),
("textCase", e(case)),
("textDecoration", e(decoration)),
// Skip ink only shows on an underline; other text gets the files' default (on), the value
// the import leaves out again.
("textDecorationSkipInk", b(st.look().skip_ink || st.decoration != TextDecoration::Underline)),
]);
if let Some(url) = &st.link {
f.push(("hyperlink", o(vec![("url", s(url))])));
+1 -1
View File
@@ -680,7 +680,7 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
for r in &mut td.runs {
px(&mut r.style);
}
td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { list: td.para_list(p).list, indent: 0 }).collect();
td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { indent: 0, ..td.para_list(p) }).collect();
td.list = ListStyle::None;
}
NodeKind::Vector { network } => {
+104
View File
@@ -0,0 +1,104 @@
//! Image paint filters read from `.fig` files, on fixtures built in code with the parent module's
//! helpers.
use super::*;
/// The shared schema plus the image filter containers only these tests use.
fn image_filter_schema() -> Schema {
let text = SCHEMA
.replace(
"message Paint ",
"message FilterColorAdjust float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance\n\
message PaintFilterMessage float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance, float contrast, float brightness, float mystery\n\
message Paint ",
)
.replace("message Paint PaintType type,", "message Paint PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust, PaintType type,");
Schema::parse(&text).unwrap()
}
#[test]
fn image_filters_import_from_current_container_and_skip_ambiguous_data() {
let filter = |fields: Vec<(&'static str, Kv<'static>)>| o(fields);
let image_paint = |id: u64, name: &'static str, mut extra: Vec<(&'static str, Kv<'static>)>| {
let mut paint = vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))];
paint.append(&mut extra);
node(id, Some((1, "!")), "RECTANGLE", name, vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![o(paint)]))])
};
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
image_paint(
2,
"Current",
vec![(
"paintFilter",
filter(vec![
("exposure", f(0.25)),
("contrast", f(0.3)),
("vibrance", f(-0.4)),
("temperature", f(0.5)),
("tint", f(1.0)),
("highlights", f(-0.6)),
("shadows", f(0.7)),
]),
)],
),
image_paint(3, "Negative tint", vec![("paintFilter", filter(vec![("tint", f(-1.0)), ("contrast", f(-0.3))]))]),
image_paint(4, "Neutral", vec![]),
image_paint(5, "Explicit neutral", vec![("paintFilter", filter(vec![("exposure", f(0.0)), ("vibrance", f(0.0))]))]),
image_paint(6, "Legacy", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))]))]),
image_paint(7, "Both", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))])), ("paintFilter", filter(vec![("contrast", f(0.2))]))]),
image_paint(
8,
"Unsupported",
vec![("paintFilter", filter(vec![("brightness", f(0.4)), ("detail", f(-0.2)), ("vignette", f(0.3)), ("mystery", f(0.1))]))],
),
];
let bytes = zip_file(&canvas(&image_filter_schema(), &o(vec![("nodeChanges", arr(nodes))])));
let (doc, warnings) = import(&bytes, "ImageFilters.fig").unwrap();
let get = |name: &str| {
let node = doc.nodes().find(|node| node.name == name).unwrap();
let figcraft_doc::color::PaintKind::Image(image) = &node.fills[0].kind else { panic!("{name} is not an image fill") };
image
};
let current = get("Current");
assert!((current.filters.exposure - 0.25).abs() < 1e-6);
assert!((current.filters.contrast - 0.3).abs() < 1e-6, "observed raw contrast remains 0.3");
assert!((current.filters.saturation + 0.4).abs() < 1e-6, "vibrance maps to saturation");
assert!((current.filters.temperature - 0.5).abs() < 1e-6);
assert!((current.filters.tint + 1.0).abs() < 1e-6, "a positive (magenta) tint in the file is a negative native tint");
assert!((current.filters.highlights + 0.6).abs() < 1e-6);
assert!((current.filters.shadows - 0.7).abs() < 1e-6);
assert!((get("Negative tint").filters.tint - 1.0).abs() < 1e-6);
assert!((get("Negative tint").filters.contrast + 0.3).abs() < 1e-6);
assert_eq!(get("Neutral").filters, Default::default());
assert_eq!(get("Explicit neutral").filters, Default::default());
assert_eq!(doc.images.get(&current.image).map(|image| image.bytes.as_slice()), Some(png().as_slice()));
assert!(warnings.iter().any(|w| w.contains("legacy image filter container is unsupported")), "{warnings:?}");
assert!(warnings.iter().any(|w| w.contains("both filter containers are present")), "{warnings:?}");
for field in ["brightness", "detail", "vignette", "mystery"] {
assert!(warnings.iter().any(|w| w.contains(field)), "missing {field} warning: {warnings:?}");
}
assert_eq!(warnings.iter().filter(|w| w.contains("approximate renderer")).count(), 1, "deduplicated approximation warning: {warnings:?}");
}
#[test]
fn warnings_naming_unknown_filter_fields_are_capped() {
// A schema can name any number of fields; each nonzero unknown one is a warning of its own.
let names: Vec<&'static str> = (0..300).map(|i| &*Box::leak(format!("unknown{i}").into_boxed_str())).collect();
let fields: Vec<String> = names.iter().map(|n| format!("float {n}")).collect();
let text = SCHEMA
.replace("message Paint ", &format!("message ManyFilters {}\nmessage Paint ", fields.join(", ")))
.replace("message Paint PaintType type,", "message Paint ManyFilters paintFilter, PaintType type,");
let filters = o(names.iter().map(|n| (*n, f(0.5))).collect());
let paint = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))])), ("paintFilter", filters)]);
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
node(2, Some((1, "!")), "RECTANGLE", "Photo", vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![paint]))]),
];
let bytes = zip_file(&canvas(&Schema::parse(&text).unwrap(), &o(vec![("nodeChanges", arr(nodes))])));
let (_, warnings) = import(&bytes, "ManyFilters.fig").unwrap();
let named = warnings.iter().filter(|w| w.contains("unsupported image filter")).count();
assert!((100..=crate::rest::MAX_WARNINGS).contains(&named), "{named} of {} warnings", warnings.len());
}
+24 -4
View File
@@ -10,8 +10,16 @@ use std::fmt;
/// Deepest struct/message nesting decoded.
const MAX_DEPTH: usize = 64;
/// Most values decoded from one message (every scalar, array element and field counts).
const MAX_VALUES: usize = 20_000_000;
/// Most values decoded from one message (every scalar, array element and field counts): at least
/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a
/// large real file decodes while a small one can't expand into a huge tree, however far its
/// chunks inflate. 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is
/// lower: past it the file is refused cleanly instead of running out of memory.
const MIN_VALUES: usize = 20_000_000;
#[cfg(not(target_pointer_width = "32"))]
const MAX_VALUES: usize = 128 << 20;
#[cfg(target_pointer_width = "32")]
const MAX_VALUES: usize = 32 << 20;
/// Most definitions in a schema, and fields in one definition.
const MAX_DEFS: usize = 10_000;
const MAX_FIELDS: usize = 10_000;
@@ -284,13 +292,23 @@ impl Schema {
self.defs.iter().position(|d| d.name == name)
}
/// Decode a message of definition `root`.
/// Decode a message of definition `root`, within [`MIN_VALUES`] values.
pub fn decode_message<'s>(&'s self, root: usize, data: &[u8]) -> Result<Kv<'s>, KiwiError> {
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget: MAX_VALUES };
self.decode_message_within(root, data, MIN_VALUES)
}
/// [`Self::decode_message`] within `budget` values (see [`value_budget`]).
pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result<Kv<'s>, KiwiError> {
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget };
d.def(root, 0)
}
}
/// Most values decoded from a file whose compressed canvas is `packed` bytes.
pub(crate) fn value_budget(packed: usize) -> usize {
packed.clamp(MIN_VALUES, MAX_VALUES)
}
struct Decoder<'s, 'b> {
schema: &'s Schema,
r: Reader<'b>,
@@ -327,6 +345,8 @@ impl<'s> Decoder<'s, '_> {
loop {
let id = self.r.var_uint()?;
if id == 0 {
// Grown by doubling: trimmed, a large file's tree takes a good part less.
out.shrink_to_fit();
return Ok(Kv::Object(out));
}
let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?;
+1191 -121
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,146 @@
//! Connector snapshot flattening stays within the node and depth budgets.
use super::{MAX_DEPTH, Mapper, View};
use crate::fig::kiwi::Kv;
use crate::rest::MAX_NODES;
use figcraft_doc::geom::Affine;
fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn guid(local_id: u64) -> Kv<'static> {
obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))])
}
fn path_blob() -> Vec<u8> {
let mut bytes = vec![1];
bytes.extend(0.0f32.to_le_bytes());
bytes.extend(0.0f32.to_le_bytes());
bytes.push(2);
bytes.extend(20.0f32.to_le_bytes());
bytes.extend(10.0f32.to_le_bytes());
bytes
}
fn record(local_id: u64, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
fields.push(("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))])));
obj(fields)
}
fn message() -> Kv<'static> {
let line = record(
0,
vec![
(
"transform",
obj(vec![
("m00", Kv::Float(1.0)),
("m01", Kv::Float(0.0)),
("m02", Kv::Float(0.0)),
("m10", Kv::Float(0.0)),
("m11", Kv::Float(1.0)),
("m12", Kv::Float(0.0)),
]),
),
("size", obj(vec![("x", Kv::Float(20.0)), ("y", Kv::Float(10.0))])),
("fillGeometry", Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])])),
],
);
let text = record(
1,
vec![
(
"transform",
obj(vec![
("m00", Kv::Float(1.0)),
("m01", Kv::Float(0.0)),
("m02", Kv::Float(0.0)),
("m10", Kv::Float(0.0)),
("m11", Kv::Float(1.0)),
("m12", Kv::Float(0.0)),
]),
),
("size", obj(vec![("x", Kv::Float(50.0)), ("y", Kv::Float(20.0))])),
],
);
let background = record(
2,
vec![
(
"transform",
obj(vec![
("m00", Kv::Float(1.0)),
("m01", Kv::Float(0.0)),
("m02", Kv::Float(0.0)),
("m10", Kv::Float(0.0)),
("m11", Kv::Float(1.0)),
("m12", Kv::Float(0.0)),
]),
),
("size", obj(vec![("x", Kv::Float(60.0)), ("y", Kv::Float(30.0))])),
],
);
let line_style = record(0, vec![]);
let text_style = record(1, vec![("textData", obj(vec![("characters", Kv::Str("budget label".into()))]))]);
let background_style = record(2, vec![("fillPaints", Kv::Array(vec![]))]);
obj(vec![
(
"nodeChanges",
Kv::Array(vec![obj(vec![
("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])),
("type", Kv::Enum("CONNECTOR")),
("name", Kv::Str("budget connector".into())),
("connectorType", Kv::Enum("MANUAL")),
("connectorVersion", Kv::Float(1.0)),
("connectorLineStyle", Kv::Enum("STRAIGHT")),
("derivedImmutableFrameData", obj(vec![("version", Kv::Float(1.0)), ("overrides", Kv::Array(vec![line, text, background]))])),
("nodeGenerationData", obj(vec![("overrides", Kv::Array(vec![line_style, text_style, background_style]))])),
])]),
),
("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])),
])
}
#[test]
fn connector_snapshot_respects_last_and_zero_child_slots() {
let message = message();
let node = message.get("nodeChanges").unwrap().list().first().unwrap();
let view = View::of(node);
let mut mapper = Mapper::new(&message, MAX_NODES);
mapper.count = MAX_NODES - 1;
let children = mapper.connector_snapshot_children(&view, "1:123");
assert_eq!(children.len(), 1, "the final available slot preserves the saved vector");
assert_eq!(children[0]["type"], "VECTOR");
assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit");
assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10");
assert_eq!(mapper.count, MAX_NODES);
assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped")));
let mut mapper = Mapper::new(&message, MAX_NODES);
mapper.count = MAX_NODES;
let children = mapper.connector_snapshot_children(&view, "1:123");
assert!(children.is_empty());
assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded");
assert!(mapper.warnings.iter().any(|warning| warning.contains("connector snapshot children were skipped")));
}
#[test]
fn connector_snapshot_flattens_vector_at_depth_and_node_limits() {
let message = message();
let mut mapper = Mapper::new(&message, MAX_NODES);
let depth_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector");
assert_eq!(depth_limited["type"], "VECTOR");
assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty());
assert!(depth_limited.get("children").is_none());
assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children"));
let mut mapper = Mapper::new(&message, MAX_NODES);
mapper.count = MAX_NODES - 1;
let budget_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, 0).expect("last allowed connector");
assert_eq!(budget_limited["type"], "VECTOR");
assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty());
assert!(budget_limited.get("children").is_none());
assert_eq!(mapper.count, MAX_NODES);
assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children"));
}
@@ -0,0 +1,27 @@
//! Malformed image paint filters are skipped with a warning.
use super::*;
#[test]
fn malformed_non_finite_filter_is_skipped_with_warning() {
let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![("exposure", Kv::Float(f32::NAN))]))]);
let mut warnings = Vec::new();
let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap();
assert!(value.get("filters").is_none(), "{value}");
assert!(warnings.iter().any(|warning| warning.contains("malformed image filter `exposure`")), "{warnings:?}");
}
#[test]
fn boolean_filter_values_are_malformed_even_when_false() {
for (field, value, expected_warning) in [
("exposure", Kv::Bool(true), "malformed image filter `exposure`"),
("exposure", Kv::Bool(false), "malformed image filter `exposure`"),
("mystery", Kv::Bool(false), "malformed unsupported image filter `mystery`"),
] {
let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![(field, value)]))]);
let mut warnings = Vec::new();
let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap();
assert!(value.get("filters").is_none(), "{value}");
assert!(warnings.iter().any(|warning| warning.contains(expected_warning)), "{warnings:?}");
}
}
+82 -8
View File
@@ -6,12 +6,15 @@
//! zstd: a binary schema in the kiwi format ([`kiwi`]), then a message encoded with it. Every file
//! carries its own schema, so the message is decoded generically and read by field name: a list
//! of node changes (each with its id, parent id and position among its siblings) that make up the
//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`]). [`map`]
//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`], and the bytes
//! of images that travel inside the message, [`embedded_images`]). [`map`]
//! turns that into the REST API file JSON shape and the REST importer builds the document, so both
//! imports share one mapping. Layer kinds the editor doesn't have (sticky notes, connectors,
//! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped
//! with a warning; malformed input never panics.
#[cfg(test)]
mod connector_label_tests;
mod export;
#[cfg(test)]
mod export_tests;
@@ -21,8 +24,22 @@ pub mod kiwi;
mod map;
mod network;
#[cfg(test)]
mod slot_tests;
#[cfg(test)]
mod source_identity_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod text_paragraph_tests;
mod text_path;
#[cfg(test)]
mod text_path_tests;
#[cfg(test)]
mod text_skip_ink_tests;
#[cfg(test)]
mod text_style_tests;
use std::collections::BTreeMap;
use std::io::Read;
use figcraft_doc::Document;
@@ -30,7 +47,7 @@ use figcraft_doc::Document;
pub use export::export;
use crate::FormatError;
use kiwi::Schema;
use kiwi::{Kv, Schema};
/// Largest `canvas.fig` member read, and largest decompressed chunk.
const MAX_CANVAS: u64 = 256 * 1024 * 1024;
@@ -39,6 +56,18 @@ const MAX_CHUNK: u64 = 512 * 1024 * 1024;
const MAX_IMAGE: u64 = 256 * 1024 * 1024;
/// Most chunks read from `canvas.fig`.
const MAX_CHUNKS: usize = 16;
/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its
/// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances
/// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the
/// budget follows what the file takes, not what it unpacks to: a small file can't claim a large
/// count, however it's built; a large one gets room.
const PACKED_BYTES_PER_LAYER: usize = 32;
const MAX_FIG_NODES: usize = 2_000_000;
/// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes.
pub(crate) fn layer_budget(packed: usize) -> usize {
(packed / PACKED_BYTES_PER_LAYER).clamp(crate::rest::MAX_NODES, MAX_FIG_NODES)
}
/// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas.
pub fn is_fig(bytes: &[u8]) -> bool {
@@ -57,14 +86,24 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option<Result<(Document, Vec<Stri
/// Build a document from a `.fig` file. Returns it with the warnings about what was skipped or
/// approximated. `name`: the document name when the file doesn't carry one.
pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), FormatError> {
let archive = Archive::open(bytes)?;
let (schema, message) = decode_canvas(&archive.canvas)?;
let mut archive = Archive::open(bytes)?;
// The budgets follow the compressed canvas, which the file itself bounds (its zip member may
// inflate too).
let packed = archive.canvas.len().min(bytes.len());
// Each stage's input is dropped as soon as the next one is built: in a large file every one
// of them takes gigabytes, and holding them all at once runs out of memory.
let (schema, message) = decode_canvas(&std::mem::take(&mut archive.canvas))?;
let root = schema.find("Message").ok_or_else(|| corrupt("the schema has no Message definition"))?;
let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?;
let msg = schema.decode_message_within(root, &message, kiwi::value_budget(packed)).map_err(|e| corrupt(e.to_string()))?;
let max_nodes = layer_budget(packed);
drop(message);
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
let (json, mut warnings) = map::to_rest(&msg, file_name);
let images = |r: &str| archive.image(r);
let (doc, more) = crate::rest::import(&json, &images)?;
let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
// The message goes before the layers are built, so the images it carries are copied out first.
let embedded: BTreeMap<String, Vec<u8>> = embedded_images(&msg).into_iter().map(|(hash, bytes)| (hash, bytes.to_vec())).collect();
drop(msg);
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned());
let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
warnings.extend(more.into_iter().filter_map(map::adapt_warning));
Ok((doc, warnings))
}
@@ -94,6 +133,41 @@ fn corrupt(msg: impl Into<String>) -> FormatError {
FormatError::Corrupt(format!(".fig file: {}", msg.into()))
}
/// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's
/// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed
/// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The
/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. The
/// bytes stay borrowed from the message.
fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap<String, &'m [u8]> {
let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
let mut found = BTreeMap::new();
let mut pending = vec![msg];
while let Some(value) = pending.pop() {
match value {
Kv::Object(fields) => {
if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) {
found.entry(hash).or_insert(bytes);
}
pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field));
}
Kv::Array(items) => pending.extend(items),
_ => {}
}
}
found
}
/// The hash and bytes of an `Image` value whose `dataBlob` points at a usable blob.
fn embedded_image<'k>(image: &Kv, blobs: &'k [Kv]) -> Option<(String, &'k [u8])> {
let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?;
let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None };
let bytes = blobs.get(usize::try_from(*index).ok()?)?.get("bytes").and_then(Kv::bytes)?;
if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE {
return None;
}
Some((hash.iter().map(|b| format!("{b:02x}")).collect(), bytes))
}
/// The parts of a `.fig` archive the import reads.
struct Archive<'b> {
canvas: Vec<u8>,
+4 -3
View File
@@ -17,6 +17,7 @@ pub(super) struct Vertex {
}
pub(super) struct Segment {
pub style: u32,
pub a: usize,
pub ta: (f64, f64),
pub b: usize,
@@ -76,8 +77,8 @@ pub(super) fn decode(b: &[u8]) -> Option<Network> {
net.vertices.push(Vertex { style: r.u32()?, x: r.f32()?, y: r.f32()? });
}
for _ in 0..ns {
r.u32()?;
net.segments.push(Segment { a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) });
let style = r.u32()?;
net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) });
}
let mut entries = 0usize;
for _ in 0..nr {
@@ -110,7 +111,7 @@ pub(super) fn encode(net: &Network) -> Vec<u8> {
fl(v.y, &mut out);
}
for s in &net.segments {
u(0, &mut out);
u(s.style, &mut out);
u(s.a as u32, &mut out);
fl(s.ta.0, &mut out);
fl(s.ta.1, &mut out);
+225
View File
@@ -0,0 +1,225 @@
//! Component slot content from `.fig` files: SLOT definitions, the frames that reference them and
//! the content subtrees that instances assign, on synthetic messages.
use serde_json::Value;
use super::kiwi::Kv;
use super::map::to_rest;
fn g(local: u32) -> Kv<'static> {
Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))])
}
fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn node(local: u32, kind: &'static str, parent: Option<u32>, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", Kv::Str("Synthetic".into()))];
if let Some(parent) = parent {
all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str("!".into()))])));
}
all.append(&mut fields);
o(all)
}
fn mapped(msg: &Kv<'_>) -> Value {
to_rest(msg, "Synthetic resources", crate::rest::MAX_NODES).0
}
fn named_id<'b>(v: &'b Value, id: &str) -> Option<&'b Value> {
if v.get("id").and_then(Value::as_str) == Some(id) {
return Some(v);
}
v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| named_id(v, id))
}
fn changes<'a>(msg: &'a mut Kv<'static>) -> &'a mut Vec<Kv<'static>> {
let Kv::Object(fields) = msg else { panic!() };
let (_, Kv::Array(rows)) = fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!() };
rows
}
fn slot_message(content: Option<u32>, preferred: bool) -> Kv<'static> {
let slot_ref = o(vec![
("variableField", Kv::Enum("SLOT_CONTENT_ID")),
("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])),
]);
let mut instance_fields = vec![("symbolData", o(vec![("symbolID", g(10))]))];
if let Some(content) = content {
instance_fields.push((
"componentPropAssignments",
Kv::Array(vec![o(vec![
("defID", g(70)),
("value", o(vec![])),
(
"varValue",
o(vec![
("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])),
("dataType", Kv::Enum("SLOT_CONTENT_ID")),
("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")),
]),
),
])]),
));
}
let mut definition = vec![("id", g(70)), ("name", Kv::Str("Content".into())), ("type", Kv::Enum("SLOT"))];
if preferred {
definition.push((
"preferredValues",
o(vec![("instanceSwapValues", Kv::Array(vec![o(vec![("type", Kv::Enum("COMPONENT")), ("key", Kv::Str("unresolved-key".into()))])]))]),
));
}
o(vec![(
"nodeChanges",
Kv::Array(vec![
node(1, "DOCUMENT", None, vec![]),
node(2, "CANVAS", Some(1), vec![]),
node(3, "CANVAS", Some(1), vec![("internalOnly", Kv::Bool(true))]),
node(10, "SYMBOL", Some(2), vec![("componentPropDefs", Kv::Array(vec![o(definition)]))]),
node(12, "FRAME", Some(10), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]),
node(13, "RECTANGLE", Some(12), vec![]),
node(20, "INSTANCE", Some(2), instance_fields),
node(30, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]),
node(31, "RECTANGLE", Some(30), vec![]),
node(32, "ELLIPSE", Some(30), vec![]),
node(33, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]),
]),
)])
}
#[test]
fn slot_definition_reference_and_arbitrary_override_children_are_imported() {
let rest = mapped(&slot_message(Some(30), false));
let main = named_id(&rest["document"], "9:10").unwrap();
assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT");
let main_slot = named_id(&rest["document"], "9:12").unwrap();
assert_eq!(main_slot["componentPropertyReferences"]["slot"], "Content#9:70");
let slot = named_id(&rest["document"], "I9:20;9:12").unwrap();
assert!(slot.get("componentPropertyReferences").is_none());
assert_eq!(slot[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true);
let kids = slot["children"].as_array().unwrap();
assert_eq!(kids.len(), 2);
assert_eq!(kids[0]["id"], "I9:20;9:31");
assert_eq!(kids[1]["id"], "I9:20;9:32");
let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let main = doc
.node(*doc.pages.first().unwrap())
.unwrap()
.children
.iter()
.find_map(|id| doc.node(*id).filter(|n| n.name == "Synthetic" && matches!(n.kind, figcraft_doc::NodeKind::Component { .. })))
.unwrap();
assert!(
matches!(&main.kind, figcraft_doc::NodeKind::Component { props, .. } if matches!(props.get("Content"), Some(figcraft_doc::PropDef::Slot { preferred }) if preferred.is_empty()))
);
let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap();
let slot = doc.node(instance.children[0]).unwrap();
assert!(!slot.prop_refs.contains_key("slot"));
assert_eq!(slot.source, Some(main.children[0]));
assert!(slot.overrides.contains("slot"));
assert_eq!(slot.children.len(), 2);
assert!(slot.children.iter().all(|id| doc.node(*id).is_some_and(|child| child.source.is_none())));
let (public, _) = crate::rest::import(&rest, &crate::rest::no_images).unwrap();
let public_instance = public.ids().filter_map(|id| public.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap();
let public_slot = public.node(public_instance.children[0]).unwrap();
assert!(!public_slot.overrides.contains("slot"));
}
#[test]
fn nested_instance_in_own_slot_content_keeps_its_own_source_tree() {
let mut msg = slot_message(Some(30), false);
let rows = changes(&mut msg);
let slot_frame = rows.iter_mut().find(|row| row.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(31.0)).unwrap();
let Kv::Object(fields) = slot_frame else { panic!() };
fields.iter_mut().find(|(key, _)| *key == "type").unwrap().1 = Kv::Enum("FRAME");
rows.extend([
node(34, "INSTANCE", Some(31), vec![("symbolData", o(vec![("symbolID", g(40))]))]),
node(40, "SYMBOL", Some(2), vec![]),
node(41, "FRAME", Some(40), vec![]),
node(42, "RECTANGLE", Some(41), vec![]),
]);
let rest = mapped(&msg);
let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let outer = doc
.ids()
.filter_map(|id| doc.node(id))
.find(|n| {
matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })
&& !doc.ancestors(n.id).iter().any(|a| doc.node(*a).is_some_and(|p| matches!(p.kind, figcraft_doc::NodeKind::Instance { .. })))
})
.unwrap();
let slot = doc.node(outer.children[0]).unwrap();
let own_frame = slot.children.iter().filter_map(|id| doc.node(*id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame)).unwrap();
assert!(own_frame.source.is_none());
let nested = slot
.children
.iter()
.filter_map(|id| doc.node(*id))
.flat_map(|n| n.children.iter())
.filter_map(|id| doc.node(*id))
.find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. }))
.unwrap();
let figcraft_doc::NodeKind::Instance { component, .. } = nested.kind else { panic!("not an instance") };
assert_eq!(nested.source, Some(component));
let main_frame = doc.node(doc.node(component).unwrap().children[0]).unwrap();
let expanded_frame = doc.node(nested.children[0]).unwrap();
assert_eq!(expanded_frame.source, Some(main_frame.id));
assert_eq!(expanded_frame.children.len(), 1);
assert_eq!(doc.node(expanded_frame.children[0]).unwrap().source, Some(main_frame.children[0]));
}
#[test]
fn outer_slot_assignment_wins_over_a_nested_instances_own() {
let mut msg = slot_message(Some(30), false);
let assignment = |content: u32| {
Kv::Array(vec![o(vec![
("defID", g(70)),
("value", o(vec![])),
(
"varValue",
o(vec![
("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])),
("dataType", Kv::Enum("SLOT_CONTENT_ID")),
("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")),
]),
),
])])
};
// The nested instance inside the main assigns the empty slot content; the outer instance's content wins.
let slot_ref = o(vec![
("variableField", Kv::Enum("SLOT_CONTENT_ID")),
("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])),
]);
changes(&mut msg).extend([
node(14, "INSTANCE", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), ("componentPropAssignments", assignment(33))]),
node(40, "SYMBOL", Some(2), vec![]),
node(41, "FRAME", Some(40), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]),
]);
let rest = mapped(&msg);
let slot = named_id(&rest["document"], "I9:20;9:14;9:41").unwrap();
assert_eq!(slot["children"].as_array().map_or(0, Vec::len), 2, "{slot}");
}
#[test]
fn unassigned_slot_keeps_default_and_bad_content_target_warns() {
let rest = mapped(&slot_message(None, false));
let slot = named_id(&rest["document"], "I9:20;9:12").unwrap();
assert_eq!(slot["children"].as_array().unwrap().len(), 1);
assert!(slot.get("overrides").is_none());
let (rest, warnings, _) = to_rest(&slot_message(Some(999), false), "Synthetic", crate::rest::MAX_NODES);
assert!(warnings.iter().any(|w| w.contains("slot content assignment target is missing or is not slot content")), "{warnings:?}");
let slot = named_id(&rest["document"], "I9:20;9:12").unwrap();
assert_eq!(slot["children"].as_array().unwrap().len(), 1);
let (rest, warnings, _) = to_rest(&slot_message(Some(33), true), "Synthetic", crate::rest::MAX_NODES);
let main = named_id(&rest["document"], "9:10").unwrap();
assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT");
assert!(warnings.iter().any(|w| w.contains("preferred slot component keys cannot be resolved")), "{warnings:?}");
let empty = named_id(&rest["document"], "I9:20;9:12").unwrap();
assert_eq!(empty["children"].as_array().map_or(0, Vec::len), 0);
assert_eq!(empty[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true);
}
File diff suppressed because it is too large. Load diff
+110 -7
View File
@@ -3,13 +3,13 @@
//! fixtures built in code with the parent module's helpers.
use figcraft_doc::proto::Action;
use figcraft_doc::{HandleMirroring, StrokeCap, WindingRule};
use figcraft_doc::{HandleMirroring, StrokeCap, StrokeJoin, WindingRule};
use super::super::network::{self, Network, Region, Segment, Vertex};
use super::*;
fn seg(a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment {
Segment { a, ta, b, tb }
Segment { style: 0, a, ta, b, tb }
}
fn vtx(style: u32, x: f64, y: f64) -> Vertex {
@@ -19,7 +19,7 @@ fn vtx(style: u32, x: f64, y: f64) -> Vertex {
/// A square with one curved side and a rounded, mirrored corner, filled even-odd.
fn square_blob() -> Vec<u8> {
network::encode(&Network {
vertices: vec![vtx(0, 0.0, 0.0), vtx(1, 50.0, 0.0), vtx(0, 50.0, 50.0), vtx(0, 0.0, 50.0)],
vertices: vec![vtx(0, 0.0, 0.0), vtx(1, 50.0, 0.0), vtx(2, 50.0, 50.0), vtx(3, 0.0, 50.0)],
segments: vec![
seg(0, 1, (10.0, 0.0), (-10.0, 0.0)),
seg(1, 2, (0.0, 0.0), (0.0, 0.0)),
@@ -39,6 +39,15 @@ fn arrow_blob() -> Vec<u8> {
})
}
/// An open path with one missing style ID and one unsupported point join.
fn join_fallback_blob() -> Vec<u8> {
network::encode(&Network {
vertices: vec![vtx(4, 0.0, 0.0), vtx(5, 50.0, 0.0), vtx(0, 100.0, 0.0)],
segments: vec![seg(0, 1, (0.0, 0.0), (0.0, 0.0)), seg(1, 2, (0.0, 0.0), (0.0, 0.0))],
regions: vec![],
})
}
fn vector_data(blob: u64, nx: f64, ny: f64, styles: Vec<Kv<'static>>) -> Kv<'static> {
o(vec![("vectorNetworkBlob", u(blob)), ("normalizedSize", xy(nx, ny)), ("styleOverrideTable", arr(styles))])
}
@@ -121,13 +130,24 @@ fn tail_message() -> Kv<'static> {
("transform", at(700.0, 0.0)),
("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])),
("fillGeometry", outline(0)),
("strokeJoin", e("MITER")),
("dashPattern", arr(vec![f(4.0), f(2.0)])),
(
"vectorData",
vector_data(
1,
100.0,
50.0,
vec![o(vec![("styleID", u(1)), ("cornerRadius", f(4.0)), ("handleMirroring", e("ANGLE_AND_LENGTH"))])],
vec![
o(vec![
("styleID", u(1)),
("cornerRadius", f(4.0)),
("handleMirroring", e("ANGLE_AND_LENGTH")),
("strokeJoin", e("BEVEL")),
]),
o(vec![("styleID", u(2)), ("strokeJoin", e("MITER"))]),
o(vec![("styleID", u(3)), ("strokeJoin", e("ROUND"))]),
],
),
),
],
@@ -146,6 +166,19 @@ fn tail_message() -> Kv<'static> {
("vectorData", vector_data(2, 100.0, 0.0, vec![o(vec![("styleID", u(2)), ("strokeCap", e("ARROW_LINES"))])])),
],
),
node(
42,
Some((1, "#")),
"VECTOR",
"Join Fallbacks",
vec![
("size", xy(100.0, 10.0)),
("transform", at(0.0, 330.0)),
stroke(),
("strokeJoin", e("MITER")),
("vectorData", vector_data(4, 100.0, 10.0, vec![o(vec![("styleID", u(5)), ("strokeJoin", u(999))])])),
],
),
node(
32,
Some((1, "#")),
@@ -223,7 +256,7 @@ fn tail_message() -> Kv<'static> {
];
let mut broken = square_blob();
broken.truncate(30);
let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken];
let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken, join_fallback_blob()];
o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(blobs.into_iter().map(|b| o(vec![("bytes", Kv::Bytes(b))])).collect()))])
}
@@ -247,6 +280,11 @@ fn vectors_import_as_editable_networks() {
assert_eq!(pts, [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
assert_eq!((wave.vertices[1].radius, wave.vertices[1].mirror), (4.0, Some(HandleMirroring::AngleAndLength)));
assert_eq!(wave.vertices[0].mirror, None);
assert_eq!(
wave.vertices.iter().map(|v| v.join).collect::<Vec<_>>(),
[None, Some(StrokeJoin::Bevel), Some(StrokeJoin::Miter), Some(StrokeJoin::Round)]
);
assert_eq!(named(&doc, "Wave").stroke.join, StrokeJoin::Miter);
assert_eq!((wave.segments[0].ta, wave.segments[0].tb), ((20.0, 0.0), (-20.0, 0.0)));
assert_eq!(wave.regions.len(), 1);
assert_eq!((wave.regions[0].loops.clone(), wave.regions[0].winding), (vec![vec![0, 1, 2, 3]], WindingRule::EvenOdd));
@@ -260,6 +298,48 @@ fn vectors_import_as_editable_networks() {
// A damaged network falls back to the outline.
assert_eq!(network_of(&doc, "Broken").regions.len(), 1);
assert!(warnings.iter().any(|w| w.contains("damaged vector networks import as outlines")), "{warnings:?}");
let fallback = network_of(&doc, "Join Fallbacks");
assert!(fallback.vertices.iter().all(|v| v.join.is_none()));
assert_eq!(named(&doc, "Join Fallbacks").stroke.join, StrokeJoin::Miter);
assert!(
warnings
.iter()
.any(|w| w.contains("vector points refer to point settings missing from the file; they use the layer's joins and plain corners")),
"{warnings:?}"
);
assert!(warnings.iter().any(|w| w.contains("unknown stroke joins use the layer join")), "{warnings:?}");
assert!(warnings.iter().any(|w| w.contains("dashed or variable-width strokes render with the layer join")), "{warnings:?}");
}
#[test]
fn per_segment_stroke_styles_are_reported() {
let styled_import = |segment_style: u32| {
let mut blob = square_blob();
// The first segment's style id follows the counts and the vertices (12 bytes each).
blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&segment_style.to_le_bytes());
let net = network::decode(&blob).unwrap();
assert_eq!((net.segments.len(), net.segments[0].style), (4, segment_style));
let mut message = tail_message();
let Kv::Object(fields) = &mut message else { panic!() };
let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() };
blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]);
let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap();
assert_eq!(network_of(&doc, "Wave").segments.len(), 4);
warnings
};
assert!(network::decode(&square_blob()).unwrap().segments.iter().all(|s| s.style == 0));
let plain = import(&tail_canvas(), "Tail").unwrap().1;
assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}");
let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke");
let missing = |w: &String| w.contains("some vector segments refer to style settings missing from the file; they use the layer's stroke");
// Style 1 has a row in the layer's override table; its contents are not applied.
let known = styled_import(1);
assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}");
assert!(!known.iter().any(missing), "{known:?}");
// Style 7 names no row at all.
let unknown = styled_import(7);
assert_eq!(unknown.iter().filter(|w| missing(w)).count(), 1, "{unknown:?}");
assert!(!unknown.iter().any(uninterpreted), "{unknown:?}");
}
#[test]
@@ -366,6 +446,29 @@ fn damaged_network_blobs_never_panic() {
}
}
#[test]
fn segment_styles_round_trip_without_changing_geometry() {
let original = Network {
vertices: vec![vtx(0, 2.0, 3.0), vtx(0, 10.0, 20.0), vtx(0, 40.0, 50.0), vtx(0, 60.0, 70.0)],
segments: vec![
seg(0, 1, (1.0, 2.0), (3.0, 4.0)),
Segment { style: 17, a: 1, ta: (5.0, 6.0), b: 2, tb: (7.0, 8.0) },
Segment { style: 27, a: 2, ta: (9.0, 10.0), b: 3, tb: (11.0, 12.0) },
],
regions: vec![],
};
let bytes = network::encode(&original);
let decoded = network::decode(&bytes).unwrap();
assert_eq!(decoded.segments.len(), 3);
for (got, want) in decoded.segments.iter().zip(&original.segments) {
assert_eq!(got.style, want.style);
assert_eq!((got.a, got.ta, got.b, got.tb), (want.a, want.ta, want.b, want.tb));
}
for cut in 0..bytes.len() {
assert!(network::decode(&bytes[..cut]).is_none(), "truncated at {cut}");
}
}
#[test]
fn mangled_tail_files_never_panic() {
let c = tail_canvas();
@@ -392,8 +495,8 @@ fn mangled_tail_files_never_panic() {
m[i] = r.next() as u8;
}
if let Ok(kv) = sc.decode_message(root, &m) {
let (json, _) = map::to_rest(&kv, "x");
let _ = crate::rest::import(&json, &crate::rest::no_images);
let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES);
let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES);
}
}
}
+286 -6
View File
@@ -33,12 +33,13 @@ enum NavigationType NAVIGATE OVERLAY
enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE
enum EasingType IN_CUBIC OUT_CUBIC
enum ConstraintType MIN CENTER MAX STRETCH SCALE
enum LeadingTrim NONE CAP_HEIGHT
struct GUID uint sessionID, uint localID
struct Color float r, float g, float b, float a
struct Vector float x, float y
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
struct ColorStop Color color, float position
message Image byte[] hash, string name
message Image byte[] hash, string name, uint dataBlob
message AssetRef string key, string version
message VariableID GUID guid, AssetRef assetRef
message PropRefValue GUID defId
@@ -72,7 +73,7 @@ message Event InteractionType interactionType
message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions
message Interaction Event event, Action[] actions
message StartingPoint string name, string position
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern
message Blob byte[] bytes
message Message NodeChange[] nodeChanges, Blob[] blobs
";
@@ -283,6 +284,7 @@ fn message() -> Kv<'static> {
("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])),
("fontSize", f(16.0)),
("fontName", font("Regular")),
("leadingTrim", e("CAP_HEIGHT")),
("textData", text),
],
),
@@ -390,6 +392,175 @@ fn named<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::N
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"))
}
/// The shared schema plus the grid sizing definitions only these tests use.
fn grid_schema() -> Schema {
const NODE_FIELDS: &str = ", GridTrackMap gridColumns, GridTrackMap gridRows, GridTrackSizeMap gridColumnsSizing, GridTrackSizeMap gridRowsSizing, GUID gridColumnAnchor, GUID gridRowAnchor, uint gridColumnSpan, uint gridRowSpan, float gridColumnGap, float gridRowGap";
const TYPES: &str = "
enum GridTrackSizingType FLEX FIXED HUG UNKNOWN
message GridTrackSizeFunction GridTrackSizingType type, float value
message GridTrackSize GridTrackSizeFunction minSizing, GridTrackSizeFunction maxSizing
message GridTrackSizeEntry GUID id, GridTrackSize trackSize
message GridTrackSizeMap GridTrackSizeEntry[] entries
message GridTrackEntry GUID id, string position
message GridTrackMap GridTrackEntry[] entries
message Blob ";
let text = SCHEMA.replace("enum StackMode NONE HORIZONTAL VERTICAL\n", "enum StackMode NONE HORIZONTAL VERTICAL GRID\n").replacen(
"\nmessage Blob ",
&format!("{NODE_FIELDS}{TYPES}"),
1,
);
Schema::parse(&text).unwrap()
}
fn track_size(kind: &'static str, value: f64) -> Kv<'static> {
let sizing = || o(vec![("type", e(kind)), ("value", f(value))]);
o(vec![("minSizing", sizing()), ("maxSizing", sizing())])
}
fn ranged_track_size(min: Kv<'static>, max: Kv<'static>) -> Kv<'static> {
o(vec![("minSizing", min), ("maxSizing", max)])
}
fn track_map_entry(id: u64, size: Kv<'static>) -> Kv<'static> {
o(vec![("id", g(id)), ("trackSize", size)])
}
fn positioned_track(id: u64, position: &'static str) -> Kv<'static> {
o(vec![("id", g(id)), ("position", s(position))])
}
fn unpositioned_track(id: u64) -> Kv<'static> {
o(vec![("id", g(id))])
}
#[test]
fn grid_tracks_import_by_guid_and_keep_track_order() {
let cols = arr(vec![positioned_track(13, "$"), unpositioned_track(10), positioned_track(12, "#"), unpositioned_track(11)]);
let rows = arr(vec![positioned_track(21, "\""), positioned_track(20, "!")]);
// Sizing entries intentionally differ from track order. Their GUIDs define the join.
let col_sizes = arr(vec![
track_map_entry(12, track_size("FLEX", 1.0)),
track_map_entry(10, track_size("FIXED", 120.0)),
track_map_entry(13, track_size("FLEX", 2.0)),
track_map_entry(11, track_size("HUG", 80.0)),
]);
let row_sizes = arr(vec![track_map_entry(21, track_size("HUG", 30.0)), track_map_entry(20, track_size("FIXED", 50.0))]);
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
node(
2,
Some((1, "!")),
"FRAME",
"Grid",
vec![
("size", xy(600.0, 300.0)),
("transform", at(0.0, 0.0)),
("stackMode", e("GRID")),
("gridColumns", o(vec![("entries", cols)])),
("gridRows", o(vec![("entries", rows)])),
("gridColumnsSizing", o(vec![("entries", col_sizes)])),
("gridRowsSizing", o(vec![("entries", row_sizes)])),
("gridColumnGap", f(10.0)),
("gridRowGap", f(12.0)),
],
),
node(
3,
Some((2, "!")),
"RECTANGLE",
"Hug child",
vec![
("size", xy(80.0, 30.0)),
("transform", at(0.0, 0.0)),
("gridColumnAnchor", g(11)),
("gridRowAnchor", g(21)),
("gridColumnSpan", u(2)),
("gridRowSpan", u(1)),
],
),
];
let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))]));
let (doc, warnings) = import(&bytes, "Grid tracks").unwrap();
let grid = named(&doc, "Grid").layout.as_ref().unwrap();
assert_eq!((grid.mode, grid.grid_columns, grid.grid_rows, grid.gap, grid.cross_gap), (figcraft_doc::LayoutMode::Grid, 4, 2, 10.0, 12.0));
assert_eq!(
grid.column_tracks,
vec![
figcraft_doc::GridTrack::fixed(120.0),
figcraft_doc::GridTrack::hug(),
figcraft_doc::GridTrack::fill(1.0),
figcraft_doc::GridTrack::fill(2.0),
]
);
assert_eq!(grid.row_tracks, vec![figcraft_doc::GridTrack::fixed(50.0), figcraft_doc::GridTrack::hug()]);
let child = &named(&doc, "Hug child").child_layout;
assert_eq!((child.grid_column, child.grid_row, child.grid_column_span, child.grid_row_span), (Some(1), Some(1), 2, 1));
assert!(!warnings.iter().any(|w| w.contains("grid track")), "{warnings:?}");
}
#[test]
fn malformed_grid_tracks_warn_and_keep_equal_track_fallbacks() {
let tracks = arr(vec![positioned_track(30, "!"), positioned_track(31, "\""), positioned_track(32, "#"), positioned_track(33, "$")]);
let sizing = arr(vec![
track_map_entry(30, track_size("FIXED", -1.0)),
track_map_entry(31, ranged_track_size(o(vec![("type", e("FLEX")), ("value", f(1.0))]), o(vec![("type", e("FLEX")), ("value", f(2.0))]))),
track_map_entry(33, track_size("UNKNOWN", 5.0)),
track_map_entry(34, track_size("FLEX", 3.0)),
]);
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
node(
2,
Some((1, "!")),
"FRAME",
"Grid",
vec![
("size", xy(100.0, 100.0)),
("transform", at(0.0, 0.0)),
("stackMode", e("GRID")),
("gridColumns", o(vec![("entries", tracks)])),
("gridColumnsSizing", o(vec![("entries", sizing)])),
],
),
];
let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))]));
let (doc, warnings) = import(&bytes, "Malformed grid tracks").unwrap();
let layout = named(&doc, "Grid").layout.as_ref().unwrap();
assert_eq!(layout.column_tracks, vec![figcraft_doc::GridTrack::default(); 4]);
assert!(warnings.iter().any(|w| w.contains("grid track sizing is unsupported or malformed")), "{warnings:?}");
assert!(warnings.iter().any(|w| w.contains("stale or invalid grid track sizing entries")), "{warnings:?}");
}
#[test]
fn oversized_grid_track_lists_warn_and_use_default_tracks() {
let oversized: Vec<Kv<'static>> = (0..1_001).map(|i| unpositioned_track(100 + i)).collect();
let sizing = vec![track_map_entry(100, track_size("FIXED", 40.0))];
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
node(
2,
Some((1, "!")),
"FRAME",
"Grid",
vec![
("size", xy(100.0, 100.0)),
("transform", at(0.0, 0.0)),
("stackMode", e("GRID")),
("gridColumns", o(vec![("entries", arr(oversized))])),
("gridColumnsSizing", o(vec![("entries", arr(sizing))])),
],
),
];
let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))]));
let (doc, warnings) = import(&bytes, "Oversized grid tracks").unwrap();
let layout = named(&doc, "Grid").layout.as_ref().unwrap();
assert!(layout.column_tracks.is_empty(), "{:?}", layout.column_tracks.len());
assert!(warnings.iter().any(|w| w.contains("grid track list exceeds 1000 entries")), "{warnings:?}");
}
#[test]
fn kiwi_round_trip() {
let sc = schema();
@@ -477,6 +648,7 @@ fn imports_the_fixture() {
let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") };
assert_eq!(t.characters, "Hi there");
assert_eq!(t.style.font_size, 16.0);
assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)");
assert_eq!(t.runs.len(), 1);
assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold"));
// The group's children keep their place on the page (group at 100, 100).
@@ -539,6 +711,53 @@ fn bare_canvas_and_zlib_chunks_open() {
assert!(import(&out, "Z").is_ok());
}
/// The fixture message with the photo's image bytes in its own blobs, as copies written by plugins
/// and tools carry them: the PNG goes at the end of `blobs` and `image.dataBlob` points `shift`
/// places past it (0 is the PNG itself).
fn message_with_image_in_blobs(shift: u64) -> Kv<'static> {
fn point_images_at(value: &mut Kv<'static>, blob: u64) {
match value {
Kv::Object(fields) => {
for (name, field) in fields.iter_mut() {
match field {
Kv::Object(image) if *name == "image" => image.push(("dataBlob", u(blob))),
_ => point_images_at(field, blob),
}
}
}
Kv::Array(items) => items.iter_mut().for_each(|item| point_images_at(item, blob)),
_ => {}
}
}
let mut msg = message();
let index = {
let Kv::Object(fields) = &mut msg else { panic!("the message is an object") };
let Some((_, Kv::Array(blobs))) = fields.iter_mut().find(|(name, _)| *name == "blobs") else { panic!("no blobs") };
blobs.push(o(vec![("bytes", Kv::Bytes(png()))]));
blobs.len() as u64 - 1
};
point_images_at(&mut msg, index + shift);
msg
}
#[test]
fn a_bare_canvas_reads_images_carried_in_its_blobs() {
// The clipboard holds a bare canvas (no `images/`); the image comes from `image.dataBlob`.
let c = canvas(&schema(), &message_with_image_in_blobs(0));
let (doc, warnings) = import(&c, "Pasted").unwrap();
let photo = named(&doc, "Photo");
let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") };
assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1)));
assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
}
#[test]
fn a_data_blob_past_the_blobs_stays_a_placeholder() {
let c = canvas(&schema(), &message_with_image_in_blobs(5));
let (_, warnings) = import(&c, "Pasted").unwrap();
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
}
#[test]
fn other_files_are_not_fig() {
assert!(try_import(b"{\"document\": {}}", "x").is_none());
@@ -588,7 +807,7 @@ fn mangled_kiwi_never_panics() {
}
for cut in (0..msg.len()).step_by(3) {
if let Ok(kv) = sc.decode_message(root, &msg[..cut]) {
let _ = map::to_rest(&kv, "x");
let _ = map::to_rest(&kv, "x", crate::rest::MAX_NODES);
}
}
for _ in 0..2000 {
@@ -598,8 +817,8 @@ fn mangled_kiwi_never_panics() {
m[i] = r.next() as u8;
}
if let Ok(kv) = sc.decode_message(root, &m) {
let (json, _) = map::to_rest(&kv, "x");
let _ = crate::rest::import(&json, &crate::rest::no_images);
let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES);
let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES);
}
let mut sbytes = schema_bytes.clone();
let i = (r.next() as usize) % sbytes.len();
@@ -621,6 +840,61 @@ fn mangled_kiwi_never_panics() {
}
}
#[test]
fn large_files_get_a_larger_value_budget() {
// Small files keep the fixed floor: a few bytes can't expand into a huge tree.
assert_eq!(kiwi::value_budget(0), 20_000_000);
assert_eq!(kiwi::value_budget(1 << 20), 20_000_000);
// A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds
// 30.5 million values was refused as "too many values".
assert!(kiwi::value_budget(83_491_232) > 30_514_899);
// A real 194 MB design-system file (UX4G): a 39.5 MB canvas holding 29.7 million values.
assert!(kiwi::value_budget(39_528_420) > 29_703_730);
// Still bounded by the largest canvas read.
assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20);
}
#[test]
fn the_layer_cap_follows_the_file_size() {
// A small (or broken) file keeps the REST import's cap: instances expand into a layer per
// layer of their component, and a few bytes mustn't claim millions of layers.
assert_eq!(layer_budget(0), crate::rest::MAX_NODES);
assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES);
// A real 894 004-layer file (an 83.5 MB canvas) stopped at 200 000 and came in with 27 of
// its 30 pages empty.
assert!(layer_budget(83_491_232) > 894_004);
// No file gets more than the ceiling.
assert_eq!(layer_budget(MAX_CANVAS as usize), MAX_FIG_NODES);
}
#[test]
fn a_small_file_that_unpacks_large_gets_no_larger_budgets() {
// 40 MB of zeros in a blob pack into a few KB. Keyed to the unpacked message, they lifted the
// layer cap above the 231 231 layers its instances expand into; keyed to the file, the cap
// stays at the REST import's.
let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])];
nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(10.0, 10.0))]));
nodes.extend((10..1010).map(|i| node(i, Some((5, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))])));
nodes.extend((2000..2230).map(|i| node(i, Some((1, "!")), "INSTANCE", "I", vec![("symbolData", o(vec![("symbolID", g(5))]))])));
let blob = o(vec![("bytes", Kv::Bytes(vec![0; 40 << 20]))]);
let file = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![blob]))]));
assert!(file.len() < 1 << 20, "{} bytes", file.len());
let (doc, warnings) = import(&file, "x").unwrap();
assert!(warnings.iter().any(|w| w.contains("more than 200000 layers")), "{warnings:?}");
assert!(doc.node_count() <= crate::rest::MAX_NODES + 10);
}
#[test]
fn the_mapper_stops_at_the_layer_cap_it_is_given() {
let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])];
nodes.extend((2..12).map(|i| node(i, Some((1, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))])));
let msg = o(vec![("nodeChanges", arr(nodes))]);
let (_, warnings, _) = map::to_rest(&msg, "x", 5);
assert!(warnings.iter().any(|w| w.contains("more than 5 layers")), "{warnings:?}");
let (_, warnings, _) = map::to_rest(&msg, "x", crate::rest::MAX_NODES);
assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}");
}
#[test]
fn hostile_input_is_refused() {
// A message that contains itself, nested past the depth cap.
@@ -873,6 +1147,12 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() {
#[path = "tail_tests.rs"]
mod tail;
#[path = "image_filter_tests.rs"]
mod image_filters;
#[path = "text_list_tests.rs"]
mod text_list;
/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset).
pub(super) fn samples() -> Vec<std::path::PathBuf> {
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() };
@@ -916,7 +1196,7 @@ fn dump_samples() {
std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap();
let (rest, _) = map::to_rest(&msg, &stem);
let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len()));
std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap();
}
}
+93
View File
@@ -0,0 +1,93 @@
//! Numbered lists that start past 1 (#686): each text line says where its list starts counting,
//! less one (`listStartOffset`). Fixtures built in code with the parent module's helpers.
use figcraft_doc::{ListStyle, NodeKind, ParaList};
use super::*;
const LIST_SCHEMA: &str = "
enum NodeType NONE DOCUMENT CANVAS TEXT
enum LineType PLAIN ORDERED_LIST UNORDERED_LIST
struct GUID uint sessionID, uint localID
struct Vector float x, float y
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
message ParentIndex GUID guid, string position
message TextLineData LineType lineType, int indentationLevel, int listStartOffset, bool isFirstLineOfList
message TextData string characters, TextLineData[] lines
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData
message Message NodeChange[] nodeChanges
";
/// A list line: type, start offset, first of its list.
fn line(ty: &'static str, offset: i64, first: bool) -> Kv<'static> {
let level = i64::from(ty != "PLAIN");
o(vec![("lineType", e(ty)), ("indentationLevel", Kv::Int(level)), ("listStartOffset", Kv::Int(offset)), ("isFirstLineOfList", Kv::Bool(first))])
}
fn text_node(id: u64, name: &str, chars: &str, lines: Vec<Kv<'static>>) -> Kv<'static> {
let pos = ["!", "\"", "#", "$", "%", "&"][id as usize % 6];
node(
id,
Some((1, pos)),
"TEXT",
name,
vec![
("size", xy(200.0, 60.0)),
("transform", at(0.0, 0.0)),
("fontSize", f(16.0)),
("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])),
],
)
}
fn starts(doc: &figcraft_doc::Document, name: &str) -> Vec<(ListStyle, u32)> {
let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") };
(0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start_offset)).collect()
}
#[test]
fn numbered_lists_keep_their_start() {
use ListStyle::{Bulleted as B, None as P, Numbered as N};
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
text_node(2, "From 1", "Start here", vec![line("ORDERED_LIST", 0, true)]),
text_node(3, "From 2", "Next try this", vec![line("ORDERED_LIST", 1, true)]),
text_node(4, "First only", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 0, false), line("ORDERED_LIST", 0, false)]),
text_node(5, "Every line", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 3, false), line("ORDERED_LIST", 3, false)]),
text_node(6, "Mixed", "x\na\nb", vec![line("PLAIN", 0, false), line("ORDERED_LIST", 4, true), line("UNORDERED_LIST", 2, true)]),
text_node(7, "Junk", "a", vec![line("ORDERED_LIST", -5, true)]),
];
let sc = Schema::parse(LIST_SCHEMA).unwrap();
let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(starts(&doc, "From 1"), [(N, 0)]);
assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2.");
assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]);
assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]);
assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 0)], "bullets have no number, so no start");
assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1");
}
#[test]
fn the_export_writes_where_a_list_starts() {
let mut doc = figcraft_doc::Document::new("Lists");
let page = doc.first_page().unwrap();
use ListStyle::{Bulleted as B, None as P, Numbered as N};
let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot");
let pl = |list, start_offset| ParaList { list, indent: 0, start_offset };
td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)];
doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap();
let (bytes, _) = super::super::export(&doc, None).unwrap();
let a = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&a.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("List")).unwrap();
let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap();
let offsets: Vec<f64> = lines.iter().map(|l| l.get("listStartOffset").and_then(Kv::f64).unwrap()).collect();
// Every line of a numbered list carries its first item's start (a reader that takes it per
// line or per list counts the same); bullets and plain lines carry none.
assert_eq!(offsets, [1.0, 1.0, 0.0, 5.0, 0.0]);
let (back, _) = import(&bytes, "x").unwrap();
assert_eq!(starts(&back, "List"), [(N, 1), (N, 1), (P, 0), (N, 5), (B, 0)]);
}
@@ -0,0 +1,288 @@
//! Paragraph options (list spacing, hanging list and punctuation, leading trim) in encoded `.fig`
//! files: the highest valid value wins, invalid instance rows fall through, native saves keep them.
use super::kiwi::Kv;
use super::{export, import};
fn paragraph_fig(shared: Vec<(&'static str, Kv<'static>)>, local: Vec<(&'static str, Kv<'static>)>) -> Vec<u8> {
use super::kiwi::{Schema, encode};
let schema = Schema::parse(
"enum NodeType NONE DOCUMENT CANVAS TEXT\n\
enum StyleType NONE FILL TEXT\n\
enum LeadingTrim NONE CAP_HEIGHT\n\
struct GUID uint sessionID, uint localID\n\
struct ParentIndex GUID guid, string position\n\
struct FontName string family, string style, string postscript\n\
struct StyleId GUID guid\n\
message TextData string characters\n\
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, StyleType styleType, FontName fontName, float fontSize, StyleId styleIdForText, TextData textData, float paragraphSpacing, float paragraphIndent, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim\n\
message Message NodeChange[] nodeChanges",
)
.unwrap();
fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn guid(id: u64) -> Kv<'static> {
object(vec![("sessionID", Kv::Uint(7)), ("localID", Kv::Uint(id))])
}
fn node(id: u64, ty: &'static str, name: &str, parent: Option<u64>, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))];
if let Some(parent) = parent {
out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))])));
}
out.extend(fields);
object(out)
}
fn font(family: &str) -> Kv<'static> {
object(vec![("family", Kv::Str(family.into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))])
}
let mut base = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(20.0))];
base.extend(shared);
let mut layer = vec![
("fontName", font("Stale")),
("fontSize", Kv::Float(40.0)),
("styleIdForText", object(vec![("guid", guid(10))])),
("textData", object(vec![("characters", Kv::Str("a\nb".into()))])),
];
layer.extend(local);
let msg = object(vec![(
"nodeChanges",
Kv::Array(vec![
node(1, "DOCUMENT", "Document", None, vec![]),
node(2, "CANVAS", "Page", Some(1), vec![]),
node(10, "TEXT", "Shared", Some(2), base),
node(11, "TEXT", "Label", Some(2), layer),
]),
)]);
let root = schema.find("Message").unwrap();
let mut deflated = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
use std::io::Write;
deflated.write_all(&encode::schema(&schema)).unwrap();
let schema_chunk = deflated.finish().unwrap();
let message = encode::message(&schema, root, &msg).unwrap();
let message_chunk = ruzstd::encoding::compress_to_vec(message.as_slice(), ruzstd::encoding::CompressionLevel::Fastest);
let mut bytes = b"fig-kiwi".to_vec();
bytes.extend_from_slice(&101u32.to_le_bytes());
for chunk in [schema_chunk, message_chunk] {
bytes.extend_from_slice(&(chunk.len() as u32).to_le_bytes());
bytes.extend_from_slice(&chunk);
}
bytes
}
#[test]
fn encoded_fig_maps_paragraph_list_fields_through_native_round_trip() {
let shared = vec![
("listSpacing", Kv::Float(18.0)),
("hangingList", Kv::Bool(false)),
("hangingPunctuation", Kv::Bool(false)),
("leadingTrim", Kv::Enum("CAP_HEIGHT")),
];
let (inherited, warnings) = import(&paragraph_fig(shared.clone(), vec![]), "Synthetic paragraph options").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let text = inherited.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap();
assert_eq!(text.list_spacing, 18.0);
assert!(!text.hanging_list);
assert!(!text.hanging_punctuation);
assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight);
assert_eq!(text.style.font_family, "Inter");
let reset = vec![
("listSpacing", Kv::Float(0.0)),
("hangingList", Kv::Bool(false)),
("hangingPunctuation", Kv::Bool(false)),
("leadingTrim", Kv::Enum("NONE")),
];
let (cleared, warnings) = import(&paragraph_fig(shared, reset), "Synthetic paragraph resets").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let text = cleared.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap();
assert_eq!(text.list_spacing, 0.0);
assert!(!text.hanging_list);
assert!(!text.hanging_punctuation);
assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None);
assert_eq!(text.style.font_family, "Inter");
let bytes = crate::save_bytes(&inherited, None).unwrap();
let (back, warnings) = crate::load_bytes(&bytes).unwrap();
assert!(warnings.is_empty(), "native round trip: {warnings:?}");
let text = back.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap();
assert_eq!(text.list_spacing, 18.0);
assert!(!text.hanging_list);
assert!(!text.hanging_punctuation);
assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight);
let (_, export_warnings) = export(&inherited, None).unwrap();
assert!(
export_warnings.iter().any(|warning| warning.contains("list spacing, hanging punctuation, leading trim")),
"existing .fig export loss must remain visible: {export_warnings:?}"
);
}
fn instance_paragraph_fig(overrides: Vec<(&'static str, Kv<'static>)>, nonfinite_spacing: bool) -> Vec<u8> {
use super::kiwi::{Schema, encode};
let schema = Schema::parse(
"enum NodeType NONE DOCUMENT CANVAS TEXT SYMBOL INSTANCE\n\
enum StyleType NONE FILL TEXT\n\
enum LeadingTrim NONE CAP_HEIGHT\n\
struct GUID uint sessionID, uint localID\n\
struct ParentIndex GUID guid, string position\n\
struct FontName string family, string style, string postscript\n\
struct StyleId GUID guid\n\
struct GUIDPath GUID[] guids\n\
struct SymbolOverride GUIDPath guidPath, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim\n\
struct SymbolData GUID symbolID, SymbolOverride[] symbolOverrides\n\
message TextData string characters\n\
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, StyleType styleType, FontName fontName, float fontSize, StyleId styleIdForText, TextData textData, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim, SymbolData symbolData\n\
message Message NodeChange[] nodeChanges",
)
.unwrap();
fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn guid(id: u64) -> Kv<'static> {
object(vec![("sessionID", Kv::Uint(7)), ("localID", Kv::Uint(id))])
}
fn node(id: u64, ty: &'static str, name: &str, parent: Option<u64>, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))];
if let Some(parent) = parent {
out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))])));
}
out.extend(fields);
object(out)
}
fn font() -> Kv<'static> {
object(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))])
}
let main_text = node(
21,
"TEXT",
"Label",
Some(20),
vec![
("fontName", font()),
("fontSize", Kv::Float(20.0)),
("styleIdForText", object(vec![("guid", guid(10))])),
("textData", object(vec![("characters", Kv::Str("a\nb".into()))])),
],
);
let mut override_fields = vec![("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))]))];
override_fields.extend(overrides);
let instance = node(
30,
"INSTANCE",
"Copy",
Some(2),
vec![("symbolData", object(vec![("symbolID", guid(20)), ("symbolOverrides", Kv::Array(vec![object(override_fields)]))]))],
);
let message = object(vec![(
"nodeChanges",
Kv::Array(vec![
node(1, "DOCUMENT", "Document", None, vec![]),
node(2, "CANVAS", "Page", Some(1), vec![]),
node(
10,
"TEXT",
"Shared",
Some(2),
vec![
("styleType", Kv::Enum("TEXT")),
("fontName", font()),
("fontSize", Kv::Float(20.0)),
("listSpacing", Kv::Float(18.0)),
("hangingList", Kv::Bool(true)),
("hangingPunctuation", Kv::Bool(true)),
("leadingTrim", Kv::Enum("CAP_HEIGHT")),
],
),
node(20, "SYMBOL", "Main", Some(2), vec![]),
main_text,
instance,
]),
)]);
let root = schema.find("Message").unwrap();
let mut deflated = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
use std::io::Write;
deflated.write_all(&encode::schema(&schema)).unwrap();
let schema_chunk = deflated.finish().unwrap();
let mut message = encode::message(&schema, root, &message).unwrap();
if nonfinite_spacing {
let finite = 37.0_f32.to_bits().rotate_right(23).to_le_bytes();
let nan = f32::NAN.to_bits().rotate_right(23).to_le_bytes();
let matches: Vec<usize> = message.windows(finite.len()).enumerate().filter_map(|(i, w)| (w == finite).then_some(i)).collect();
assert_eq!(matches.len(), 1, "finite test token must identify the instance listSpacing field");
let offset = matches[0];
message[offset..offset + nan.len()].copy_from_slice(&nan);
}
let message_chunk = ruzstd::encoding::compress_to_vec(message.as_slice(), ruzstd::encoding::CompressionLevel::Fastest);
let mut bytes = b"fig-kiwi".to_vec();
bytes.extend_from_slice(&101u32.to_le_bytes());
for chunk in [schema_chunk, message_chunk] {
bytes.extend_from_slice(&(chunk.len() as u32).to_le_bytes());
bytes.extend_from_slice(&chunk);
}
bytes
}
#[test]
fn encoded_instance_paragraph_overrides_fall_back_only_when_invalid() {
let valid = vec![
("listSpacing", Kv::Float(24.0)),
("hangingList", Kv::Bool(false)),
("hangingPunctuation", Kv::Bool(false)),
("leadingTrim", Kv::Enum("NONE")),
];
let (doc, warnings) = import(&instance_paragraph_fig(valid, false), "Synthetic instance paragraph values").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let copy = doc.nodes().find(|node| node.name == "Copy").unwrap();
let label = doc.node(*copy.children.first().unwrap()).unwrap();
let text = label.kind.text().unwrap();
assert_eq!(text.list_spacing, 24.0);
assert!(!text.hanging_list);
assert!(!text.hanging_punctuation);
assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None);
let reset = vec![
("listSpacing", Kv::Float(0.0)),
("hangingList", Kv::Bool(false)),
("hangingPunctuation", Kv::Bool(false)),
("leadingTrim", Kv::Enum("NONE")),
];
let (doc, warnings) = import(&instance_paragraph_fig(reset, false), "Synthetic instance paragraph resets").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let copy = doc.nodes().find(|node| node.name == "Copy").unwrap();
let label = doc.node(*copy.children.first().unwrap()).unwrap();
let text = label.kind.text().unwrap();
assert_eq!(text.list_spacing, 0.0);
assert!(!text.hanging_list);
assert!(!text.hanging_punctuation);
assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None);
let invalid = vec![
("listSpacing", Kv::Float(37.0)),
("hangingList", Kv::Bool(false)),
("hangingPunctuation", Kv::Bool(false)),
("leadingTrim", Kv::Uint(77)),
];
let (doc, warnings) = import(&instance_paragraph_fig(invalid, true), "Synthetic malformed instance paragraph values").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let copy = doc.nodes().find(|node| node.name == "Copy").unwrap();
let label = doc.node(*copy.children.first().unwrap()).unwrap();
let text = label.kind.text().unwrap();
assert_eq!(text.list_spacing, 18.0);
assert!(!text.hanging_list);
assert!(!text.hanging_punctuation);
assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight);
let bytes = crate::save_bytes(&doc, None).unwrap();
let (back, warnings) = crate::load_bytes(&bytes).unwrap();
assert!(warnings.is_empty(), "native round trip: {warnings:?}");
let copy = back.nodes().find(|node| node.name == "Copy").unwrap();
let label = back.node(*copy.children.first().unwrap()).unwrap();
let text = label.kind.text().unwrap();
assert_eq!(text.list_spacing, 18.0);
assert!(!text.hanging_list);
assert!(!text.hanging_punctuation);
assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight);
}
+315
View File
@@ -0,0 +1,315 @@
//! Convert the simple vector network used by text-on-path layers in `.fig` files into a native text path.
use figcraft_doc::{
TextPath,
geom::{
BezPath,
kurbo::{CubicBez, Line, ParamCurve, ParamCurveArclen, Point},
},
};
use super::network::{Network, Segment};
const START_SEGMENT_STYLE: u32 = 0x01ff_ffff;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Use {
segment: usize,
forward: bool,
}
/// Build a native path from a validated connected chain or cycle. The marked source segment is
/// oriented a→b; `t` is local to that line/cubic. Coordinates are scaled into layer space.
pub(super) fn from_network(net: &Network, sx: f64, sy: f64, t: f64, forward: bool) -> Result<TextPath, &'static str> {
if !t.is_finite() || !(0.0..=1.0).contains(&t) {
return Err("invalid start parameter");
}
if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 {
return Err("invalid path scale");
}
let markers: Vec<usize> = net.segments.iter().enumerate().filter_map(|(i, s)| (s.style == START_SEGMENT_STYLE).then_some(i)).collect();
let [marked] = markers.as_slice() else {
return Err("missing or ambiguous start segment marker");
};
let (uses, closed) = ordered_chain(net, *marked)?;
let mut d = BezPath::new();
let mut total = 0.0;
let mut prefix = 0.0;
let mut start_segment_length = 0.0;
for use_ in &uses {
let (p0, p1, p2, p3) = cubic(net, *use_, sx, sy)?;
if d.elements().is_empty() {
d.move_to((p0.0, p0.1));
}
let line = p1 == p0 && p2 == p3;
let (length, partial) = if line {
d.line_to((p3.0, p3.1));
let line = Line::new(Point::new(p0.0, p0.1), Point::new(p3.0, p3.1));
(line.arclen(1e-9), line.arclen(1e-9) * t)
} else {
d.curve_to((p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1));
let curve = CubicBez::new((p0.0, p0.1), (p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1));
let partial = if t <= 0.0 { 0.0 } else { curve.subsegment(0.0..t).arclen(1e-9) };
(curve.arclen(1e-9), partial)
};
if !length.is_finite() || length <= 1e-10 {
return Err("zero-length path segment");
}
if use_.segment == *marked {
start_segment_length = partial;
if !start_segment_length.is_finite() {
return Err("invalid path length");
}
prefix = total;
}
total += length;
}
if closed {
d.close_path();
}
if !total.is_finite() || total <= 1e-10 {
return Err("zero-length path");
}
let mut start = ((prefix + start_segment_length) / total).clamp(0.0, 1.0);
let flip = !forward;
if flip {
start = 1.0 - start;
}
let path = TextPath { d: d.to_svg(), start, flip };
let lines = figcraft_doc::geom::measure::polylines(&path.path()).ok_or("cannot be measured by text renderer")?;
if lines.first().is_none_or(|line| line.length() <= 1e-9) {
return Err("cannot be measured by text renderer");
}
Ok(path)
}
/// Return every segment in chain order. Open chains are oriented so the marked segment runs a→b;
/// cycles start at the marked segment's a vertex and traverse it first.
fn ordered_chain(net: &Network, marked: usize) -> Result<(Vec<Use>, bool), &'static str> {
let marker = net.segments.get(marked).ok_or("invalid start segment index")?;
let mut adjacency = vec![Vec::<usize>::new(); net.vertices.len()];
for (i, s) in net.segments.iter().enumerate() {
if s.a >= adjacency.len() || s.b >= adjacency.len() {
return Err("invalid path vertex index");
}
if s.a == s.b {
return Err("path contains a segment that starts and ends at the same vertex");
}
adjacency[s.a].push(i);
adjacency[s.b].push(i);
}
let mut endpoints = Vec::new();
for edges in &adjacency {
if edges.len() > 2 {
return Err("branched path network");
}
if edges.len() == 1 {
endpoints.push(edges[0]);
}
}
if !endpoints.is_empty() && endpoints.len() != 2 {
return Err("disconnected or malformed path network");
}
let cycle = endpoints.is_empty();
let mut used = vec![false; net.segments.len()];
let mut out = Vec::with_capacity(net.segments.len());
if cycle {
out.push(Use { segment: marked, forward: true });
used[marked] = true;
let mut at = marker.b;
let mut visited = vec![false; net.vertices.len()];
visited[marker.a] = true;
visited[marker.b] = true;
while at != marker.a {
let next = adjacency[at].iter().copied().find(|i| !used[*i]).ok_or("disconnected path cycle")?;
used[next] = true;
let s = &net.segments[next];
let is_forward = s.a == at;
at = if is_forward { s.b } else { s.a };
if at != marker.a && std::mem::replace(&mut visited[at], true) {
return Err("path cycle is not simple");
}
out.push(Use { segment: next, forward: is_forward });
if out.len() > net.segments.len() {
return Err("invalid path cycle");
}
}
} else {
let endpoint = adjacency.iter().position(|e| e.len() == 1).ok_or("missing path endpoint")?;
let mut at = endpoint;
while let Some(next) = adjacency[at].iter().copied().find(|i| !used[*i]) {
used[next] = true;
let s = &net.segments[next];
let is_forward = s.a == at;
at = if is_forward { s.b } else { s.a };
out.push(Use { segment: next, forward: is_forward });
}
let Some(marker_use) = out.iter().find(|u| u.segment == marked) else { return Err("disconnected start segment") };
if !marker_use.forward {
out.reverse();
for u in &mut out {
u.forward = !u.forward;
}
}
}
if !used.iter().all(|x| *x) {
return Err("disconnected path network");
}
Ok((out, cycle))
}
type P = (f64, f64);
fn cubic(net: &Network, use_: Use, sx: f64, sy: f64) -> Result<(P, P, P, P), &'static str> {
let s: &Segment = net.segments.get(use_.segment).ok_or("invalid path segment index")?;
let a = net.vertices.get(s.a).ok_or("invalid path vertex index")?;
let b = net.vertices.get(s.b).ok_or("invalid path vertex index")?;
let p0 = (a.x * sx, a.y * sy);
let p3 = (b.x * sx, b.y * sy);
let ta = (s.ta.0 * sx, s.ta.1 * sy);
let tb = (s.tb.0 * sx, s.tb.1 * sy);
if [p0.0, p0.1, p3.0, p3.1, ta.0, ta.1, tb.0, tb.1].iter().any(|x| !x.is_finite()) {
return Err("invalid path coordinates");
}
let (p0, p1, p2, p3) =
if ta == (0.0, 0.0) && tb == (0.0, 0.0) { (p0, p0, p3, p3) } else { (p0, (p0.0 + ta.0, p0.1 + ta.1), (p3.0 + tb.0, p3.1 + tb.1), p3) };
if use_.forward { Ok((p0, p1, p2, p3)) } else { Ok((p3, p2, p1, p0)) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fig::network::{Segment, Vertex};
use figcraft_doc::geom::kurbo::PathEl;
fn vertex(x: f64, y: f64) -> Vertex {
Vertex { style: 0, x, y }
}
fn segment(style: u32, a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment {
Segment { style, a, ta, b, tb }
}
fn curved() -> Network {
Network {
vertices: vec![vertex(0.0, 82.5), vertex(300.0, 82.5), vertex(600.0, 82.5)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (70.0, -110.0), (-70.0, -110.0)), segment(0, 1, 2, (70.0, 110.0), (-70.0, 110.0))],
..Network::default()
}
}
#[test]
fn local_cubic_parameter_converts_to_path_distance() {
let path = from_network(&curved(), 1.0, 1.0, 0.0086954795, true).unwrap();
assert!((path.start - 0.004744078).abs() < 0.00001);
// Cubic t is not its fraction of total path length.
assert!((path.start - 0.0086954795).abs() > 0.003);
let elements = path.path().elements().to_vec();
assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 82.5));
assert!(matches!(elements.get(1), Some(PathEl::CurveTo(..))));
}
#[test]
fn second_segment_marker_and_t_are_respected() {
let mut net = curved();
net.segments[0].style = 0;
net.segments[1].style = START_SEGMENT_STYLE;
let path = from_network(&net, 1.0, 1.0, 0.49800667, true).unwrap();
assert!((path.start - 0.7490379757).abs() < 0.00002);
}
#[test]
fn reverse_direction_inverts_normalized_start() {
let net = Network {
vertices: vec![vertex(0.0, 0.0), vertex(600.0, 0.0)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
..Network::default()
};
let path = from_network(&net, 1.0, 1.0, 0.83986014, false).unwrap();
assert!(path.flip);
assert!((path.start - 0.16013986).abs() < 1e-8);
assert!(matches!(path.path().elements().get(1), Some(PathEl::LineTo(p)) if p.x == 600.0 && p.y == 0.0));
}
#[test]
fn rejects_missing_duplicate_and_zero_length_segments() {
let mut net = curved();
net.segments[0].style = 0;
assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker"));
net.segments[1].style = START_SEGMENT_STYLE;
net.segments[0].style = START_SEGMENT_STYLE;
assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker"));
let zero = Network {
vertices: vec![vertex(0.0, 0.0), vertex(0.0, 0.0)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
..Network::default()
};
assert_eq!(from_network(&zero, 1.0, 1.0, 0.5, true), Err("zero-length path segment"));
}
#[test]
fn rejects_branching_and_disconnected_segments() {
let branch = Network {
vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(1.0, 1.0)],
segments: vec![
segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)),
segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)),
segment(0, 1, 3, (0.0, 0.0), (0.0, 0.0)),
],
..Network::default()
};
assert_eq!(from_network(&branch, 1.0, 1.0, 0.5, true), Err("branched path network"));
let disconnected = Network {
vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(3.0, 0.0)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), segment(0, 2, 3, (0.0, 0.0), (0.0, 0.0))],
..Network::default()
};
assert_eq!(from_network(&disconnected, 1.0, 1.0, 0.5, true), Err("disconnected or malformed path network"));
}
#[test]
fn scales_geometry_and_accepts_closed_cycles() {
let cycle = Network {
vertices: vec![vertex(0.0, 0.0), vertex(100.0, 0.0), vertex(100.0, 100.0)],
segments: vec![
segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)),
segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)),
segment(0, 2, 0, (0.0, 0.0), (0.0, 0.0)),
],
..Network::default()
};
let path = from_network(&cycle, 2.0, 3.0, 0.5, true).unwrap();
let elements = path.path().elements().to_vec();
assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 0.0));
assert!(matches!(elements.get(1), Some(PathEl::LineTo(p)) if p.x == 200.0 && p.y == 0.0));
let total = 200.0 + 300.0 + 130_000.0_f64.sqrt();
assert!((path.start - 100.0 / total).abs() < 1e-8);
assert!(matches!(elements.last(), Some(PathEl::ClosePath)));
}
#[test]
fn rejects_paths_the_native_renderer_cannot_measure() {
let count = 40_000usize;
let many_lines = Network {
vertices: (0..=count).map(|i| vertex(i as f64, 0.0)).collect(),
segments: (0..count).map(|i| segment(if i == 0 { START_SEGMENT_STYLE } else { 0 }, i, i + 1, (0.0, 0.0), (0.0, 0.0))).collect(),
..Network::default()
};
assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer"));
let huge = Network {
vertices: vec![vertex(0.0, 0.0), vertex(1e9, 0.0)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
..Network::default()
};
assert_eq!(from_network(&huge, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer"));
let tiny = Network {
vertices: vec![vertex(0.0, 0.0), vertex(5e-10, 0.0)],
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
..Network::default()
};
assert_eq!(from_network(&tiny, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer"));
}
}
+211
View File
@@ -0,0 +1,211 @@
//! Mapper → private REST ingress tests for source text-path fields.
use super::kiwi::Kv;
const SCHEMA: &str = "
enum NodeType DOCUMENT CANVAS TEXT TEXT_PATH
enum StyleType NONE TEXT
enum TextDecoration NONE UNDERLINE STRIKETHROUGH
struct GUID uint sessionID, uint localID
struct StyleId GUID guid
struct ParentIndex GUID guid, string position
struct Vector float x, float y
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
struct FontName string family, string style
message TextOverride uint styleID, FontName fontName, float fontSize
message TextData string characters, uint[] characterStyleIDs, TextOverride[] styleOverrideTable
message TextPathStart float tValue, bool forward
message VectorData uint vectorNetworkBlob, Vector normalizedSize
message Blob byte[] bytes
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, TextData textData, float fontSize, FontName fontName, TextPathStart textPathStart, VectorData vectorData, StyleType styleType, StyleId styleIdForText, TextDecoration textDecoration
message Message NodeChange[] nodeChanges, Blob[] blobs
";
use figcraft_doc::{NodeKind, TextPath};
fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn a(values: Vec<Kv<'static>>) -> Kv<'static> {
Kv::Array(values)
}
fn f(value: f32) -> Kv<'static> {
Kv::Float(value)
}
fn u(value: u64) -> Kv<'static> {
Kv::Uint(value)
}
fn s(value: &'static str) -> Kv<'static> {
Kv::Str(value.into())
}
fn point(x: f32, y: f32) -> Kv<'static> {
o(vec![("x", f(x)), ("y", f(y))])
}
fn guid(id: u64) -> Kv<'static> {
o(vec![("sessionID", u(1)), ("localID", u(id))])
}
fn node(id: u64, parent: Option<u64>, ty: &'static str, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut node = vec![("guid", guid(id)), ("type", s(ty))];
if let Some(parent) = parent {
node.push(("parentIndex", o(vec![("guid", guid(parent)), ("position", s("!"))])));
}
node.extend(fields);
o(node)
}
fn blob() -> Vec<u8> {
let mut bytes = Vec::new();
let u32le = |out: &mut Vec<u8>, n: u32| out.extend_from_slice(&n.to_le_bytes());
let f32le = |out: &mut Vec<u8>, n: f32| out.extend_from_slice(&n.to_le_bytes());
u32le(&mut bytes, 2); // vertices
u32le(&mut bytes, 1); // segments
u32le(&mut bytes, 0); // regions
for (x, y) in [(0.0, 0.0), (100.0, 0.0)] {
u32le(&mut bytes, 0);
f32le(&mut bytes, x);
f32le(&mut bytes, y);
}
u32le(&mut bytes, 0x01ff_ffff); // source start-segment marker
u32le(&mut bytes, 0);
f32le(&mut bytes, 0.0);
f32le(&mut bytes, 0.0);
u32le(&mut bytes, 1);
f32le(&mut bytes, 0.0);
f32le(&mut bytes, 0.0);
bytes
}
fn canvas() -> Vec<u8> {
let schema = super::kiwi::Schema::parse(SCHEMA).unwrap();
let message = message(true);
let message = super::kiwi::encode::message(&schema, schema.find("Message").unwrap(), &message).unwrap();
let mut out = b"fig-kiwi\0\0\0\0".to_vec();
for raw in [super::kiwi::encode::schema(&schema), message] {
let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut encoder, &raw).unwrap();
let compressed = encoder.finish().unwrap();
out.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
out.extend_from_slice(&compressed);
}
out
}
fn message(with_path: bool) -> Kv<'static> {
let mut nodes = vec![node(0, None, "DOCUMENT", vec![]), node(1, Some(0), "CANVAS", vec![])];
let mut blobs = Vec::new();
if with_path {
blobs.push(o(vec![("bytes", Kv::Bytes(blob()))]));
let text = o(vec![
("characters", s("alpha")),
("characterStyleIDs", a(vec![u(0), u(0), u(0), u(2), u(2)])),
(
"styleOverrideTable",
a(vec![o(vec![("styleID", u(2)), ("fontName", o(vec![("family", s("Inter")), ("style", s("Medium"))])), ("fontSize", f(30.0))])]),
),
]);
nodes.push(node(
2,
Some(1),
"TEXT_PATH",
vec![
("name", s("alpha path")),
("size", point(100.0, 0.0)),
("transform", o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(0.0)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(0.0))])),
("textData", text),
("fontSize", f(24.0)),
("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])),
("textPathStart", o(vec![("tValue", f(0.5)), ("forward", Kv::Bool(true))])),
("vectorData", o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", point(100.0, 0.0))])),
],
));
} else {
nodes.push(node(2, Some(1), "TEXT_PATH", vec![("name", s("broken path")), ("textData", o(vec![("characters", s("fallback"))]))]));
}
o(vec![("nodeChanges", a(nodes)), ("blobs", a(blobs))])
}
fn linked_underline_message() -> Kv<'static> {
let Kv::Object(mut root) = message(true) else { panic!("expected message object") };
let (_, Kv::Array(nodes)) = root.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!("expected node changes array") };
let text = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("alpha path")).unwrap();
let Kv::Object(fields) = text else { panic!("expected text node object") };
fields.push(("styleIdForText", o(vec![("guid", guid(3))])));
nodes.push(node(
3,
Some(1),
"TEXT",
vec![
("name", s("Shared style")),
("styleType", s("TEXT")),
("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])),
("fontSize", f(24.0)),
("textDecoration", s("UNDERLINE")),
],
));
Kv::Object(root)
}
#[test]
fn mapped_text_path_reaches_native_model_with_runs_and_no_standin_warning() {
let message = message(true);
let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES);
assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}");
let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap();
assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}");
let node = doc.nodes().find(|n| n.name == "alpha path").unwrap();
let NodeKind::Text(text) = &node.kind else { panic!("expected text") };
let path = text.path.as_deref().unwrap();
assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false });
assert_eq!(text.characters, "alpha");
assert_eq!(text.runs.len(), 1);
assert_eq!(text.runs[0].start, 3);
assert_eq!(text.runs[0].end, 5);
}
#[test]
fn public_rest_import_ignores_private_fig_text_path_field() {
let message = message(true);
let (json, _, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES);
// The mapper's private field is accepted only by import_fig, never by public REST import.
let path = json["document"]["children"][0]["children"][0]["_figTextPath"].clone();
assert!(path.is_object());
let (doc, _) = crate::rest::import(&json, &|_| None).unwrap();
let node = doc.nodes().find(|n| n.name == "alpha path").unwrap();
let NodeKind::Text(text) = &node.kind else { panic!("expected text") };
assert!(text.path.is_none());
}
#[test]
fn serialized_kiwi_canvas_import_decodes_start_and_builds_native_path() {
let (doc, warnings) = super::import(&canvas(), "Serialized text path").unwrap();
let node = doc.nodes().find(|n| n.name == "alpha path").unwrap();
let NodeKind::Text(text) = &node.kind else { panic!("expected text") };
let path = text.path.as_deref().unwrap();
assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false });
assert_eq!(text.characters, "alpha");
assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}");
}
#[test]
fn malformed_text_path_falls_back_with_specific_warning() {
let message = message(false);
let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES);
assert!(warnings.iter().any(|w| w.contains("text path has no start data") && w.contains("text imports off its path")), "{warnings:?}");
let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap();
let node = doc.nodes().find(|n| n.name == "broken path").unwrap();
assert!(matches!(&node.kind, NodeKind::Text(text) if text.path.is_none()));
}
#[test]
fn linked_text_style_decoration_warns_from_effective_mapped_style() {
let message = linked_underline_message();
let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES);
assert_eq!(json["document"]["children"][0]["children"][0]["style"]["textDecoration"], "UNDERLINE");
assert!(warnings.iter().any(|w| w == "text path underline or strikethrough decoration is not rendered"), "{warnings:?}");
}
@@ -0,0 +1,272 @@
//! `.fig` text skip-ink values map, inherit and round-trip as native decoration look state. Skip
//! ink only shows on an underline, so the fixtures are underlined.
use super::kiwi::Kv;
use super::{export, import};
use figcraft_doc::{Document, NodeKind, StyleKind, StyleRun, TextData, TextDecoration, TextStyle};
use serde_json::Value;
fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn guid(id: u64) -> Kv<'static> {
object(vec![("sessionID", Kv::Uint(8)), ("localID", Kv::Uint(id))])
}
fn node(id: u64, ty: &'static str, name: &str, parent: Option<u64>, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))];
if let Some(parent) = parent {
out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))])));
}
out.append(&mut fields);
object(out)
}
fn font() -> Kv<'static> {
object(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))])
}
fn raw(base: Option<Kv<'static>>, runs: Vec<Kv<'static>>, chars: &str, ids: Vec<u64>, shared: Option<Option<Kv<'static>>>) -> Value {
let mut layer = vec![
("fontName", font()),
("fontSize", Kv::Float(20.0)),
("textDecoration", Kv::Enum("UNDERLINE")),
(
"textData",
object(vec![
("characters", Kv::Str(chars.into())),
("characterStyleIDs", Kv::Array(ids.into_iter().map(Kv::Uint).collect())),
("styleOverrideTable", Kv::Array(runs)),
]),
),
];
if let Some(value) = base {
layer.push(("textDecorationSkipInk", value));
}
let mut nodes = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])];
if let Some(shared) = shared {
let mut fields =
vec![("styleType", Kv::Enum("TEXT")), ("fontName", font()), ("fontSize", Kv::Float(20.0)), ("textDecoration", Kv::Enum("UNDERLINE"))];
if let Some(value) = shared {
fields.push(("textDecorationSkipInk", value));
}
nodes.push(node(10, "TEXT", "Body", Some(2), fields));
layer.push(("styleIdForText", object(vec![("guid", guid(10))])));
}
nodes.push(node(11, "TEXT", "Label", Some(2), layer));
let message = object(vec![("nodeChanges", Kv::Array(nodes))]);
super::map::to_rest(&message, "Synthetic skip ink", crate::rest::MAX_NODES).0
}
fn imported(value: &Value) -> Document {
let (doc, warnings) = crate::rest::import_fig(value, &|_| None).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
doc
}
fn label(doc: &Document) -> &TextData {
doc.nodes().find(|n| n.name == "Label").unwrap().kind.text().unwrap()
}
fn skip_at(text: &TextData, index: usize) -> bool {
text.runs.iter().find(|r| r.start <= index && index < r.end).map_or_else(|| text.style.look().skip_ink, |r| r.style.look().skip_ink)
}
#[test]
fn node_boolean_false_true_omitted_and_malformed_keep_typed_semantics() {
for (raw_value, expected) in [(Some(Kv::Bool(true)), true), (Some(Kv::Bool(false)), false), (None, false), (Some(Kv::Str("true".into())), false)]
{
let doc = imported(&raw(raw_value.clone(), vec![], "A", vec![0], None));
assert_eq!(label(&doc).style.look().skip_ink, expected, "source value {raw_value:?}");
}
}
#[test]
fn runs_inherit_explicit_false_and_true_by_utf16_character_ids() {
let table = vec![
object(vec![("styleID", Kv::Uint(1))]),
object(vec![("styleID", Kv::Uint(2)), ("textDecorationSkipInk", Kv::Bool(false))]),
object(vec![("styleID", Kv::Uint(3)), ("textDecorationSkipInk", Kv::Bool(true))]),
];
let doc = imported(&raw(Some(Kv::Bool(true)), table, "A😀BC", vec![0, 1, 1, 2, 3], None));
let text = label(&doc);
assert_eq!(text.characters.chars().count(), 4);
assert_eq!(text.characters.encode_utf16().count(), 5, "the emoji occupies two source style-ID units");
assert_eq!((0..text.char_count()).map(|i| skip_at(text, i)).collect::<Vec<_>>(), vec![true, true, false, true]);
let false_base = imported(&raw(
Some(Kv::Bool(false)),
vec![object(vec![("styleID", Kv::Uint(1)), ("textDecorationSkipInk", Kv::Bool(true))])],
"ab",
vec![1, 0],
None,
));
assert_eq!((0..2).map(|i| skip_at(label(&false_base), i)).collect::<Vec<_>>(), vec![true, false]);
}
#[test]
fn local_skip_ink_override_preserves_shared_style_and_omitted_value_inherits() {
for (local, expected) in [(Some(false), false), (None, true)] {
let value = raw(local.map(Kv::Bool), vec![], "ab", vec![0, 0], Some(Some(Kv::Bool(true))));
let doc = imported(&value);
let node = doc.nodes().find(|n| n.name == "Label").unwrap();
assert_eq!(node.kind.text().unwrap().style.look().skip_ink, expected);
let linked = doc.style(node.styles.text.as_deref().unwrap()).unwrap();
let StyleKind::Text { style } = &linked.kind else { panic!("linked definition must remain text") };
assert!(style.look().skip_ink, "local value must not alter definition");
}
}
#[test]
fn instance_path_override_preserves_explicit_false_over_true_main() {
let main_text = node(
21,
"TEXT",
"Label",
Some(20),
vec![
("fontName", font()),
("fontSize", Kv::Float(20.0)),
("textDecoration", Kv::Enum("UNDERLINE")),
("textDecorationSkipInk", Kv::Bool(true)),
("textData", object(vec![("characters", Kv::Str("A".into()))])),
],
);
let instance = node(
30,
"INSTANCE",
"Copy",
Some(2),
vec![(
"symbolData",
object(vec![
("symbolID", guid(20)),
(
"symbolOverrides",
Kv::Array(vec![object(vec![
("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])),
("textDecorationSkipInk", Kv::Bool(false)),
])]),
),
]),
)],
);
let message = object(vec![(
"nodeChanges",
Kv::Array(vec![
node(1, "DOCUMENT", "Document", None, vec![]),
node(2, "CANVAS", "Page", Some(1), vec![]),
node(20, "SYMBOL", "Main", Some(2), vec![]),
main_text,
instance,
]),
)]);
let value = super::map::to_rest(&message, "Instance skip ink", crate::rest::MAX_NODES).0;
let copy = value["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["name"] == "Copy").unwrap();
let label = copy["children"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap();
assert_eq!(label["style"]["textDecorationSkipInk"], false);
let doc = imported(&value);
let copy = doc.nodes().find(|n| n.name == "Copy").unwrap();
let instance_label = doc.node(*copy.children.first().unwrap()).unwrap();
assert!(!instance_label.kind.text().unwrap().style.look().skip_ink);
}
#[test]
fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only_warning() {
let mut doc = Document::new("Skip ink export");
let mut text = TextData::new("abcd");
text.style.decoration = TextDecoration::Underline;
text.style.set_look(|look| look.skip_ink = false);
let mut skipped = text.style.clone();
skipped.set_look(|look| look.skip_ink = true);
text.runs = vec![StyleRun { start: 1, end: 3, style: skipped }];
doc.create(doc.first_page().unwrap(), NodeKind::Text(text), "Label", 0.0, 0.0, 100.0, 30.0).unwrap();
let (bytes, warnings) = export(&doc, None).unwrap();
assert!(warnings.is_empty(), "skip-ink is supported: {warnings:?}");
let archive = super::Archive::open(&bytes).unwrap();
let (schema, body) = super::decode_canvas(&archive.canvas).unwrap();
let message = schema.decode_message(schema.find("Message").unwrap(), &body).unwrap();
let value = super::debug_json(&message);
let layer = value["nodeChanges"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap();
assert_eq!(layer["textDecorationSkipInk"], false);
assert_eq!(layer.as_object().unwrap().keys().filter(|k| *k == "textDecorationSkipInk").count(), 1);
let rows = layer["textData"]["styleOverrideTable"].as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["textDecorationSkipInk"], true);
let (back, warnings) = import(&bytes, "Skip ink export").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!((0..4).map(|i| skip_at(label(&back), i)).collect::<Vec<_>>(), vec![false, true, true, false]);
let mut custom = TextData::new("x");
custom.style.set_look(|look| look.skip_ink = true);
custom.style.set_look(|look| look.thickness = figcraft_doc::DecorationLength::Pixels(2.0));
let mut custom_doc = Document::new("Unsupported decoration");
custom_doc.create(custom_doc.first_page().unwrap(), NodeKind::Text(custom), "Custom", 0.0, 0.0, 100.0, 30.0).unwrap();
let (_, warnings) = export(&custom_doc, None).unwrap();
assert!(warnings.iter().any(|w| w.contains("text decoration looks")), "custom look warning missing: {warnings:?}");
let mut variable = TextData::new("x");
variable.style.variations.insert("wght".into(), 400.0);
let mut variable_doc = Document::new("Variable font warning");
variable_doc.create(variable_doc.first_page().unwrap(), NodeKind::Text(variable), "Variable", 0.0, 0.0, 100.0, 30.0).unwrap();
let (_, warnings) = export(&variable_doc, None).unwrap();
assert!(warnings.iter().any(|w| w.contains("variable font settings")), "variation warning missing: {warnings:?}");
let mut shared = TextStyle { decoration: TextDecoration::Underline, ..TextStyle::default() };
shared.set_look(|look| look.skip_ink = true);
let mut local = TextStyle { decoration: TextDecoration::Underline, ..TextStyle::default() };
local.set_look(|look| look.skip_ink = false);
let mut linked_doc = Document::new("Shared skip ink");
linked_doc.styles.push(figcraft_doc::Style {
id: "body".into(),
name: "Body".into(),
description: String::new(),
kind: StyleKind::Text { style: shared },
bound: Default::default(),
});
let id = linked_doc
.create(linked_doc.first_page().unwrap(), NodeKind::Text(TextData { style: local, ..TextData::new("x") }), "Label", 0.0, 0.0, 100.0, 30.0)
.unwrap();
linked_doc.node_mut(id).unwrap().styles.text = Some("body".into());
let (bytes, warnings) = export(&linked_doc, None).unwrap();
assert!(warnings.is_empty(), "skip-ink-only shared styles are supported: {warnings:?}");
let (back, warnings) = import(&bytes, "Shared skip ink").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let node = back.nodes().find(|n| n.name == "Label").unwrap();
assert!(!node.kind.text().unwrap().style.look().skip_ink);
let linked = back.style(node.styles.text.as_deref().unwrap()).unwrap();
let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") };
assert!(style.look().skip_ink, "local false must not alter the shared definition");
}
#[test]
fn skip_ink_without_an_underline_stays_out_of_the_look_and_exports_the_default() {
// Plain text with the files' default value imports with the default look...
let mut value = raw(Some(Kv::Bool(true)), vec![], "ab", vec![0, 0], None);
value["document"]["children"][0]["children"][0]["style"].as_object_mut().unwrap().remove("textDecoration");
let doc = imported(&value);
assert_eq!(label(&doc).style.decoration, TextDecoration::None);
assert!(label(&doc).style.decoration_look.is_none());
// ...an underlined run of it keeps the layer's value...
let mut value =
raw(Some(Kv::Bool(true)), vec![object(vec![("styleID", Kv::Uint(1)), ("textDecoration", Kv::Enum("UNDERLINE"))])], "ab", vec![1, 0], None);
value["document"]["children"][0]["children"][0]["style"].as_object_mut().unwrap().remove("textDecoration");
let doc = imported(&value);
assert_eq!((0..2).map(|i| skip_at(label(&doc), i)).collect::<Vec<_>>(), vec![true, false]);
// ...and default native text writes the default, which reads back as the default look.
let mut doc = Document::new("Plain");
doc.create(doc.first_page().unwrap(), NodeKind::Text(TextData::new("x")), "Label", 0.0, 0.0, 100.0, 30.0).unwrap();
let (bytes, _) = export(&doc, None).unwrap();
let archive = super::Archive::open(&bytes).unwrap();
let (schema, body) = super::decode_canvas(&archive.canvas).unwrap();
let message = schema.decode_message(schema.find("Message").unwrap(), &body).unwrap();
let value = super::debug_json(&message);
let layer = value["nodeChanges"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap();
assert_eq!(layer["textDecorationSkipInk"], true);
let (back, _) = import(&bytes, "Plain").unwrap();
assert!(label(&back).style.decoration_look.is_none());
}
+292
View File
@@ -0,0 +1,292 @@
//! Text layers and their shared text styles on import: which of the style, the layer's own
//! values and an instance's overrides win, on messages built in code.
use figcraft_doc::{Document, LetterSpacing, StyleKind, TextCase, TextDecoration};
use serde_json::Value;
use super::kiwi::Kv;
type Fields = Vec<(&'static str, Kv<'static>)>;
const NO_STYLE: u64 = 4_294_967_295;
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 link(id: u64) -> Kv<'static> {
object(vec![("guid", guid(id))])
}
fn node(id: u64, ty: &'static str, name: &str, parent: Option<u64>, mut more: Fields) -> Kv<'static> {
let mut fields = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))];
if let Some(parent) = parent {
fields.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))])));
}
fields.append(&mut more);
object(fields)
}
fn font(family: &str) -> Kv<'static> {
object(vec![("family", Kv::Str(family.into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))])
}
fn chars(text: &str) -> Kv<'static> {
object(vec![("characters", Kv::Str(text.into()))])
}
/// The REST JSON of a page holding `nodes`.
fn rest(nodes: Vec<Kv<'static>>) -> Value {
let base = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])];
let message = object(vec![("nodeChanges", Kv::Array(base.into_iter().chain(nodes).collect()))]);
super::map::to_rest(&message, "Synthetic text", crate::rest::MAX_NODES).0
}
fn import(value: &Value) -> Document {
let (doc, warnings) = crate::rest::import_fig(value, &|_| None).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
doc
}
/// Depth-first search for a REST node by name below `v`.
fn find<'v>(v: &'v Value, name: &str) -> Option<&'v Value> {
if v["name"] == name {
return Some(v);
}
v["children"].as_array()?.iter().find_map(|c| find(c, name))
}
/// A shared text style (Inter, 20 px, with `shared` fields) and a layer linking it whose own
/// copy of the typography is stale (40 px, another family) and which sets `local`.
fn linked(shared: Fields, local: Fields) -> Document {
let mut definition = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(20.0))];
definition.extend(shared);
let mut layer = vec![("fontName", font("Stale copy")), ("fontSize", Kv::Float(40.0)), ("styleIdForText", link(10)), ("textData", chars("a\nb"))];
layer.extend(local);
import(&rest(vec![node(10, "TEXT", "Shared", Some(2), definition), node(11, "TEXT", "Label", Some(2), layer)]))
}
struct Seen {
case: TextCase,
decoration: TextDecoration,
spacing: f64,
indent: f64,
shared_case: TextCase,
}
fn seen(doc: &Document) -> Seen {
let label = doc.nodes().find(|n| n.name == "Label").unwrap();
let text = label.kind.text().unwrap();
// The shared style's typography beats the layer's stale copy, whatever the layer sets locally.
assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 20.0));
let linked = doc.style(label.styles.text.as_deref().unwrap()).unwrap();
assert_eq!(linked.name, "Shared");
let StyleKind::Text { style } = &linked.kind else { panic!("the linked definition must stay a text style") };
Seen {
case: text.style.case,
decoration: text.style.decoration,
spacing: text.paragraph_spacing,
indent: text.paragraph_indent,
shared_case: style.case,
}
}
#[test]
fn local_case_decoration_and_paragraph_values_beat_the_shared_style() {
let shared = vec![
("textCase", Kv::Enum("UPPER")),
("textDecoration", Kv::Enum("UNDERLINE")),
("paragraphSpacing", Kv::Float(24.0)),
("paragraphIndent", Kv::Float(12.0)),
];
// Nothing set on the layer: the style's values stay.
let s = seen(&linked(shared.clone(), vec![]));
assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::Upper, TextDecoration::Underline, 24.0, 12.0));
// Explicit values on the layer win, and "original", "none" and zero clear the style's.
let cleared = vec![
("textCase", Kv::Enum("ORIGINAL")),
("textDecoration", Kv::Enum("NONE")),
("paragraphSpacing", Kv::Float(0.0)),
("paragraphIndent", Kv::Float(0.0)),
];
let s = seen(&linked(shared.clone(), cleared));
assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::Original, TextDecoration::None, 0.0, 0.0));
assert_eq!(s.shared_case, TextCase::Upper, "a local value must not change the definition");
let local = vec![
("textCase", Kv::Enum("SMALL_CAPS_FORCED")),
("textDecoration", Kv::Enum("STRIKETHROUGH")),
("paragraphSpacing", Kv::Float(6.0)),
("paragraphIndent", Kv::Float(3.0)),
];
let s = seen(&linked(vec![], local));
assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::SmallCaps, TextDecoration::Strikethrough, 6.0, 3.0));
// A value the importer does not know leaves the style's in place.
let s = seen(&linked(shared, vec![("textCase", Kv::Enum("UNSUPPORTED_CASE")), ("textDecoration", Kv::Enum("WAVY"))]));
assert_eq!((s.case, s.decoration), (TextCase::Upper, TextDecoration::Underline));
}
#[test]
fn an_instance_override_beats_the_shared_style_and_a_detach_unlinks_it() {
let value = rest(vec![
node(10, "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(10)), ("textData", chars("Hi"))],
),
node(
30,
"INSTANCE",
"Inst",
Some(2),
vec![(
"symbolData",
object(vec![
("symbolID", guid(20)),
(
"symbolOverrides",
Kv::Array(vec![object(vec![
("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])),
("fontSize", Kv::Float(30.0)),
])]),
),
]),
)],
),
// An explicit "no style" detaches, even when the older field still names a style.
node(
40,
"TEXT",
"Detached",
Some(2),
vec![
("fontName", font("Roboto")),
("fontSize", Kv::Float(40.0)),
("styleIdForText", object(vec![("guid", object(vec![("sessionID", Kv::Uint(NO_STYLE)), ("localID", Kv::Uint(NO_STYLE))]))])),
("inheritTextStyleID", guid(10)),
("textData", chars("ab")),
],
),
]);
let label = find(find(&value["document"], "Inst").unwrap(), "Label").unwrap();
assert_eq!(label["style"]["fontSize"].as_f64(), Some(30.0), "the override beats the style");
let detached = find(&value["document"], "Detached").unwrap();
assert!(detached["styles"].get("text").is_none(), "{}", detached["styles"]);
assert_eq!(detached["style"]["fontSize"].as_f64(), Some(40.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();
let value = rest(vec![
node(
20,
"SYMBOL",
"Main",
Some(2),
vec![(
"componentPropDefs",
Kv::Array(vec![object(vec![
("id", guid(70)),
("name", Kv::Str("Text".into())),
("type", Kv::Enum("TEXT")),
("initialValue", object(vec![("textValue", chars("Hi you"))])),
])]),
)],
),
node(
21,
"TEXT",
"Label",
Some(20),
vec![
("fontName", font("Inter")),
("fontSize", Kv::Float(12.0)),
(
"textData",
object(vec![
("characters", Kv::Str("Hi you".into())),
("characterStyleIDs", Kv::Array(ids)),
("styleOverrideTable", Kv::Array(vec![object(vec![("styleID", Kv::Uint(1)), ("fontSize", Kv::Float(30.0))])])),
]),
),
(
"parameterConsumptionMap",
object(vec![(
"entries",
Kv::Array(vec![object(vec![
("variableField", Kv::Enum("TEXT_DATA")),
("variableData", object(vec![("value", object(vec![("propRefValue", object(vec![("defId", guid(70))]))]))])),
])]),
)]),
),
],
),
node(
30,
"INSTANCE",
"Inst",
Some(2),
vec![
("symbolData", object(vec![("symbolID", guid(20))])),
(
"componentPropAssignments",
Kv::Array(vec![object(vec![("defID", guid(70)), ("value", object(vec![("textValue", chars("Goodbye"))]))])]),
),
],
),
]);
let main = find(find(&value["document"], "Main").unwrap(), "Label").unwrap();
let kept: Vec<u64> = main["characterStyleOverrides"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
assert_eq!(kept, [0, 0, 0, 1, 1, 1], "the main keeps its run styles");
let inst = find(&value["document"], "Inst").unwrap();
let layer = find(inst, "Label").unwrap();
assert_eq!(layer["characters"], "Goodbye");
assert!(layer.get("characterStyleOverrides").is_none(), "positions of the main's runs do not fit the property text");
}
#[test]
fn percent_letter_spacing_stays_a_percentage() {
let spacing = |units: &'static str, value: f32| object(vec![("value", Kv::Float(value)), ("units", Kv::Enum(units))]);
let layer = |id, name: &str, letter: Kv<'static>| {
node(
id,
"TEXT",
name,
Some(2),
vec![("fontName", font("Inter")), ("fontSize", Kv::Float(20.0)), ("letterSpacing", letter), ("textData", chars("Hello"))],
)
};
let doc = import(&rest(vec![layer(10, "Percent", spacing("PERCENT", 5.0)), layer(11, "Pixels", spacing("PIXELS", 2.0))]));
let spacing = |name: &str| doc.nodes().find(|n| n.name == name).unwrap().kind.text().unwrap().style.letter_spacing;
assert_eq!(spacing("Percent"), LetterSpacing::Percent(5.0));
assert_eq!(spacing("Pixels"), LetterSpacing::Pixels(2.0));
}
#[test]
fn the_private_percent_is_read_for_fig_imports_only() {
let value = rest(vec![node(
10,
"TEXT",
"Label",
Some(2),
vec![
("fontName", font("Inter")),
("fontSize", Kv::Float(20.0)),
("letterSpacing", object(vec![("value", Kv::Float(5.0)), ("units", Kv::Enum("PERCENT"))])),
("textData", chars("Hello")),
],
)]);
let spacing = |doc: &Document| doc.nodes().find(|n| n.name == "Label").unwrap().kind.text().unwrap().style.letter_spacing;
assert_eq!(spacing(&import(&value)), LetterSpacing::Percent(5.0));
// The same JSON through the public REST import is pixels.
let (public, _) = crate::rest::import(&value, &|_| None).unwrap();
assert_eq!(spacing(&public), LetterSpacing::Pixels(1.0));
}
+17
View File
@@ -202,3 +202,20 @@ fn the_header_is_read_on_its_own() {
let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap();
assert_eq!(index, Some(["ab".to_string()].into_iter().collect()));
}
#[test]
fn document_json_may_unpack_in_proportion_to_its_packed_size() {
const MB: u64 = 1024 * 1024;
// A small archive keeps the member cap: it can't unpack into gigabytes.
assert_eq!(document_limit(0, 0), MAX_MEMBER);
assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER);
// A real 900 000-layer document saved by us packs 1 477 139 810 bytes into 244 362 923, and
// didn't open again under the 512 MB cap.
if cfg!(not(target_pointer_width = "32")) {
assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810);
}
// The packed size an archive claims counts only up to the archive's own size.
assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER);
// Never more than the ceiling.
assert_eq!(document_limit(u64::MAX, u64::MAX), MAX_DOCUMENT);
}
+17 -1
View File
@@ -39,6 +39,15 @@ pub const FORMAT: &str = "figcraft";
pub const VERSION: u32 = 1;
/// Largest archive member we read (guards against zip bombs).
const MAX_MEMBER: u64 = 512 * 1024 * 1024;
/// `document.json` may unpack to [`DOCUMENT_RATIO`] times what it takes in the archive, within
/// [`MAX_MEMBER`] and [`MAX_DOCUMENT`]: a large document saved by us (a 900 000-layer file packs
/// 1.48 GB into 244 MB) opens again, while a small archive still can't unpack into gigabytes.
/// 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is lower.
const DOCUMENT_RATIO: u64 = 16;
#[cfg(not(target_pointer_width = "32"))]
const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024;
#[cfg(target_pointer_width = "32")]
const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024;
/// Most versions a file keeps: saving more is refused, extra entries in a file are skipped.
pub const MAX_VERSIONS: usize = 100;
/// Longest version name and description kept (chars).
@@ -455,6 +464,11 @@ pub(crate) fn read_member<R: Read + Seek>(z: &mut zip::ZipArchive<R>, name: &str
Ok(data)
}
/// Largest `document.json` read from an archive of `len` bytes in which it takes `packed`.
pub(crate) fn document_limit(packed: u64, len: u64) -> u64 {
packed.min(len).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT)
}
/// How many images, and how many bytes of them together, one file may give.
#[derive(Clone, Copy, Debug)]
pub(crate) struct ImageBudget {
@@ -476,7 +490,9 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F
return Ok(Loaded { issues, ..Loaded::from(doc) });
}
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let text = String::from_utf8(read_member(&mut z, "document.json", MAX_MEMBER)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let packed = z.by_name("document.json").map(|f| f.compressed_size()).unwrap_or(0);
let limit = document_limit(packed, bytes.len() as u64);
let text = String::from_utf8(read_member(&mut z, "document.json", limit)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let (mut doc, mut issues, own_images) = parse_json(&text)?;
let (images, unread) = read_images(&mut z, own_images.as_ref(), budget);
report_lost_images(&mut issues, &unread, own_images.as_ref(), &images);
+321
View File
@@ -0,0 +1,321 @@
//! Explicit .fig GUID provenance projected through instance expansion contexts.
//! All strings borrow the bounded JSON; no names, node-kind similarity or child order match links.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::{Document, NodeId, NodeKind};
use serde_json::Value;
use super::{FIG_SOURCE_FIELD, MAX_DEPTH};
// A GUID has at most two 20-digit u64 parts and a colon; instance paths contain at most
// MAX_DEPTH + 2 GUIDs. Check before constructing any projected path string.
const MAX_SOURCE_ID_BYTES: usize = 44 * (MAX_DEPTH + 2);
#[derive(Clone, Copy)]
struct Origin<'a> {
rest: &'a str,
guid: &'a str,
expanded: bool,
slot_content: bool,
wire_frame: bool,
wire_instance: bool,
component: Option<&'a str>,
}
pub(super) struct Sources<'a> {
/// Most layers recorded.
max_nodes: usize,
nodes: BTreeMap<NodeId, Origin<'a>>,
duplicate_ids: BTreeSet<&'a str>,
rejected: usize,
}
fn guid(s: &str) -> bool {
let Some((session, local)) = s.split_once(':') else { return false };
s.len() <= 41
&& [session, local].into_iter().all(|part| !part.is_empty() && part.bytes().all(|c| c.is_ascii_digit()) && part.parse::<u64>().is_ok())
}
fn expanded_id(instance: &str, origin: &str) -> Option<String> {
let bytes = instance.len().checked_add(origin.len())?.checked_add(2)?;
if bytes > MAX_SOURCE_ID_BYTES {
return None;
}
Some(if instance.starts_with('I') { format!("{instance};{origin}") } else { format!("I{instance};{origin}") })
}
impl<'a> Sources<'a> {
pub(super) fn new(max_nodes: usize) -> Self {
Self { max_nodes, nodes: BTreeMap::new(), duplicate_ids: BTreeSet::new(), rejected: 0 }
}
pub(super) fn record(&mut self, id: NodeId, node: &'a Value, primary: bool) {
let Some(rest) = node.get("id").and_then(Value::as_str) else { return };
if !primary {
self.duplicate_ids.insert(rest);
return;
}
let Some(meta) = node.get(FIG_SOURCE_FIELD) else { return };
let parsed = meta.get("origin").and_then(Value::as_str).zip(meta.get("expanded").and_then(Value::as_bool));
let Some((origin, expanded)) = parsed else {
self.rejected += 1;
return;
};
if self.nodes.len() >= self.max_nodes
|| rest.len() > MAX_SOURCE_ID_BYTES
|| !guid(origin)
|| (!expanded && rest != origin)
|| (expanded && !rest.starts_with('I'))
{
self.rejected += 1;
return;
}
let wire_frame = meta.get("wireFrame").and_then(Value::as_bool).unwrap_or(false);
let wire_instance = !wire_frame
&& meta.get("wireInstance").and_then(Value::as_bool) == Some(true)
&& node.get("type").and_then(Value::as_str) == Some("INSTANCE");
let component = wire_instance.then(|| node.get("componentId").and_then(Value::as_str).filter(|id| guid(id))).flatten();
let slot_content = meta.get("slotContent").and_then(Value::as_bool).unwrap_or(false);
self.nodes.insert(id, Origin { rest, guid: origin, expanded, slot_content, wire_frame, wire_instance, component });
}
fn origin(&self, id: NodeId) -> Option<Origin<'a>> {
let meta = *self.nodes.get(&id)?;
(!self.duplicate_ids.contains(meta.rest)).then_some(meta)
}
fn raw_in_main(&self, origin: &str, main: NodeId, doc: &Document, ids: &BTreeMap<String, NodeId>) -> Option<NodeId> {
let target = *ids.get(origin)?;
let meta = self.origin(target)?;
(!meta.expanded && meta.rest == origin && target != main && doc.is_ancestor(main, target)).then_some(target)
}
fn root_source(&self, id: NodeId, doc: &Document) -> Option<NodeId> {
let NodeKind::Instance { component, .. } = &doc.node(id)?.kind else { return None };
let main_meta = self.origin(*component)?;
(!main_meta.expanded
&& matches!(doc.node(*component)?.kind, NodeKind::Component { .. })
&& *component != id
&& !doc.is_ancestor(id, *component)
&& !doc.is_ancestor(*component, id))
.then_some(*component)
}
fn missing_frame(&self, id: NodeId, doc: &Document, ids: &BTreeMap<String, NodeId>) -> bool {
self.origin(id).is_some_and(|meta| {
meta.wire_instance
&& matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::Frame))
// An existing target of the wrong kind or a cycle-blocked main is not missing.
&& meta.component.is_some_and(|main| !ids.contains_key(main))
})
}
fn owner(&self, id: NodeId, doc: &Document, ids: &BTreeMap<String, NodeId>) -> Option<NodeId> {
let mut current = doc.node(id)?.parent;
let mut seen = BTreeSet::from([id]);
for _ in 0..512 {
let parent = current?;
if !seen.insert(parent) {
return None;
}
let node = doc.node(parent)?;
if matches!(node.kind, NodeKind::Instance { .. }) || self.missing_frame(parent, doc, ids) {
return Some(parent);
}
current = node.parent;
}
None
}
fn namespace(&self, id: NodeId, owner: NodeId, doc: &Document) -> bool {
let Some(owner_meta) = self.origin(owner) else { return false };
let mut current = id;
let mut seen = BTreeSet::new();
for _ in 0..512 {
if current == owner {
return true;
}
if !seen.insert(current) {
return false;
}
let Some(meta) = self.origin(current) else { return false };
if !meta.expanded || expanded_id(owner_meta.rest, meta.guid).as_deref() != Some(meta.rest) {
return false;
}
let Some(parent) = doc.node(current).and_then(|n| n.parent) else { return false };
current = parent;
}
false
}
// A missing-main instance is materialized as a frame. Outside real instances its children
// are owned layers; known instances there become ordinary roots with their own actual main.
fn owned_layer(&self, id: NodeId, doc: &Document, ids: &BTreeMap<String, NodeId>) -> bool {
if doc.ancestors(id).into_iter().any(|p| doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))) {
return false;
}
let mut current = id;
for _ in 0..=MAX_DEPTH {
let Some(owner) = self.owner(current, doc, ids) else { return false };
if !self.missing_frame(owner, doc, ids) || !self.namespace(current, owner, doc) {
return false;
}
let Some(meta) = self.origin(owner) else { return false };
if !meta.expanded {
return true;
}
current = owner;
}
false
}
fn frame_target(&self, id: NodeId, owner: NodeId, source_owner: NodeId, doc: &Document, ids: &BTreeMap<String, NodeId>) -> Option<NodeId> {
let meta = self.origin(id)?;
let owner_meta = self.origin(owner)?;
let source_meta = self.origin(source_owner)?;
if !self.missing_frame(source_owner, doc, ids) || owner_meta.component != source_meta.component {
return None;
}
let target = *ids.get(expanded_id(source_meta.rest, meta.guid)?.as_str())?;
let target_meta = self.origin(target)?;
if !target_meta.expanded
|| target_meta.guid != meta.guid
|| self.owner(target, doc, ids) != Some(source_owner)
|| !self.namespace(target, source_owner, doc)
{
return None;
}
let parent = doc.node(id)?.parent?;
let target_parent = doc.node(target)?.parent?;
if parent == owner {
(target_parent == source_owner).then_some(target)
} else {
(target_parent != source_owner && self.origin(parent)?.guid == self.origin(target_parent)?.guid).then_some(target)
}
}
fn source(
&self,
id: NodeId,
doc: &Document,
ids: &BTreeMap<String, NodeId>,
memo: &mut BTreeMap<NodeId, Option<NodeId>>,
active: &mut BTreeSet<NodeId>,
depth: usize,
) -> Option<NodeId> {
if let Some(source) = memo.get(&id) {
return *source;
}
if depth > MAX_DEPTH || !active.insert(id) {
return None;
}
let found = (|| {
let meta = self.origin(id)?;
let _ = doc.node(id)?;
if meta.slot_content {
return if matches!(doc.node(id)?.kind, NodeKind::Instance { .. }) { self.root_source(id, doc) } else { None };
}
if !meta.expanded {
return self.root_source(id, doc);
}
// An actual wire INSTANCE whose main is absent remains a namespace owner even
// when the REST importer materializes it as a frame. Ordinary frames never do.
let owner = self.owner(id, doc, ids)?;
if !self.namespace(id, owner, doc) {
return None;
}
let source_owner = self.source(owner, doc, ids, memo, active, depth + 1);
if self.missing_frame(owner, doc, ids) {
let target = match source_owner {
Some(source_owner) => self.frame_target(id, owner, source_owner, doc, ids)?,
None if self.owned_layer(id, doc, ids) => return self.root_source(id, doc),
_ => return None,
};
return (target != id && !doc.is_ancestor(id, target)).then_some(target);
}
let NodeKind::Instance { component: main, .. } = &doc.node(owner)?.kind else { return None };
let source_owner = source_owner?;
let target = match &doc.node(source_owner)?.kind {
NodeKind::Component { .. } if source_owner == *main => self.raw_in_main(meta.guid, *main, doc, ids)?,
NodeKind::Instance { component: source_main, .. } if source_main != main => {
// A resolved main switch is evidence for resetting to that CURRENT main.
self.raw_in_main(meta.guid, *main, doc, ids)?
}
NodeKind::Instance { component: source_main, .. } if source_main == main => {
let source_meta = self.origin(source_owner)?;
let target = *ids.get(expanded_id(source_meta.rest, meta.guid)?.as_str())?;
let target_meta = self.origin(target)?;
let target_owner = self.owner(target, doc, ids);
if !target_meta.expanded || target_meta.guid != meta.guid || target_owner != Some(source_owner) {
return None;
}
target
}
NodeKind::Frame if self.origin(source_owner)?.wire_frame => {
// An actual wire FRAME becoming INSTANCE has no previous main. Its resolved
// current main is the only proven namespace for its newly expanded children.
self.raw_in_main(meta.guid, *main, doc, ids)?
}
_ => return None,
};
(target != id && !doc.is_ancestor(id, target)).then_some(target)
})();
active.remove(&id);
memo.insert(id, found);
found
}
pub(super) fn apply(self, doc: &mut Document, ids: &BTreeMap<String, NodeId>) -> usize {
let mut links = BTreeMap::new();
let mut active = BTreeSet::new();
for &id in self.nodes.keys() {
let _ = self.source(id, doc, ids, &mut links, &mut active, 0);
}
let mut missing = self.rejected;
for (&id, meta) in &self.nodes {
let needs_source = (!meta.slot_content && meta.expanded && !self.owned_layer(id, doc, ids))
|| doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }));
if !needs_source {
continue;
}
let target = links.get(&id).copied().flatten().filter(|_| acyclic(id, &links));
if let Some(target) = target {
if let Some(node) = doc.node_mut(id) {
node.source = Some(target);
}
} else {
missing += 1;
}
}
missing
}
}
fn acyclic(id: NodeId, links: &BTreeMap<NodeId, Option<NodeId>>) -> bool {
let mut current = id;
for _ in 0..=MAX_DEPTH {
let Some(next) = links.get(&current).copied().flatten() else { return true };
if next == id {
return false;
}
current = next;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn self_cycles_and_overlong_source_chains_are_rejected() {
let self_link = BTreeMap::from([(NodeId(1), Some(NodeId(1)))]);
assert!(!acyclic(NodeId(1), &self_link));
let cycle = BTreeMap::from([(NodeId(1), Some(NodeId(2))), (NodeId(2), Some(NodeId(1)))]);
assert!(!acyclic(NodeId(1), &cycle));
let long = (1..=MAX_DEPTH as u64 + 2).map(|i| (NodeId(i), Some(NodeId(i + 1)))).collect();
assert!(!acyclic(NodeId(1), &long));
assert!(expanded_id(&"x".repeat(MAX_SOURCE_ID_BYTES), "1:2").is_none());
}
}
@@ -0,0 +1,59 @@
//! Stroke-only vectors that import as the outline of their stroke keep the stroke's style and variables.
use super::proto_variable_tests::{BRAND, alias, variables_json};
use super::*;
use serde_json::json;
fn at(x: f64, y: f64) -> Value {
json!([[1, 0, x], [0, 1, y]])
}
fn file(children: Value) -> Value {
json!({"name": "Fixture", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": children}
]}})
}
fn named<'a>(doc: &'a Document, name: &str) -> &'a Node {
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no node {name}"))
}
/// A stroke-only icon vector as `geometry=paths` returns it: no fills, one visible stroke.
fn stroke_only_vector(id: &str, name: &str, stroke: Value) -> Value {
json!({"id": id, "name": name, "type": "VECTOR", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10},
"fills": [], "strokes": [stroke], "strokeWeight": 2,
"fillGeometry": [],
"strokeGeometry": [{"path": "M 0 0 L 10 0 L 10 2 L 0 2 Z", "windingRule": "NONZERO"}],
"styles": {"stroke": "S:stroke"}})
}
#[test]
fn stroke_only_vector_shared_stroke_style_keeps_paints() {
let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}});
let mut v = file(json!([stroke_only_vector("1:1", "Icon", stroke)]));
v["styles"] = json!({"S:stroke": {"key": "k", "name": "Brand/Stroke", "styleType": "FILL"}});
let (doc, _) = import(&v, &no_images).unwrap();
let icon = named(&doc, "Icon");
assert!(matches!(icon.kind, NodeKind::Vector { .. }));
let style = doc.style("S:stroke").expect("shared stroke style is created");
match &style.kind {
StyleKind::Paint { paints } => assert_eq!(paints.len(), 1, "shared stroke style must keep the stroke paint, got {paints:?}"),
k => panic!("unexpected style kind {k:?}"),
}
}
#[test]
fn stroke_colour_variable_on_outlined_vector_is_kept() {
let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}, "boundVariables": {"color": alias(BRAND)}});
let mut node = stroke_only_vector("1:1", "Icon", stroke);
node.as_object_mut().unwrap().remove("styles");
node["boundVariables"] = json!({"strokes": [alias(BRAND)]});
let (doc, warnings) = import_with(&file(json!([node])), Some(&variables_json()), &no_images).unwrap();
let icon = named(&doc, "Icon");
// The stroke paint now paints the outline as its fill: its colour token must follow it.
assert!(
icon.bound.values().any(|v| v == BRAND),
"colour variable bound on the outlined stroke was dropped; bound={:?} warnings={warnings:?}",
icon.bound
);
}
+126 -29
View File
@@ -16,7 +16,8 @@
//!
//! Instances come out with their own layers as the file lists them. Linking those layers to the
//! main component's (`source`) and deriving overrides is left to the caller (the engine does it
//! after loading), since that needs the component model.
//! after loading), since that needs the component model. The private .fig entry point additionally
//! preserves explicit GUID/expansion provenance before the engine derives overrides.
#[cfg(test)]
mod blur_tests;
@@ -24,8 +25,11 @@ mod blur_tests;
mod conditional_tests;
#[cfg(test)]
mod effects_tests;
mod fig_sources;
#[cfg(test)]
mod gamepad_tests;
#[cfg(test)]
mod import_regression_tests;
pub(crate) mod props;
pub(crate) mod proto;
#[cfg(test)]
@@ -49,16 +53,20 @@ use crate::FormatError;
use props::{MAX_COORD, MAX_SIZE, clip_str, flag, list, num, text};
pub use variables::export as export_variables;
/// Most layers imported from one file.
/// Most layers imported from one file. The `.fig` import sets its own, from the file's size.
pub const MAX_NODES: usize = 200_000;
/// Deepest layer nesting followed.
const MAX_DEPTH: usize = 256;
/// Private .fig provenance, consumed only by the `.fig` import ([`import_with_networks`]).
pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource";
/// Hidden .fig library context; accepted only by the `.fig` import ([`import_with_networks`]).
pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts";
/// Longest SVG path string read from one geometry entry.
const MAX_PATH_BYTES: usize = 4 << 20;
/// Most path elements in one vector.
const MAX_PATH_ELEMENTS: usize = 200_000;
/// Most distinct warnings reported.
const MAX_WARNINGS: usize = 200;
pub(crate) const MAX_WARNINGS: usize = 200;
/// Most shared style definitions read.
const MAX_STYLES: usize = 10_000;
/// Image key used for image fills without an `imageRef`.
@@ -138,12 +146,43 @@ pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec<String>
/// in the file JSON (a `meta` with `variableCollections`, or `variableCollections` and
/// `variables` at the top level) are read too; the first definition of an id wins.
pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) -> Result<(Document, Vec<String>), FormatError> {
import_parts(v, variables, images, Vec::new(), MAX_NODES, false)
}
/// Internal .fig JSON path: GUID provenance is not part of the public REST import contract.
/// The `.fig` import itself goes through [`import_with_networks`].
#[cfg(test)]
pub(crate) fn import_fig(v: &Value, images: ImageResolver) -> Result<(Document, Vec<String>), FormatError> {
import_with_networks(v, images, Vec::new(), MAX_NODES)
}
/// [`import_fig`] for the `.fig` import's JSON, whose vectors name their networks by
/// `vectorNetworkRef`, an index in `networks` (built once each, and far smaller than as JSON).
/// `max_nodes`: the layer cap, set by the `.fig` import in place of [`MAX_NODES`].
pub(crate) fn import_with_networks(
v: &Value,
images: ImageResolver,
networks: Vec<Option<VectorNetwork>>,
max_nodes: usize,
) -> Result<(Document, Vec<String>), FormatError> {
import_parts(v, None, images, networks, max_nodes, true)
}
fn import_parts(
v: &Value,
variables: Option<&Value>,
images: ImageResolver,
networks: Vec<Option<VectorNetwork>>,
max_nodes: usize,
fig_sources: bool,
) -> Result<(Document, Vec<String>), FormatError> {
let entries: Vec<&Value> = if is_document(v) { vec![v] } else { nodes_entries(v).collect() };
if entries.is_empty() {
return Err(FormatError::Corrupt("not a REST API file JSON (no `document`)".into()));
}
let name = text(v, "name").filter(|n| !n.trim().is_empty()).map_or("Imported file", clip_str);
let mut cx = Ctx::new(Document::new(name), images);
cx.fig_sources = fig_sources.then(|| fig_sources::Sources::new(max_nodes));
let blank = cx.doc.first_page();
let mut loose: Option<NodeId> = None;
for e in &entries {
@@ -157,6 +196,8 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver)
cx.warn("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored");
}
cx.style_definitions(v);
cx.networks = networks;
cx.max_nodes = max_nodes;
for e in entries {
let Some(root) = e.get("document") else { continue };
match text(root, "type") {
@@ -178,6 +219,12 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver)
None => cx.warn("entries without a node type were skipped"),
}
}
if fig_sources {
for context in v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).into_iter().flatten() {
let store = cx.doc.ensure_library_store();
cx.build(context, store, Space::default(), None, 0, false);
}
}
// Drop the empty page the new document started with once real pages exist.
if let Some(p) = blank
&& cx.doc.pages.len() > 1
@@ -210,7 +257,7 @@ fn style_binding(slot: &str, key: &str) -> Option<String> {
/// Put back the shape a layer was before the `.fig` export wrote it as the outline of its stroke
/// (`outlinedStroke`: `{kind, fills, strokes, stroke}` as the document holds them). Anything that
/// isn't such a shape is ignored.
fn restore_stroke(n: &mut Node, original: &Value) {
fn restore_stroke(n: &mut Node, original: &Value) -> bool {
#[derive(serde::Deserialize)]
struct Original {
kind: NodeKind,
@@ -218,16 +265,16 @@ fn restore_stroke(n: &mut Node, original: &Value) {
strokes: Vec<figcraft_doc::color::Paint>,
stroke: figcraft_doc::StrokeProps,
}
let Ok(mut o) = serde_json::from_value::<Original>(original.clone()) else { return };
let Ok(mut o) = serde_json::from_value::<Original>(original.clone()) else { return false };
match &mut o.kind {
NodeKind::Vector { network } => {
if network.vertices.len() + network.segments.len() > MAX_PATH_ELEMENTS {
return;
return false;
}
network.sanitize();
}
NodeKind::Line | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle => {}
_ => return,
_ => return false,
}
let sp = &mut o.stroke;
sp.weight = sp.weight.clamp(0.0, props::MAX_SIZE);
@@ -237,6 +284,28 @@ fn restore_stroke(n: &mut Node, original: &Value) {
o.fills.truncate(64);
o.strokes.truncate(64);
(n.kind, n.fills, n.strokes, n.stroke) = (o.kind, o.fills, o.strokes, o.stroke);
true
}
/// A stroke-only vector imported as the outline of its stroke: the stroke paints fill the
/// outline, and the stroke's shared style and colour variables move with them. The hidden fills
/// it replaces go, with their style and bindings.
fn outline_stroke(n: &mut Node) {
n.fills = std::mem::take(&mut n.strokes);
n.styles.fill = n.styles.stroke.take();
let bound = std::mem::take(&mut n.bound);
for (key, var) in bound {
let moved = match key.as_str() {
"fill" => continue,
k if k.starts_with("fills/") => continue,
"stroke" => "fill".to_string(),
k => match k.strip_prefix("strokes/") {
Some(rest) => format!("fills/{rest}"),
None => key,
},
};
n.bound.insert(moved, var);
}
}
fn children(v: &Value) -> &[Value] {
@@ -291,6 +360,7 @@ pub(crate) struct Ctx<'a> {
count: usize,
/// REST node id → imported node.
ids: BTreeMap<String, NodeId>,
fig_sources: Option<fig_sources::Sources<'a>>,
component_meta: BTreeMap<&'a str, &'a Value>,
set_meta: BTreeMap<&'a str, &'a Value>,
style_meta: BTreeMap<&'a str, &'a Value>,
@@ -309,6 +379,10 @@ pub(crate) struct Ctx<'a> {
/// Imported variables by id, and the mode ids of imported collections (default first).
var_kinds: BTreeMap<String, VariableType>,
var_modes: BTreeMap<String, Vec<String>>,
/// Vector networks built by the `.fig` import, named by layers' `vectorNetworkRef`.
networks: Vec<Option<VectorNetwork>>,
/// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap.
max_nodes: usize,
}
impl<'a> Ctx<'a> {
@@ -321,6 +395,7 @@ impl<'a> Ctx<'a> {
warnings: Warnings::default(),
count: 0,
ids: BTreeMap::new(),
fig_sources: None,
component_meta: BTreeMap::new(),
set_meta: BTreeMap::new(),
style_meta: BTreeMap::new(),
@@ -332,6 +407,8 @@ impl<'a> Ctx<'a> {
overlay_meta: BTreeMap::new(),
var_kinds: BTreeMap::new(),
var_modes: BTreeMap::new(),
networks: Vec::new(),
max_nodes: MAX_NODES,
}
}
@@ -387,8 +464,9 @@ impl<'a> Ctx<'a> {
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
return;
}
if self.count >= MAX_NODES {
self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped"));
if self.count >= self.max_nodes {
let max = self.max_nodes;
self.warn(format!("the file has more than {max} layers; the rest were skipped"));
return;
}
let Some(ty) = text(j, "type") else {
@@ -403,6 +481,10 @@ impl<'a> Ctx<'a> {
let id = self.doc.alloc_id();
let label = kind.label();
let mut n = Node::new(id, kind, text(j, "name").map_or(label, clip_str), w, h);
if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get("slotOverride")).and_then(Value::as_bool) == Some(true)
{
n.overrides.insert("slot".into());
}
n.transform = local(space.world, world);
self.common(&mut n, j, parent_mode);
let (container, follows, mode) = (n.kind.is_container(), n.kind.bounds_follow_children(), n.layout.as_ref().map(|l| l.mode));
@@ -411,7 +493,10 @@ impl<'a> Ctx<'a> {
}
self.count += 1;
if let Some(rid) = text(j, "id") {
self.ids.entry(rid.to_string()).or_insert(id);
let primary = *self.ids.entry(rid.to_string()).or_insert(id) == id;
if let Some(sources) = &mut self.fig_sources {
sources.record(id, j, primary);
}
}
if ty == "INSTANCE" {
self.instances.push((id, j));
@@ -548,19 +633,18 @@ impl<'a> Ctx<'a> {
if let Some(c) = flag(j, "clipsContent") {
n.clip = c;
}
n.layout = props::auto_layout(j);
n.layout = props::auto_layout(j, self.fig_sources.is_some());
n.grids = props::layout_grids(j);
}
n.child_layout = props::child_layout(j, n.layout.as_ref().map(|l| l.mode), parent_mode);
n.exports = props::export_settings(j);
for (field, prop) in j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten() {
if let (Some(p), true) = (prop.as_str(), matches!(field.as_str(), "visible" | "characters" | "mainComponent")) {
let supported = matches!(field.as_str(), "visible" | "characters" | "mainComponent") || (field == "slot" && self.fig_sources.is_some());
if let (Some(p), true) = (prop.as_str(), supported) {
n.prop_refs.insert(field.clone(), p.to_string());
}
}
if matches!(n.kind, NodeKind::Vector { .. }) {
self.vector(n, j);
}
let outlined = matches!(n.kind, NodeKind::Vector { .. }) && self.vector(n, j);
if matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }) {
let defs = self.prop_defs(n.id, j);
match &mut n.kind {
@@ -581,20 +665,28 @@ impl<'a> Ctx<'a> {
self.overlay_meta.entry(rid).or_insert(j);
}
self.bindings(n, j);
if let Some(original) = j.get("outlinedStroke") {
restore_stroke(n, original);
let restored = j.get("outlinedStroke").is_some_and(|original| restore_stroke(n, original));
// Styles and bindings were read against the stroke: they follow its paints to the fill.
if outlined && !restored {
outline_stroke(n);
}
}
/// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our
/// extension, written by the `.fig` import), else from its geometry. A stroke-only vector
/// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape).
fn vector(&mut self, n: &mut Node, j: &Value) {
if let Some(net) = j.get("vectorNetwork") {
match network(net) {
/// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or
/// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its
/// geometry. A stroke-only vector uses the stroke outline, painted with the stroke paints
/// (exact look, editable as a shape): returns whether it does, and the caller moves the paints
/// with [`outline_stroke`] once the styles and variables of the stroke are read.
fn vector(&mut self, n: &mut Node, j: &Value) -> bool {
let net = match j.get("vectorNetworkRef") {
Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
None => j.get("vectorNetwork").map(network),
};
if let Some(net) = net {
match net {
Some(network) => {
n.kind = NodeKind::Vector { network };
return;
return false;
}
None => self.warn("invalid or oversized vector networks import as outlines"),
}
@@ -609,17 +701,15 @@ impl<'a> Ctx<'a> {
(None, Some(s)) => (s, true),
(None, None) => {
self.warn("vectors without geometry are empty (export the file JSON with geometry=paths)");
return;
return false;
}
};
let mut network = VectorNetwork::from_path(&path);
for r in &mut network.regions {
r.winding = winding;
}
if outline {
n.fills = std::mem::take(&mut n.strokes);
}
n.kind = NodeKind::Vector { network };
outline
}
/// The paths of a `fillGeometry` / `strokeGeometry` list (node-local SVG path data).
@@ -658,6 +748,7 @@ impl<'a> Ctx<'a> {
continue;
}
Some("VARIANT") => continue,
Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() },
_ => {
self.warn("unknown component property types were skipped");
continue;
@@ -822,6 +913,12 @@ impl<'a> Ctx<'a> {
}
}
self.resolve_instances();
if let Some(sources) = self.fig_sources.take() {
let missing = sources.apply(&mut self.doc, &self.ids);
if missing > 0 {
self.warn(format!("{missing} .fig instance source link(s) could not be resolved and were left unlinked"));
}
}
self.rename_props();
for (id, j) in std::mem::take(&mut self.protos) {
let list = self.interactions(j);
@@ -1110,7 +1207,7 @@ fn local(parent: Affine, world: Affine) -> Affine {
/// Finite path data within the coordinate cap.
/// A vector network in our own JSON shape, when it's within bounds and refers only to what it has.
fn network(v: &Value) -> Option<VectorNetwork> {
pub(crate) fn network(v: &Value) -> Option<VectorNetwork> {
use serde::Deserialize;
let mut net = VectorNetwork::deserialize(v).ok()?;
let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum();
+47 -6
View File
@@ -5,8 +5,8 @@ use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, I
use figcraft_doc::geom::Affine;
use figcraft_doc::{
AutoLayout, Blur, BlurType, CellAlign, ChildLayout, Constraints, ContentAlign, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting,
ExportSize, Glass, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, StrokeCap,
StrokeJoin, StrokeProps, Texture, VConstraint, WidthPoint, WidthProfile,
ExportSize, Glass, GridAlign, GridKind, GridTrack, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign,
StrokeCap, StrokeJoin, StrokeProps, Texture, VConstraint, WidthPoint, WidthProfile,
};
use serde_json::Value;
@@ -178,6 +178,24 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint {
.filter(|c| c[0].abs() > 1e-9 && c[3].abs() > 1e-9);
let f = p.get("filters");
let filter = |k: &str| f.and_then(|f| num(f, k)).unwrap_or(0.0).clamp(-1.0, 1.0) as f32;
let filter_fields = ["exposure", "contrast", "saturation", "temperature", "tint", "highlights", "shadows"];
let mut has_filters = false;
if let Some(filters) = f.and_then(Value::as_object) {
for (key, value) in filters {
match value.as_f64().filter(|value| value.is_finite()) {
Some(value) if filter_fields.contains(&key.as_str()) => has_filters |= value != 0.0,
Some(0.0) => {}
Some(_) => cx.warn(format!("nonzero unsupported image filter `{}` was skipped", clip_str(key))),
None => cx.warn(format!("malformed image filter `{}` was skipped", clip_str(key))),
}
}
} else if f.is_some_and(|filters| !filters.is_null()) {
cx.warn("malformed image filters container was skipped");
}
let fig = cx.fig_sources.is_some();
if has_filters && fig {
cx.warn("image filters use FigCraft's approximate renderer");
}
ImagePaint {
image: cx.image_key(text(p, "imageRef").unwrap_or("")),
scale_mode,
@@ -189,7 +207,9 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint {
contrast: filter("contrast"),
saturation: filter("saturation"),
temperature: filter("temperature"),
tint: filter("tint"),
// The files' tint runs from green (negative) to magenta (positive), in REST file JSON and
// .fig alike; a positive native tint adds green. (`0 - x`: no negative zero.)
tint: 0.0 - filter("tint"),
highlights: filter("highlights"),
shadows: filter("shadows"),
},
@@ -394,7 +414,7 @@ pub(super) fn constraints(v: &Value) -> Option<Constraints> {
// ---- auto layout ----
/// The auto layout of a frame-like node (`layoutMode` other than `NONE`).
pub(super) fn auto_layout(v: &Value) -> Option<AutoLayout> {
pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
let mode = match text(v, "layoutMode")? {
"HORIZONTAL" => LayoutMode::Horizontal,
"VERTICAL" => LayoutMode::Vertical,
@@ -432,10 +452,31 @@ pub(super) fn auto_layout(v: &Value) -> Option<AutoLayout> {
grid_rows: count("gridRowCount", 2),
strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false),
reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false),
..AutoLayout::default()
column_tracks: if fig_tracks && grid { fig_grid_tracks(v, "columnTracks") } else { Vec::new() },
row_tracks: if fig_tracks && grid { fig_grid_tracks(v, "rowTracks") } else { Vec::new() },
})
}
fn fig_grid_tracks(v: &Value, axis: &str) -> Vec<GridTrack> {
v.get(crate::rest::FIG_SOURCE_FIELD)
.and_then(|source| source.get("gridTracks"))
.and_then(|tracks| tracks.get(axis))
.and_then(Value::as_array)
.into_iter()
.flatten()
.take(1_000)
.map(|track| {
let value = track.get("value").and_then(Value::as_f64).filter(|n| n.is_finite() && (0.0..=MAX_SIZE).contains(n));
match (track.get("sizing").and_then(Value::as_str), value) {
(Some("fixed"), Some(px)) => GridTrack::fixed(px),
(Some("fill"), Some(fr)) if fr > 0.0 => GridTrack::fill(fr),
(Some("hug"), _) => GridTrack::hug(),
_ => GridTrack::default(),
}
})
.collect()
}
/// How the node sizes and sits in its parent: its own hug/fixed axes (auto layout frames), its
/// fill/fixed axes as a child of an auto layout `parent_mode`, absolute positioning, min/max sizes
/// and grid cells.
@@ -550,6 +591,6 @@ pub(super) fn export_settings(v: &Value) -> Vec<ExportSetting> {
}
/// At most 200 chars of `s` (names and labels from the file stay readable in warnings).
pub(super) fn clip_str(s: &str) -> &str {
pub(crate) fn clip_str(s: &str) -> &str {
s.char_indices().nth(200).and_then(|(i, _)| s.get(..i)).unwrap_or(s)
}
+37
View File
@@ -186,6 +186,26 @@ fn paints_gradients_and_image_placeholders() {
assert!(warnings.iter().any(|w| w.starts_with("1 image without image data shows")), "{warnings:?}");
}
#[test]
fn image_filter_tint_turns_magenta_positive_into_native_green_positive() {
let rect = |name: &str, tint: f64| {
json!({
"id": name, "name": name, "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10},
"fills": [{"type": "IMAGE", "imageRef": "tint", "filters": {"tint": tint}}]
})
};
let (doc, warnings) = one(json!([rect("Magenta", 0.75), rect("Green", -0.5), rect("Neutral", 0.0)]));
let image = |name: &str| {
let PaintKind::Image(image) = &named(&doc, name).fills[0].kind else { panic!("{name} is not an image fill") };
image
};
// The same sign as a `.fig` import (`fig::tests::image_filters`).
assert!(close(f64::from(image("Magenta").filters.tint), -0.75));
assert!(close(f64::from(image("Green").filters.tint), 0.5));
assert!(image("Neutral").filters.tint.is_sign_positive(), "no negative zero");
assert!(!warnings.iter().any(|w| w.contains("approximate renderer")), "{warnings:?}");
}
#[test]
fn image_bytes_come_from_the_resolver() {
let png = {
@@ -363,6 +383,7 @@ fn auto_layout_constraints_and_grids() {
]},
{"id": "4", "name": "Grid", "type": "FRAME", "relativeTransform": at(200.0, 0.0), "size": {"x": 300, "y": 200},
"layoutMode": "GRID", "gridColumnCount": 3, "gridRowCount": 2, "gridColumnGap": 4, "gridRowGap": 6,
"_figSource": {"gridTracks": {"columnTracks": [{"sizing": "fixed", "value": 120}] }},
"children": [{"id": "5", "name": "Cell", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10},
"gridColumnAnchorIndex": 1, "gridRowAnchorIndex": 0, "gridColumnSpan": 2, "gridRowSpan": 1}]}
]));
@@ -389,6 +410,7 @@ fn auto_layout_constraints_and_grids() {
assert_eq!((abs.constraints.horizontal, abs.constraints.vertical), (HConstraint::LeftRight, VConstraint::Bottom));
let grid = named(&doc, "Grid").layout.clone().unwrap();
assert_eq!((grid.mode, grid.grid_columns, grid.grid_rows, grid.gap, grid.cross_gap), (LayoutMode::Grid, 3, 2, 4.0, 6.0));
assert!(grid.column_tracks.is_empty(), "private .fig track metadata is ignored by the public REST importer");
let cell = &named(&doc, "Cell").child_layout;
assert_eq!((cell.grid_column, cell.grid_row, cell.grid_column_span, cell.grid_row_span), (Some(1), Some(0), 2, 1));
}
@@ -707,3 +729,18 @@ fn layer_count_is_capped() {
assert_eq!(doc.node_count(), MAX_NODES + 1);
assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}");
}
#[test]
fn the_fig_import_sets_its_own_layer_cap() {
// The `.fig` import passes a cap from the file's size: above `MAX_NODES` every layer comes in.
let kids: Vec<Value> = (0..MAX_NODES + 5).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect();
let json = file(json!([page("Page", Value::Array(kids))]));
let (doc, warnings) = import_with_networks(&json, &no_images, Vec::new(), MAX_NODES + 10).unwrap();
assert_eq!(doc.node_count(), MAX_NODES + 6);
assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}");
// Below it, the import stops at the cap it was given.
let few: Vec<Value> = (0..10).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect();
let (doc, warnings) = import_with_networks(&file(json!([page("Page", Value::Array(few))])), &no_images, Vec::new(), 4).unwrap();
assert_eq!(doc.node_count(), 5);
assert!(warnings.iter().any(|w| w.contains("more than 4 layers")), "{warnings:?}");
}
+28 -2
View File
@@ -5,7 +5,7 @@ use std::collections::BTreeMap;
use figcraft_doc::{
DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase,
TextData, TextDecoration, TextStyle,
TextData, TextDecoration, TextPath, TextStyle,
};
use serde_json::Value;
@@ -27,8 +27,25 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData {
let mut td = TextData { characters, style: base, ..TextData::default() };
paragraph(style_json, &mut td);
td.runs = runs(cx, v, &td);
// Skip ink only shows on an underline. A file can carry it on any text, so elsewhere it stays out
// of the look (runs have inherited the base value by now).
for st in std::iter::once(&mut td.style).chain(td.runs.iter_mut().map(|r| &mut r.style)) {
if st.decoration != TextDecoration::Underline && st.look().skip_ink {
st.set_look(|l| l.skip_ink = false);
}
}
td.normalize();
lists(v, &mut td);
if cx.fig_sources.is_some()
&& let Some(path) = v.get("_figTextPath")
{
match (path.get("d").and_then(Value::as_str), path.get("start").and_then(Value::as_f64), path.get("flip").and_then(Value::as_bool)) {
(Some(d), Some(start), Some(flip)) if !d.is_empty() && start.is_finite() && (0.0..=1.0).contains(&start) => {
td.path = Some(Box::new(TextPath { d: d.to_owned(), start, flip }));
}
_ => cx.warn("invalid internal .fig text path data was ignored"),
}
}
td
}
@@ -106,6 +123,12 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle {
if let Some(ls) = num(s, "letterSpacing") {
t.letter_spacing = if ls == 0.0 { LetterSpacing::default() } else { LetterSpacing::Pixels(ls.clamp(-1e4, 1e4)) };
}
// Private, from the .fig mapper: the spacing was a share of the font size.
if cx.fig_sources.is_some()
&& let Some(p) = num(s, "_letterSpacingPercent")
{
t.letter_spacing = LetterSpacing::Percent(p.clamp(-1e4, 1e4));
}
match text(s, "textCase") {
Some("ORIGINAL") => t.case = TextCase::Original,
Some("UPPER") => t.case = TextCase::Upper,
@@ -210,10 +233,12 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec<StyleRun> {
out
}
/// Each paragraph's list type and nesting level from `lineTypes` and `lineIndentations`.
/// Each paragraph's list type, nesting level and start number from `lineTypes`,
/// `lineIndentations` and (from `.fig` files) `listStartOffsets`.
fn lists(v: &Value, td: &mut TextData) {
let types = list(v, "lineTypes");
let indents = list(v, "lineIndentations");
let starts = list(v, "listStartOffsets");
td.paragraphs = types
.iter()
.enumerate()
@@ -224,6 +249,7 @@ fn lists(v: &Value, td: &mut TextData) {
_ => ListStyle::None,
},
indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8),
start_offset: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32),
})
.collect();
// Clamp the levels and fold one style for all back into the layer's list style.
+2 -2
View File
@@ -250,8 +250,8 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"save_file",
"Save file",
"Save the active document as a .figcraft file. `path` is required the first time (an untitled document has no path); later saves reuse it.",
obj(json!({"path": string("Output .figcraft path")}), &[]),
"Save the active document as a .figcraft file. `path` is required the first time (an untitled document has no path); later saves reuse it. A `path` ending in .fig writes an experimental .fig export copy instead and leaves the document's own path and unsaved changes alone.",
obj(json!({"path": string("Output .figcraft path, or a .fig path for an experimental export copy")}), &[]),
false,
),
tool("undo", "Undo", "Undo the last change (one command, batch or gesture). Returns {undone: bool}.", empty(), false),
+59 -3
View File
@@ -258,11 +258,16 @@ pub(crate) fn kjoin(j: StrokeJoin) -> Join {
}
}
/// Plain centred stroke of `path` at `width`.
/// The widest stroke outlined: past this the rasteriser's fixed-point maths overflows and panics,
/// and nothing visible changes (a stroke this wide covers any canvas already).
pub const MAX_STROKE_WIDTH: f64 = 1e6;
/// Plain centred stroke of `path` at `width` (capped at [`MAX_STROKE_WIDTH`]).
pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath {
if !(width.is_finite() && width > 0.0 && finite(path)) {
return BezPath::new();
}
let width = width.min(MAX_STROKE_WIDTH);
let mut style = kurbo::Stroke::new(width)
.with_join(kjoin(props.join))
.with_miter_limit(props.miter_limit.clamp(1.0, 100.0))
@@ -284,10 +289,17 @@ pub fn stroke_outline(path: &BezPath, props: &StrokeProps, closed_shape: bool) -
/// [`stroke_outline`] of a shape whose stroke follows vector network `net` (`path` is its stroke
/// path): points with a cap or join of their own draw them there.
pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool, net: Option<&VectorNetwork>) -> BezPath {
let w = props.weight;
if !(w.is_finite() && w > 0.0) {
if !(props.weight.is_finite() && props.weight > 0.0) {
return BezPath::new();
}
let capped;
let props = if props.weight > MAX_STROKE_WIDTH {
capped = StrokeProps { weight: MAX_STROKE_WIDTH, ..props.clone() };
&capped
} else {
props
};
let w = props.weight;
let styled = net.and_then(|n| points::Styled::new(n, props));
// Centred at `width` (twice the weight for Inside/Outside, which clip half of it away).
let centred = |width: f64| match (&styled, &props.brush, props.profile()) {
@@ -525,6 +537,50 @@ mod tests {
assert!(arrowhead(&BezPath::new(), StrokeCap::LineArrow, 1.0, true).is_none());
}
#[test]
fn equal_point_joins_keep_the_layer_stroke_and_differing_ones_apply() {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((10.0, 10.0));
path.line_to((20.0, 0.0));
let net = VectorNetwork {
vertices: vec![
figcraft_doc::Vertex { cap: Some(StrokeCap::Square), ..figcraft_doc::Vertex::new(0.0, 0.0) },
figcraft_doc::Vertex { x: 10.0, y: 10.0, radius: 2.0, join: Some(StrokeJoin::Miter), ..figcraft_doc::Vertex::new(10.0, 10.0) },
figcraft_doc::Vertex { cap: Some(StrokeCap::Square), ..figcraft_doc::Vertex::new(20.0, 0.0) },
],
segments: vec![figcraft_doc::Segment::line(0, 1), figcraft_doc::Segment::line(1, 2)],
..Default::default()
};
let props = StrokeProps { weight: 4.0, ..Default::default() };
let inherited = stroke_outline_of(&path, &props, false, Some(&net));
let mut without_join = net.clone();
without_join.vertices[1].join = None;
let baseline = stroke_outline_of(&path, &props, false, Some(&without_join));
assert_eq!(inherited, baseline);
let different_layer = StrokeProps { join: StrokeJoin::Bevel, ..props };
let explicit = stroke_outline_of(&path, &different_layer, false, Some(&net));
let layer_only = stroke_outline_of(&path, &different_layer, false, Some(&without_join));
assert_ne!(explicit, layer_only);
}
#[test]
fn equal_joins_do_not_break_closed_stroke_runs() {
let path = sq(0.0, 0.0, 20.0);
let mut net = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)], true);
for vertex in &mut net.vertices {
vertex.join = Some(StrokeJoin::Round);
}
let props = StrokeProps { join: StrokeJoin::Round, weight: 4.0, ..Default::default() };
let styled = stroke_outline_of(&path, &props, true, Some(&net));
for vertex in &mut net.vertices {
vertex.join = None;
}
let inherited = stroke_outline_of(&path, &props, true, Some(&net));
assert_eq!(styled, inherited);
}
#[test]
fn dashes() {
let mut line = BezPath::new();
+8 -2
View File
@@ -18,11 +18,17 @@ pub(crate) struct Styled {
impl Styled {
/// `None` when no point has a style of its own, or the stroke is dashed, variable or a brush.
/// shortcut: dashed, variable-width and brush strokes keep the layer's caps and joins at every point; stroke them run by run too if asked for.
/// Joins equal to the layer do not require styled runs; stored overrides remain unchanged.
pub(crate) fn new(net: &VectorNetwork, props: &StrokeProps) -> Option<Styled> {
if !net.has_point_styles() || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() {
if !net.has_point_styles(props.join) || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() {
return None;
}
let net = net.rounded().unwrap_or_else(|| net.clone());
let mut net = net.rounded().unwrap_or_else(|| net.clone());
for vertex in &mut net.vertices {
if vertex.join == Some(props.join) {
vertex.join = None;
}
}
let runs = net.stroke_runs();
let mut degrees = vec![0usize; net.vertices.len()];
for s in &net.segments {
+1 -1
View File
@@ -155,7 +155,7 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
props.dashes.len().hash(&mut h);
// Points' own caps and joins (vector networks).
let net = match &n.kind {
NodeKind::Vector { network } if network.has_point_styles() => Some(network),
NodeKind::Vector { network } if network.has_point_styles(props.join) => Some(network),
_ => None,
};
for v in net.map(|n| n.vertices.as_slice()).unwrap_or_default() {
+1 -1
View File
@@ -638,7 +638,7 @@ impl W<'_> {
|| k.brush.is_some()
|| k.cap_start.is_arrow()
|| k.cap_end.is_arrow()
|| matches!(&n.kind, NodeKind::Vector { network } if network.has_point_styles())
|| matches!(&n.kind, NodeKind::Vector { network } if network.has_point_styles(k.join))
|| !(w.is_finite() && w > 0.0)
{
return None;
+25
View File
@@ -115,3 +115,28 @@ fn text_run_fill_survives_live_text_export() {
assert!(svg.contains("#000000"), "{svg}");
assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b</tspan>") && svg.contains(">a</tspan>"), "{svg}");
}
#[test]
fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() {
use figcraft_doc::{StrokeJoin, VectorNetwork};
use figcraft_geom::kurbo::Point;
let svg_with = |join: StrokeJoin| {
let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 50.0), Point::new(100.0, 0.0)], false);
for v in &mut network.vertices {
v.join = Some(StrokeJoin::Miter);
}
if let Some(v) = network.vertices.get_mut(1) {
v.join = Some(join);
}
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let v = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 50.0).unwrap();
let n = d.get_mut(v).unwrap();
n.strokes = vec![Paint::solid(Color::from_hex("000000").unwrap())];
n.stroke.weight = 2.0;
n.stroke.join = StrokeJoin::Miter;
export_svg(&d, &[v], &SvgOptions::default()).unwrap()
};
assert!(svg_with(StrokeJoin::Miter).contains("stroke-width=\"2\""), "equal joins stay a stroke");
assert!(!svg_with(StrokeJoin::Round).contains("stroke-width="), "a differing join is drawn as an outline");
}
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more