mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 17:01:09 +00:00
Merge upstream/main into feat/text-style-paragraph
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
2e331ecd70
127 files changed
+3303
-299
No files matched your search
@@ -48,3 +48,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
Generated
+32
-22
@@ -1157,6 +1157,15 @@ dependencies = [
|
||||
"zip 9.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -1763,7 +1772,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-menu",
|
||||
@@ -1795,7 +1804,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -1813,7 +1822,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -1831,7 +1840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1854,7 +1863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1863,7 +1872,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1876,7 +1885,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
@@ -1910,7 +1919,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-layout",
|
||||
@@ -1927,7 +1936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1935,7 +1944,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1943,7 +1952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1959,7 +1968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1968,7 +1977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1985,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1998,7 +2007,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -2017,7 +2026,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
@@ -2042,7 +2051,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -2058,7 +2067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"craft-font-catalog",
|
||||
@@ -2076,7 +2085,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -2104,7 +2113,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-vectordrawable"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -2118,9 +2127,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"figcraft-collab-net",
|
||||
@@ -6739,7 +6749,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+5
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated statusLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are Line truncated
|
||||
|
||||
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
|
||||
@@ -143,6 +143,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
- **2026-10-27** · Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return one outcome per step. References to failed steps fail with both step numbers; successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. Numbers unchanged.
|
||||
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
|
||||
- **2026-10-25** · M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged.
|
||||
- **2026-10-26** · Web host bridge: `?host=parent` (a same-origin page embedding FigCraft in an iframe) or `data-host="page"` on the canvas (the page's own document, over non-bubbling `CustomEvent`s) opens the host's file like File ▸ Open (over the browser open limit of 512 MB refused) and takes every file the app would otherwise download (`figcraft:save` with the `saveAs` rule), plus `figcraft:dirty`, `figcraft:ready` and `figcraft:failed`; `?author=` names the user for comments and board notes; host mode starts with no document. Unit tests cover the opt-in rule (only the exact `page` value), the `saveAs` rule and the open-name fallback. Numbers unchanged.
|
||||
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
|
||||
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
|
||||
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
|
||||
@@ -357,6 +358,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | M3, #1885: glass bends the backdrop with the reference tool's thick-lens profile: most a 1-unit bevel in from the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further in, and a fifth as much on the bevel itself (it fell off as the square of the way in from the very edge). Dispersion splits the channels over 8 steps with a triangular window, red moving furthest and blue least, as far apart as the reference tool's (at 100% red and blue move 0.16 of the bend apart; it was three samples, blue furthest), and `Glass::max_bend` bounds red's furthest step, so the backdrop read around a pane covers every sample. Tests: `glass::tests::the_bend_follows_the_thick_lens_profile`, `glass::tests::dispersion_splits_the_channels_along_the_bend`. Numbers unchanged |
|
||||
| 2026-11-02 | minor | M3, #1885: glass effects take variables (light angle, intensity, refraction, depth, dispersion, frost and splay, on layers and in effect styles, and from the Design panel's glass fields), and a glass's frost stays within 0–100, the reference tool's range: a larger frost from an older file draws at 100, and the field, a bound variable and scaling stop there. Numbers unchanged |
|
||||
| 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged |
|
||||
| 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged |
|
||||
| 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged |
|
||||
@@ -492,6 +495,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| 2026-10-27 | minor | Placed images keep their own size (M7, #1360): dropping, Place image, paste and `image.place` put an image at its pixel size and fit only a side past 4096 px (they fitted it into 1000 px); a PNG whose `pHYs` records a whole 2x–4x multiple of 72 dpi (macOS screenshots record 144 dpi) is placed at its point size, so an 800 × 600 px capture comes in at 400 × 300; other densities, such as 300 dpi scans, keep their pixel size; the image data stays at full resolution and an explicit width or height still wins. On macOS, paste reads the clipboard's PNG as is (its density and colour profile kept) and falls back to the decoded image. Numbers unchanged |
|
||||
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
|
||||
| 2026-10-25 | minor | M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. |
|
||||
| 2026-10-26 | minor | Web host bridge: `?host=parent` (a same-origin page embedding FigCraft in an iframe) or `data-host="page"` on the canvas (the page's own document, over non-bubbling `CustomEvent`s) opens the host's file like File ▸ Open and takes every file the app would otherwise download, with `figcraft:ready`, `figcraft:open`, `figcraft:save` (a `saveAs` rule), `figcraft:dirty` and `figcraft:failed`; `?author=` names the user for comments and board notes; documented in the hosting guide. Numbers unchanged |
|
||||
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
|
||||
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
|
||||
|
||||
@@ -498,7 +498,8 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
|
||||
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).map_err(failed)?;
|
||||
// `create_dir`, not `create_dir_all`: a directory or link already at this predictable name is refused.
|
||||
std::fs::create_dir(&dir).map_err(failed)?;
|
||||
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
|
||||
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
|
||||
let mut lib_s = Session::with_document(lib_doc);
|
||||
|
||||
@@ -75,3 +75,11 @@ fn codes_are_found_through_the_index_as_before() {
|
||||
assert_eq!(store.resolve_code(who(105), &codes[2]).unwrap_err(), CODE_GONE);
|
||||
assert_eq!(store.resolve_code(who(106), &codes[3]).unwrap().0, files[3].0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_taken_from_the_file_is_cleaned_like_a_given_one() {
|
||||
let dir = TempDir::new("embedded-name");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (file, ..) = store.publish("", &snapshot("Bad\u{1b}[2Jname\u{202E}")).unwrap();
|
||||
assert_eq!(file.name, "Bad[2Jname");
|
||||
}
|
||||
@@ -95,8 +95,9 @@ impl Store {
|
||||
return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
|
||||
}
|
||||
let mut doc = replica.document().clone();
|
||||
// The name the file keeps, whichever one it comes from, is cleaned.
|
||||
let name = match clean_name(name) {
|
||||
n if n.is_empty() => doc.name.clone(),
|
||||
n if n.is_empty() => clean_name(&doc.name),
|
||||
n => n,
|
||||
};
|
||||
doc.name = name.clone();
|
||||
|
||||
@@ -19,6 +19,8 @@ figcraft-format = { workspace = true }
|
||||
figcraft-ui-egui = { workspace = true }
|
||||
figcraft-collab-net = { workspace = true }
|
||||
figcraft-text = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
base64 = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -30,4 +32,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage", "Event", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Host modes: a page that runs FigCraft (as an `<iframe>`, or in its own document) opens
|
||||
//! documents in it and stores what it saves instead of the browser downloading. The messages
|
||||
//! and the two transports are `craft-web-host`'s (storytold/craft-libs): the same contract as
|
||||
//! the other Craft apps' bridges. This module is FigCraft's part of it.
|
||||
//!
|
||||
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
|
||||
//! `postMessage`; `data-host="page"` on the canvas (the page's own document) uses DOM
|
||||
//! `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present.
|
||||
//! - `figcraft:ready` (to the host) once the app is running and listening; send a file now.
|
||||
//! - `figcraft:open` (from the host): `{ name, bytes }`. It opens exactly as a file picked with
|
||||
//! File › Open or dropped on the window would (the app's own open queue, with its "Opening as
|
||||
//! a new file" card). A payload over the browser's own open limit
|
||||
//! ([`figcraft_ui_egui::browser_can_read`]'s 512 MB) is refused before its bytes are copied,
|
||||
//! and anything malformed is ignored. FigCraft reads whole files, so it says `file: false` in
|
||||
//! `ready` and a host sends `bytes`.
|
||||
//! - `figcraft:save` (to the host): `{ name, saveAs, bytes }` for every file the app hands its
|
||||
//! download service; in host mode nothing reaches the browser's downloads. `saveAs` says whether
|
||||
//! the file becomes the document's file ([`crate::hostmode::save_as`]):
|
||||
//! - A Save of the file the host opened, under its own name: `false`.
|
||||
//! - A document file (`.figcraft` or `.json`, the formats `file.save` and `file.saveAs` write)
|
||||
//! under a name the host didn't open — Save As, or a Save of a document the host never sent
|
||||
//! (an untitled one): `true`. It becomes the document's file; the next Save sends its name
|
||||
//! with `false`. Names compare without case.
|
||||
//! - Everything else is a copy or export that never becomes the document: `false` — rendered
|
||||
//! images, SVG and PDF, `.fig` copies, and the variables exports (a `.variables.json`
|
||||
//! sidecar's `.json` extension doesn't make it a document).
|
||||
//! - `figcraft:dirty` whenever unsaved changes appear or go away, `figcraft:failed` when the app
|
||||
//! couldn't start.
|
||||
//!
|
||||
//! In host mode the app starts with no document (the home screen) and the host sends the file to
|
||||
//! edit; `?author=` names the user for comments and board notes.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use figcraft_ui_egui::FigApp;
|
||||
|
||||
/// The app id in the host messages (`figcraft:ready`).
|
||||
const APP: &str = "figcraft";
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
/// Files the host sent, on their way to the app's own open queue ([`figcraft_ui_egui::pending_open`]):
|
||||
/// `(name, bytes)`.
|
||||
pub type OpenQueue = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
host: Rc<Host>,
|
||||
/// The file the host opened, and after a `saveAs` hand-over the document's file: the name a
|
||||
/// Save under (`saveAs: false`) is decided against.
|
||||
host_file: Rc<RefCell<Option<String>>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// The bridge for this page, or `None` when FigCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?), host_file: Rc::default() })
|
||||
}
|
||||
|
||||
/// Hear the files the host sends, and queue each one on `opens` for the app's own open path
|
||||
/// (the shell drains it into [`figcraft_ui_egui::pending_open`] every frame, so a host file
|
||||
/// opens like a dropped one).
|
||||
pub fn listen(&self, opens: OpenQueue, ctx: egui::Context) {
|
||||
let host_file = self.host_file.clone();
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&opens, &ctx, &host_file, &name, &data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and board notes.
|
||||
pub fn set_author(app: &mut FigApp, author: &str) {
|
||||
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
|
||||
if !author.is_empty() {
|
||||
app.session.prefs.user_name = author;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
/// The document file the host opened or the last Save As made, if any.
|
||||
pub fn host_file(&self) -> Option<String> {
|
||||
self.host_file.borrow().clone()
|
||||
}
|
||||
|
||||
/// Hand a file the app would otherwise download to the host; `false` when nothing was
|
||||
/// delivered. The `saveAs` rule is this module's documentation's.
|
||||
pub fn post_save(&self, path: &str, bytes: &[u8]) -> bool {
|
||||
let name = file_name(path);
|
||||
let host_file = self.host_file.borrow().clone();
|
||||
let save_as = crate::hostmode::save_as(&name, host_file.as_deref());
|
||||
let sent = self.host.post_save(&name, bytes, None, save_as).is_ok();
|
||||
// After a Save As hand-over the host has the new file: the next Save of it sends its name
|
||||
// with `saveAs: false`. A failed send changes nothing, so a retry is still a Save As.
|
||||
if sent && save_as {
|
||||
*self.host_file.borrow_mut() = Some(name);
|
||||
}
|
||||
sent
|
||||
}
|
||||
|
||||
/// Report unsaved changes when they appear or go away, not every frame (the crate does not
|
||||
/// repeat a state it already reported).
|
||||
pub fn set_dirty(&self, dirty: bool) {
|
||||
self.host.post_dirty(dirty);
|
||||
}
|
||||
}
|
||||
|
||||
/// The file an open message carries, queued for the app's own open path, or nothing when the
|
||||
/// payload isn't one (a `File` the app doesn't read, or over the open limit).
|
||||
fn deliver_open(opens: &OpenQueue, ctx: &egui::Context, host_file: &RefCell<Option<String>>, name: &str, data: &FileData) {
|
||||
let name = crate::hostmode::open_name(name);
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; FigCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
if figcraft_ui_egui::browser_can_read(&name, data.size()).is_err() {
|
||||
log::warn!("host file {name} is too large to open in the browser; not opened");
|
||||
return;
|
||||
}
|
||||
// The document's file is now the one the host sent: a Save of it under this name is
|
||||
// `saveAs: false`.
|
||||
*host_file.borrow_mut() = Some(name.clone());
|
||||
opens.borrow_mut().push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The last component of `path`, or the whole of it when there is none.
|
||||
fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
|
||||
//! (like [`crate::query`]): the name the bridge fills in when an open request has none, and whether
|
||||
//! a handed-over file becomes the document's file. Which pages host the app is `craft-web-host`'s.
|
||||
|
||||
/// The file name used when an open request carries no usable one.
|
||||
pub const DEFAULT_OPEN_NAME: &str = "Untitled.figcraft";
|
||||
|
||||
/// The extensions of files that can be a document's own file: the formats `file.save` and
|
||||
/// `file.saveAs` write (`file.open` opens both as native documents). A `.fig` file is an export
|
||||
/// copy instead, and so is everything else the app hands over (rendered images, SVG, PDF,
|
||||
/// variables).
|
||||
const DOC_EXTS: &[&str] = &["figcraft", "json"];
|
||||
|
||||
/// The suffix `variable.export`'s downloads carry: a `.json` that is a variables sidecar, not a
|
||||
/// document, so its extension alone can't tell the `saveAs` decision.
|
||||
const VARIABLES_SIDECAR: &str = ".variables.json";
|
||||
|
||||
/// The name `craft-web-host` gives an open request that carries no usable one (it cleans a host's
|
||||
/// name and falls back to this).
|
||||
const NAMELESS: &str = "document";
|
||||
|
||||
/// A usable file name for an open request: the cleaned name the crate hands over, or
|
||||
/// [`DEFAULT_OPEN_NAME`] when the host sent none.
|
||||
pub fn open_name(name: &str) -> String {
|
||||
if name == NAMELESS { DEFAULT_OPEN_NAME.to_string() } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// Whether the file `name` becomes the document's own file when handed over (`saveAs: true`),
|
||||
/// given the file the host opened (`None`: the host never sent one). The rule:
|
||||
///
|
||||
/// - A document file (`.figcraft` or `.json`, [`DOC_EXTS`]'s formats) under a name the host didn't
|
||||
/// open — Save As, or a Save of a document the host never sent — is the document's new file:
|
||||
/// `true`. Names compare without case.
|
||||
/// - A Save of the host's own file under its name is not: `false`.
|
||||
/// - Everything else is a copy or export that never becomes the document — rendered images, SVG,
|
||||
/// PDF, `.fig` copies, variables — and the variables sidecar counts as one whatever its `.json`
|
||||
/// extension says: `false`.
|
||||
pub fn save_as(name: &str, host_file: Option<&str>) -> bool {
|
||||
if name.to_ascii_lowercase().ends_with(VARIABLES_SIDECAR) {
|
||||
return false;
|
||||
}
|
||||
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str);
|
||||
if !ext.is_some_and(|e| DOC_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e))) {
|
||||
return false;
|
||||
}
|
||||
match host_file {
|
||||
Some(host) => !host.eq_ignore_ascii_case(name),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_names_fall_back_to_the_default_when_the_host_sent_none() {
|
||||
assert_eq!(open_name("Poster.figcraft"), "Poster.figcraft");
|
||||
assert_eq!(open_name("Poster 2.json"), "Poster 2.json");
|
||||
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_of_the_host_s_file_is_not_save_as() {
|
||||
assert!(!save_as("Poster.figcraft", Some("Poster.figcraft")));
|
||||
assert!(!save_as("poster.FIGCRAFT", Some("Poster.figcraft")), "names compare without case");
|
||||
assert!(!save_as("design.json", Some("design.json")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_document_file_is_save_as() {
|
||||
assert!(save_as("Poster 2.figcraft", Some("Poster.figcraft")));
|
||||
assert!(save_as("Poster.json", Some("Poster.figcraft")), "the other format a document's file takes");
|
||||
assert!(save_as("Untitled.figcraft", None), "the host never sent a file");
|
||||
assert!(save_as("Poster.figcraft", Some("design.json")), "the opened .json saved as .figcraft is a new file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_and_exports_never_become_the_document_s_file() {
|
||||
for name in ["Poster.png", "Poster@2x.jpg", "Poster.svg", "Poster.pdf", "Poster.fig", "Poster.csv", "Poster.variables.css"] {
|
||||
assert!(!save_as(name, Some("Poster.figcraft")), "{name} is a copy");
|
||||
assert!(!save_as(name, None), "{name} is a copy even without a host file");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_variables_sidecar_is_a_copy_whatever_its_extension() {
|
||||
assert!(!save_as("Poster.variables.json", Some("Poster.figcraft")));
|
||||
assert!(!save_as("Poster.variables.json", None));
|
||||
assert!(!save_as("design.variables.json", Some("design.variables.json")));
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,17 @@
|
||||
//! server…), but can't host a session. A page served over `https://` reaches only `wss://`
|
||||
//! servers and the same computer (browsers block `ws://` from secure pages); serve it over
|
||||
//! `http://` on a trusted network to reach plain `ws://` hosts on other machines.
|
||||
//! - host mode: `?host=parent` (a same-origin page embedding FigCraft in an `<iframe>`) or
|
||||
//! `data-host="page"` on the canvas (the page's own document) hands the page the app's files
|
||||
//! instead of downloading them and reports unsaved changes (`host`);
|
||||
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't
|
||||
//! host one. A page served over `https://` can only reach sessions on the same computer
|
||||
//!
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
|
||||
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
|
||||
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it; `?host=parent`
|
||||
//! (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the document to open
|
||||
//! and stores what FigCraft saves. `&author=` names the user for comments and board notes in either
|
||||
//! host mode.
|
||||
//!
|
||||
//! 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.
|
||||
@@ -30,6 +38,10 @@
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod hostmode;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod kept;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -32,6 +32,15 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing FigCraft, opens
|
||||
// documents in it and stores what it saves (`host`); unframed there is no parent, so that URL
|
||||
// on its own stays the normal app with downloads. `data-host="page"` on the canvas opts a
|
||||
// page in instead: it runs FigCraft in its own document and talks over DOM `CustomEvent`s on
|
||||
// the canvas.
|
||||
let bridge = crate::host::Bridge::connect(&canvas);
|
||||
let app_bridge = bridge.clone();
|
||||
// Files the host sends, on their way into the app's own open queue each frame.
|
||||
let opens: crate::host::OpenQueue = std::rc::Rc::default();
|
||||
let mut options = eframe::WebOptions::default();
|
||||
// `?webgl`: force the WebGL2 backend (troubleshooting, and headless browsers whose WebGPU
|
||||
// adapter request returns nothing).
|
||||
@@ -44,12 +53,15 @@ pub fn start() {
|
||||
.start(
|
||||
canvas,
|
||||
options,
|
||||
Box::new(|cc| {
|
||||
Box::new(move |cc| {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
log::info!("figcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
}
|
||||
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
let mut session = Session::new();
|
||||
// Host mode: the host sends the file to edit, so start with no document (the
|
||||
// home screen), as the desktop app does without a file; otherwise the session
|
||||
// starts with one new document.
|
||||
let mut session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
|
||||
// The host names its own server: the default for sharing, codes and the team list.
|
||||
if let Some(server) = page_meta("figcraft-server").as_deref().and_then(crate::query::page_server) {
|
||||
session.prefs.publish_server = server;
|
||||
@@ -60,11 +72,18 @@ pub fn start() {
|
||||
// whatever arrives 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 mut 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(), host: app_bridge.clone() }));
|
||||
fig.note_recovered(recovered);
|
||||
if let Err(e) = crate::pasted::install(cc.egui_ctx.clone()) {
|
||||
log::error!("couldn't listen for pasted images: {e:?}");
|
||||
}
|
||||
// `?author=`: the host's name for the user signs this visit's comments and board
|
||||
// notes (nothing is stored in the browser, so nothing needs keeping).
|
||||
if app_bridge.is_some()
|
||||
&& let Some(author) = query_param("author")
|
||||
{
|
||||
crate::host::Bridge::set_author(&mut fig, &author);
|
||||
}
|
||||
// `#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) {
|
||||
@@ -78,10 +97,21 @@ pub fn start() {
|
||||
log::error!("couldn't install window.figcraft: {e:?}");
|
||||
}
|
||||
}
|
||||
Ok(Box::new(WebShell { fig }))
|
||||
// The host's files arrive on the app's own open path (a dropped file's).
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.listen(opens.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
Ok(Box::new(WebShell { fig, host: app_bridge, opens }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Ready only once the runner is up: starting can still fail after the app is made.
|
||||
if let Some(bridge) = &bridge {
|
||||
match &result {
|
||||
Ok(()) => bridge.post_ready(),
|
||||
Err(e) => bridge.post_failed(&format!("{e:?}")),
|
||||
}
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
@@ -141,20 +171,45 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
struct WebShell {
|
||||
fig: FigApp,
|
||||
/// The host bridge, when the page hosts the app (`?host=parent` or `data-host="page"`).
|
||||
host: Option<crate::host::Bridge>,
|
||||
/// Files the host sent, waiting to join the app's own open queue.
|
||||
opens: crate::host::OpenQueue,
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
|
||||
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
|
||||
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
|
||||
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
|
||||
/// Run the browser shell's eframe logic callback: hand the host's files to the app's open
|
||||
/// queue, guard `background_tick` so hidden tabs still advance autosave, live and control
|
||||
/// deadlines without painting, and report unsaved changes to the host. `FigApp::ui` does
|
||||
/// not repeat it.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Every frame, hidden or not, starts here: what the last one left is what a panic keeps.
|
||||
UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok();
|
||||
// Files the host sent join the app's own open queue (a dropped file's): the same
|
||||
// "Opening as a new file" card, and `file.open` with the contents once read.
|
||||
for (name, bytes) in std::mem::take(&mut *self.opens.borrow_mut()) {
|
||||
let read: figcraft_ui_egui::ReadFuture = Box::pin(std::future::ready(Ok(bytes)));
|
||||
self.fig.ui.pending_opens.push(figcraft_ui_egui::pending_open::PendingOpen {
|
||||
label: name.clone(),
|
||||
params: serde_json::json!({ "name": name }),
|
||||
command: "file.open",
|
||||
target: None,
|
||||
read: Some(read),
|
||||
});
|
||||
}
|
||||
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
|
||||
// The host hears when unsaved changes appear or go away (crate::host).
|
||||
if let Some(bridge) = &self.host {
|
||||
bridge.set_dirty(self.fig.session.docs.iter().any(|d| d.is_dirty()));
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
@@ -182,12 +237,15 @@ impl eframe::App for WebShell {
|
||||
|
||||
/// Browser services. Files are opened and imported through the browser's file picker, which hands
|
||||
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
|
||||
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
|
||||
/// paste events with their text or image (a page can't read the clipboard when it likes).
|
||||
/// saved as downloads, or handed to the host page in host mode. Copies go to the system clipboard
|
||||
/// through egui's output; pastes arrive as paste events with their text or image (a page can't
|
||||
/// read the clipboard when it likes).
|
||||
struct WebServices {
|
||||
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
|
||||
reduce_motion: Option<web_sys::MediaQueryList>,
|
||||
ctx: egui::Context,
|
||||
/// The host bridge in host mode: files are handed over instead of downloaded ([`crate::host`]).
|
||||
host: Option<crate::host::Bridge>,
|
||||
}
|
||||
|
||||
impl Services for WebServices {
|
||||
@@ -204,11 +262,15 @@ impl Services for WebServices {
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// A Blob behind a temporary `<a download>` link, clicked and removed.
|
||||
/// A Blob behind a temporary `<a download>` link, clicked and removed; in host mode the file
|
||||
/// goes to the host page instead ([`crate::host`]).
|
||||
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
|
||||
if crate::api::capture(name, bytes) {
|
||||
return true;
|
||||
}
|
||||
if let Some(bridge) = &self.host {
|
||||
return bridge.post_save(name, bytes);
|
||||
}
|
||||
match download(name, bytes) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
@@ -217,6 +279,9 @@ impl Services for WebServices {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn host_file(&self) -> Option<String> {
|
||||
self.host.as_ref()?.host_file()
|
||||
}
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
|
||||
}
|
||||
|
||||
@@ -98,3 +98,9 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.aleksei-suholita]
|
||||
real_name = "Oleksii Sukholita"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
@@ -333,6 +333,17 @@ impl Change {
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The synced list this entry belongs to (`None` for entries kept in maps).
|
||||
pub(crate) fn family(&self) -> Option<Family> {
|
||||
match self {
|
||||
Entry::Style(_) => Some(Family::Styles),
|
||||
Entry::Comment(_) => Some(Family::Comments),
|
||||
Entry::Collection(_) => Some(Family::Collections),
|
||||
Entry::Variable(_) => Some(Family::Variables),
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live
|
||||
/// session carry their creator's client id, ids from before and ids of other forms are in
|
||||
/// space 0. `None` for entries keyed by something else (dev snapshots by layer, images by
|
||||
|
||||
@@ -95,7 +95,8 @@ pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
|
||||
let mut changes = fields;
|
||||
let new: BTreeSet<NodeId> = added.iter().copied().collect();
|
||||
// Creations, parents before children.
|
||||
added.sort_by_cached_key(|id| (depth(to, *id), *id));
|
||||
let mut memo = BTreeMap::new();
|
||||
added.sort_by_cached_key(|id| (depth(to, *id, &mut memo), *id));
|
||||
for id in &added {
|
||||
if let Some(n) = to.node(*id) {
|
||||
changes.push(create_change(n, replica.key(*id))?);
|
||||
@@ -121,18 +122,30 @@ pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
|
||||
Ok(Diff { changes, replica })
|
||||
}
|
||||
|
||||
/// Depth of `id` in `doc` (pages are 0).
|
||||
fn depth(doc: &Document, id: NodeId) -> usize {
|
||||
let mut d = 0;
|
||||
/// Depth of `id` in `doc` (pages are 0). `memo` holds the depths found so far, so a deep chain of
|
||||
/// new layers is walked once rather than once per layer.
|
||||
fn depth(doc: &Document, id: NodeId, memo: &mut BTreeMap<NodeId, usize>) -> usize {
|
||||
let mut chain = vec![id];
|
||||
let mut base = 0;
|
||||
let mut cur = doc.node(id).and_then(|n| n.parent);
|
||||
while let Some(p) = cur {
|
||||
d += 1;
|
||||
if d > 100_000 {
|
||||
if let Some(d) = memo.get(&p) {
|
||||
base = *d + 1;
|
||||
break;
|
||||
}
|
||||
// An acyclic chain is no longer than the document: this only stops a cycle.
|
||||
if chain.len() > doc.node_count() {
|
||||
break;
|
||||
}
|
||||
chain.push(p);
|
||||
cur = doc.node(p).and_then(|n| n.parent);
|
||||
}
|
||||
d
|
||||
// `chain` runs from `id` up; the last entry's depth is `base` when it ends at a root or a memo hit.
|
||||
let top = chain.len();
|
||||
for (i, c) in chain.iter().enumerate() {
|
||||
memo.insert(*c, base + (top - 1 - i));
|
||||
}
|
||||
memo.get(&id).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// New keys for the children `kids` (in their new order) whose `old` key no longer fits: the
|
||||
@@ -196,3 +209,21 @@ fn increasing_run(keys: &[Option<&str>]) -> Vec<bool> {
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod depth_tests {
|
||||
use super::*;
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
#[test]
|
||||
fn a_chain_longer_than_a_fixed_bound_is_walked_whole() {
|
||||
let mut doc = Document::new("File");
|
||||
let mut parent = doc.first_page().unwrap();
|
||||
let mut last = parent;
|
||||
for i in 0..100_050 {
|
||||
last = doc.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
parent = last;
|
||||
}
|
||||
assert_eq!(depth(&doc, last, &mut BTreeMap::new()), 100_050);
|
||||
}
|
||||
}
|
||||
@@ -310,3 +310,14 @@ fn many_sibling_placements_keep_the_order_of_counting_them() {
|
||||
assert_eq!(r.document().node(p).unwrap().children, kids[&p], "children of {p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_of_new_nested_layers_is_created_parents_first() {
|
||||
let (a, _, frame, _) = setup();
|
||||
let mut b = a.document().clone();
|
||||
let mut parent = frame;
|
||||
for i in 0..400 {
|
||||
parent = b.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
}
|
||||
assert_eq!(round_trip(&a, &b).iter().filter(|c| matches!(c, Change::Create { .. })).count(), 400);
|
||||
}
|
||||
+123
-29
@@ -54,18 +54,71 @@ struct List<T: Keyed> {
|
||||
entry: fn(T::Key) -> Entry,
|
||||
get: fn(&Document) -> &Vec<T>,
|
||||
get_mut: fn(&mut Document) -> &mut Vec<T>,
|
||||
/// The list's slot in an [`Index`].
|
||||
index: fn(&mut Index) -> &mut Option<KeyIndex<T::Key>>,
|
||||
}
|
||||
|
||||
const STYLES: List<Style> =
|
||||
List { whole: DocProp::Styles, order: DocProp::StyleOrder, entry: Entry::Style, get: |d| &d.styles, get_mut: |d| &mut d.styles };
|
||||
const COMMENTS: List<Comment> =
|
||||
List { whole: DocProp::Comments, order: DocProp::CommentOrder, entry: Entry::Comment, get: |d| &d.comments, get_mut: |d| &mut d.comments };
|
||||
/// Where the entries of each keyed list sit, kept while one batch is applied: a batch can put
|
||||
/// hundreds of thousands of new entries, and finding each one's place by a scan of the growing
|
||||
/// list would cost the square of that on the thread every session shares.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Index {
|
||||
styles: Option<KeyIndex<String>>,
|
||||
comments: Option<KeyIndex<u64>>,
|
||||
collections: Option<KeyIndex<String>>,
|
||||
variables: Option<KeyIndex<String>>,
|
||||
}
|
||||
|
||||
/// The position of the first entry with each key in one list, and the list's length when it was
|
||||
/// last brought up to date: a list whose length changed since is indexed again.
|
||||
struct KeyIndex<K> {
|
||||
at: BTreeMap<K, usize>,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl<K: Ord> KeyIndex<K> {
|
||||
fn of<T: Keyed<Key = K>>(list: &[T]) -> Self {
|
||||
let mut at = BTreeMap::new();
|
||||
for (i, e) in list.iter().enumerate() {
|
||||
at.entry(e.key()).or_insert(i);
|
||||
}
|
||||
KeyIndex { at, len: list.len() }
|
||||
}
|
||||
|
||||
/// The index in `slot`, built again from `list` unless its length and its place for `key`
|
||||
/// still match the list, so a list changed some other way is never trusted.
|
||||
fn current<'a, T: Keyed<Key = K>>(slot: &'a mut Option<Self>, list: &[T], key: &K) -> &'a mut Self {
|
||||
let stale = |ix: &Self| ix.len != list.len() || ix.at.get(key).is_some_and(|&i| list.get(i).is_none_or(|e| e.key() != *key));
|
||||
if slot.as_ref().is_none_or(stale) {
|
||||
*slot = Some(KeyIndex::of(list));
|
||||
}
|
||||
slot.get_or_insert_with(|| KeyIndex::of(list))
|
||||
}
|
||||
}
|
||||
|
||||
const STYLES: List<Style> = List {
|
||||
whole: DocProp::Styles,
|
||||
order: DocProp::StyleOrder,
|
||||
entry: Entry::Style,
|
||||
get: |d| &d.styles,
|
||||
get_mut: |d| &mut d.styles,
|
||||
index: |i| &mut i.styles,
|
||||
};
|
||||
const COMMENTS: List<Comment> = List {
|
||||
whole: DocProp::Comments,
|
||||
order: DocProp::CommentOrder,
|
||||
entry: Entry::Comment,
|
||||
get: |d| &d.comments,
|
||||
get_mut: |d| &mut d.comments,
|
||||
index: |i| &mut i.comments,
|
||||
};
|
||||
const COLLECTIONS: List<Collection> = List {
|
||||
whole: DocProp::Collections,
|
||||
order: DocProp::CollectionOrder,
|
||||
entry: Entry::Collection,
|
||||
get: |d| &d.variables.collections,
|
||||
get_mut: |d| &mut d.variables.collections,
|
||||
index: |i| &mut i.collections,
|
||||
};
|
||||
const VARIABLES: List<Variable> = List {
|
||||
whole: DocProp::Variables,
|
||||
@@ -73,6 +126,7 @@ const VARIABLES: List<Variable> = List {
|
||||
entry: Entry::Variable,
|
||||
get: |d| &d.variables.variables,
|
||||
get_mut: |d| &mut d.variables.variables,
|
||||
index: |i| &mut i.variables,
|
||||
};
|
||||
|
||||
impl<T: Keyed> List<T> {
|
||||
@@ -111,25 +165,53 @@ impl<T: Keyed> List<T> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn put(&self, doc: &mut Document, key: T::Key, value: &Json) -> Result<(), Error> {
|
||||
fn put(&self, doc: &mut Document, key: T::Key, value: &Json, index: &mut Index) -> Result<(), Error> {
|
||||
let t: T = value.read("entry")?;
|
||||
if t.key() != key {
|
||||
return Err(Error::Invalid("an entry's id must match the entry it is put at".into()));
|
||||
}
|
||||
let list = (self.get_mut)(doc);
|
||||
match list.iter_mut().find(|e| e.key() == key) {
|
||||
let ix = KeyIndex::current((self.index)(index), list, &key);
|
||||
match ix.at.get(&key).copied().and_then(|i| list.get_mut(i)) {
|
||||
Some(e) => *e = t,
|
||||
None => list.push(t),
|
||||
None => {
|
||||
list.push(t);
|
||||
ix.at.insert(key, list.len() - 1);
|
||||
ix.len = list.len();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove(&self, doc: &mut Document, key: &T::Key) {
|
||||
(self.get_mut)(doc).retain(|e| e.key() != *key);
|
||||
fn remove(&self, doc: &mut Document, key: &T::Key, index: &mut Index) {
|
||||
let list = (self.get_mut)(doc);
|
||||
let slot = (self.index)(index);
|
||||
let ix = KeyIndex::current(slot, list, key);
|
||||
// An absent entry leaves the list as it is, without a scan of it.
|
||||
let Some(at) = ix.at.get(key).copied() else {
|
||||
return;
|
||||
};
|
||||
let before = list.len();
|
||||
list.retain(|e| e.key() != *key);
|
||||
// One entry gone at `at`: the index is kept current by shifting the entries after it down,
|
||||
// so the next removal in the batch need not index the list again. Duplicates of the key
|
||||
// (more than one entry gone) leave it to be rebuilt.
|
||||
if list.len() + 1 == before {
|
||||
ix.at.remove(key);
|
||||
for i in ix.at.values_mut() {
|
||||
if *i > at {
|
||||
*i -= 1;
|
||||
}
|
||||
}
|
||||
ix.len = list.len();
|
||||
} else {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries in `keys` order first, the rest after in their current order.
|
||||
fn reorder(&self, doc: &mut Document, value: &Json) -> Result<(), Error> {
|
||||
fn reorder(&self, doc: &mut Document, value: &Json, index: &mut Index) -> Result<(), Error> {
|
||||
*(self.index)(index) = None;
|
||||
let keys: Vec<T::Key> = value.read("order")?;
|
||||
let list = (self.get_mut)(doc);
|
||||
let mut slots: Vec<Option<T>> = std::mem::take(list).into_iter().map(Some).collect();
|
||||
@@ -150,7 +232,8 @@ impl<T: Keyed> List<T> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_whole(&self, doc: &mut Document, value: &Json) -> Result<(), Error> {
|
||||
fn set_whole(&self, doc: &mut Document, value: &Json, index: &mut Index) -> Result<(), Error> {
|
||||
*(self.index)(index) = None;
|
||||
*(self.get_mut)(doc) = value.read("list")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -313,31 +396,38 @@ pub(crate) fn diff(a: &Document, b: &Document, out: &mut Vec<Change>) -> Result<
|
||||
diff_map(&a.votes, &b.votes, |x, y| x == y, |k| Entry::Ballot(k.clone()), Json::of, out)
|
||||
}
|
||||
|
||||
/// Apply a document-level change. Returns whether it can change which pages are listed (the
|
||||
/// library store page is never listed).
|
||||
/// [`apply_indexed`] for one change on its own.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error> {
|
||||
apply_indexed(doc, change, &mut Index::default())
|
||||
}
|
||||
|
||||
/// Apply a document-level change, with the keyed lists' positions kept in `index` between the
|
||||
/// changes of one batch. Returns whether it can change which pages are listed (the library store
|
||||
/// page is never listed).
|
||||
pub(crate) fn apply_indexed(doc: &mut Document, change: &Change, index: &mut Index) -> Result<bool, Error> {
|
||||
match change {
|
||||
Change::SetDoc { prop, value } => {
|
||||
if set_scalar(doc, *prop, value)? {
|
||||
return Ok(*prop == DocProp::Libraries);
|
||||
}
|
||||
match prop {
|
||||
DocProp::Styles => STYLES.set_whole(doc, value),
|
||||
DocProp::StyleOrder => STYLES.reorder(doc, value),
|
||||
DocProp::Comments => COMMENTS.set_whole(doc, value),
|
||||
DocProp::CommentOrder => COMMENTS.reorder(doc, value),
|
||||
DocProp::Collections => COLLECTIONS.set_whole(doc, value),
|
||||
DocProp::CollectionOrder => COLLECTIONS.reorder(doc, value),
|
||||
DocProp::Variables => VARIABLES.set_whole(doc, value),
|
||||
DocProp::VariableOrder => VARIABLES.reorder(doc, value),
|
||||
DocProp::Styles => STYLES.set_whole(doc, value, index),
|
||||
DocProp::StyleOrder => STYLES.reorder(doc, value, index),
|
||||
DocProp::Comments => COMMENTS.set_whole(doc, value, index),
|
||||
DocProp::CommentOrder => COMMENTS.reorder(doc, value, index),
|
||||
DocProp::Collections => COLLECTIONS.set_whole(doc, value, index),
|
||||
DocProp::CollectionOrder => COLLECTIONS.reorder(doc, value, index),
|
||||
DocProp::Variables => VARIABLES.set_whole(doc, value, index),
|
||||
DocProp::VariableOrder => VARIABLES.reorder(doc, value, index),
|
||||
_ => Err(Error::Invalid(format!("{prop:?} is not a list"))),
|
||||
}?;
|
||||
}
|
||||
Change::Put { entry, value } => match entry {
|
||||
Entry::Style(k) => STYLES.put(doc, k.clone(), value)?,
|
||||
Entry::Comment(k) => COMMENTS.put(doc, *k, value)?,
|
||||
Entry::Collection(k) => COLLECTIONS.put(doc, k.clone(), value)?,
|
||||
Entry::Variable(k) => VARIABLES.put(doc, k.clone(), value)?,
|
||||
Entry::Style(k) => STYLES.put(doc, k.clone(), value, index)?,
|
||||
Entry::Comment(k) => COMMENTS.put(doc, *k, value, index)?,
|
||||
Entry::Collection(k) => COLLECTIONS.put(doc, k.clone(), value, index)?,
|
||||
Entry::Variable(k) => VARIABLES.put(doc, k.clone(), value, index)?,
|
||||
Entry::DevSnapshot(k) => {
|
||||
let snap: DevSnapshot = value.read("dev snapshot")?;
|
||||
doc.dev_snapshots.insert(*k, Arc::new(snap));
|
||||
@@ -351,14 +441,18 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
|
||||
}
|
||||
},
|
||||
Change::Remove { entry } => match entry {
|
||||
Entry::Style(k) => STYLES.remove(doc, k),
|
||||
Entry::Comment(k) => COMMENTS.remove(doc, k),
|
||||
Entry::Collection(k) => COLLECTIONS.remove(doc, k),
|
||||
Entry::Variable(k) => VARIABLES.remove(doc, k),
|
||||
Entry::Style(k) => STYLES.remove(doc, k, index),
|
||||
Entry::Comment(k) => COMMENTS.remove(doc, k, index),
|
||||
Entry::Collection(k) => COLLECTIONS.remove(doc, k, index),
|
||||
Entry::Variable(k) => VARIABLES.remove(doc, k, index),
|
||||
Entry::DevSnapshot(k) => {
|
||||
doc.dev_snapshots.remove(k);
|
||||
}
|
||||
Entry::Image(k) => {
|
||||
// Nothing to remove: no need to look through the document for what shows it.
|
||||
if !doc.images.contains_key(k) {
|
||||
return Ok(false);
|
||||
}
|
||||
// An image a layer or style shows stays: removing it would let other bytes go
|
||||
// under its key, swapping the picture that layer shows ([`image_for`]).
|
||||
if figcraft_doc::used_images(doc).contains(k) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Keyed document lists (comments, styles, variables, collections) applied in large batches: a
|
||||
//! batch of new entries is placed through an index, not a scan of the growing list per entry, and
|
||||
//! puts, removes and reorders in one batch still leave the list a scan would.
|
||||
|
||||
use figcraft_doc::{Comment, Document, Message};
|
||||
|
||||
use crate::change::{Change, DocProp, Entry, Json};
|
||||
use crate::{Mode, Replica};
|
||||
|
||||
fn comment(id: u64, text: &str, doc: &Document) -> Comment {
|
||||
let page = doc.first_page().unwrap();
|
||||
Comment { id, page, x: 1.0, y: 2.0, anchor: None, first: Message::new("A", text, 0), resolved: false, replies: Vec::new(), read_by: None }
|
||||
}
|
||||
|
||||
fn put(c: &Comment) -> Change {
|
||||
Change::Put { entry: Entry::Comment(c.id), value: Json::of(c).unwrap() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_batch_of_new_comments_lands_whole_and_in_order() {
|
||||
let doc = Document::new("t");
|
||||
let changes: Vec<Change> = (1..=100_000).map(|i| put(&comment(i, "hi", &doc))).collect();
|
||||
let mut r = Replica::new(doc);
|
||||
// A scan per put took minutes here; indexed it takes well under a second, even unoptimised.
|
||||
r.apply(&changes, Mode::Strict).unwrap();
|
||||
assert_eq!(r.document().comments.len(), 100_000);
|
||||
assert!(r.document().comments.iter().zip(1..).all(|(c, i)| c.id == i));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puts_removes_and_reorders_in_one_batch_match_one_by_one() {
|
||||
let doc = Document::new("t");
|
||||
let c = |i, t| comment(i, t, &doc);
|
||||
let batch = vec![
|
||||
put(&c(1, "a")),
|
||||
put(&c(2, "b")),
|
||||
put(&c(3, "c")),
|
||||
// Replaced in place.
|
||||
put(&c(2, "B")),
|
||||
Change::Remove { entry: Entry::Comment(1) },
|
||||
// Absent: nothing changes.
|
||||
Change::Remove { entry: Entry::Comment(9) },
|
||||
put(&c(4, "d")),
|
||||
put(&c(6, "f")),
|
||||
// Two removals in a row: the second goes through the index the first kept current.
|
||||
Change::Remove { entry: Entry::Comment(2) },
|
||||
Change::Remove { entry: Entry::Comment(6) },
|
||||
put(&c(2, "B")),
|
||||
put(&c(3, "C")),
|
||||
Change::SetDoc { prop: DocProp::CommentOrder, value: Json::of(&[4u64, 3, 2]).unwrap() },
|
||||
put(&c(2, "BB")),
|
||||
put(&c(1, "again")),
|
||||
Change::SetDoc { prop: DocProp::Comments, value: Json::of(&vec![c(7, "x"), c(8, "y")]).unwrap() },
|
||||
put(&c(8, "Y")),
|
||||
put(&c(5, "e")),
|
||||
];
|
||||
let mut together = Replica::new(doc.clone());
|
||||
together.apply(&batch, Mode::Strict).unwrap();
|
||||
let mut one_by_one = Replica::new(doc.clone());
|
||||
for ch in &batch {
|
||||
one_by_one.apply(std::slice::from_ref(ch), Mode::Strict).unwrap();
|
||||
}
|
||||
assert_eq!(together.document().comments, one_by_one.document().comments);
|
||||
let got: Vec<(u64, &str)> = together.document().comments.iter().map(|c| (c.id, c.first.text.as_str())).collect();
|
||||
assert_eq!(got, vec![(7, "x"), (8, "Y"), (5, "e")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_absent_image_does_not_fail() {
|
||||
let mut doc = Document::new("t");
|
||||
let remove = Change::Remove { entry: Entry::Image("missing".into()) };
|
||||
assert!(!crate::docprops::apply(&mut doc, &remove).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removals_in_one_batch_keep_the_index_current() {
|
||||
let doc = Document::new("t");
|
||||
let c = |i, t| comment(i, t, &doc);
|
||||
let batch = vec![
|
||||
put(&c(1, "a")),
|
||||
put(&c(2, "b")),
|
||||
put(&c(3, "c")),
|
||||
put(&c(4, "d")),
|
||||
put(&c(5, "e")),
|
||||
Change::Remove { entry: Entry::Comment(2) },
|
||||
Change::Remove { entry: Entry::Comment(9) },
|
||||
Change::Remove { entry: Entry::Comment(4) },
|
||||
// Replaced where the shifted index says they now are.
|
||||
put(&c(5, "E")),
|
||||
put(&c(3, "C")),
|
||||
put(&c(1, "A")),
|
||||
Change::Remove { entry: Entry::Comment(1) },
|
||||
put(&c(2, "B")),
|
||||
put(&c(5, "EE")),
|
||||
];
|
||||
let mut together = Replica::new(doc.clone());
|
||||
together.apply(&batch, Mode::Strict).unwrap();
|
||||
let mut one_by_one = Replica::new(doc.clone());
|
||||
for ch in &batch {
|
||||
one_by_one.apply(std::slice::from_ref(ch), Mode::Strict).unwrap();
|
||||
}
|
||||
assert_eq!(together.document().comments, one_by_one.document().comments);
|
||||
let got: Vec<(u64, &str)> = together.document().comments.iter().map(|c| (c.id, c.first.text.as_str())).collect();
|
||||
assert_eq!(got, vec![(3, "C"), (5, "EE"), (2, "B")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keys_from_a_whole_list_match_one_by_one() {
|
||||
let doc = Document::new("t");
|
||||
let c = |i, t| comment(i, t, &doc);
|
||||
let batch = vec![
|
||||
Change::SetDoc { prop: DocProp::Comments, value: Json::of(&vec![c(1, "a"), c(2, "b"), c(2, "b2"), c(3, "c"), c(4, "d")]).unwrap() },
|
||||
// The first entry with the key is the one replaced, as a scan finds it.
|
||||
put(&c(2, "B")),
|
||||
put(&c(4, "D")),
|
||||
// Both entries with the key go: the index is built again for what follows.
|
||||
Change::Remove { entry: Entry::Comment(2) },
|
||||
put(&c(3, "C")),
|
||||
Change::Remove { entry: Entry::Comment(1) },
|
||||
put(&c(2, "again")),
|
||||
put(&c(4, "DD")),
|
||||
];
|
||||
let mut together = Replica::new(doc.clone());
|
||||
together.apply(&batch, Mode::Lenient).unwrap();
|
||||
let mut one_by_one = Replica::new(doc.clone());
|
||||
for ch in &batch {
|
||||
one_by_one.apply(std::slice::from_ref(ch), Mode::Lenient).unwrap();
|
||||
}
|
||||
assert_eq!(together.document().comments, one_by_one.document().comments);
|
||||
let got: Vec<(u64, &str)> = together.document().comments.iter().map(|c| (c.id, c.first.text.as_str())).collect();
|
||||
assert_eq!(got, vec![(3, "C"), (4, "DD"), (2, "again")]);
|
||||
}
|
||||
@@ -148,6 +148,8 @@ mod image_tests;
|
||||
#[cfg(test)]
|
||||
mod journal_tests;
|
||||
#[cfg(test)]
|
||||
mod keyed_list_tests;
|
||||
#[cfg(test)]
|
||||
mod merge_list_tests;
|
||||
#[cfg(test)]
|
||||
mod merge_tests;
|
||||
|
||||
@@ -192,8 +192,9 @@ impl Replica {
|
||||
self.doc.take_dirty();
|
||||
let mut out = Applied::default();
|
||||
let (mut relist, mut relist_all) = (BTreeSet::new(), false);
|
||||
let mut index = docprops::Index::default();
|
||||
for c in changes {
|
||||
match self.apply_one(c, &mut out) {
|
||||
match self.apply_one(c, &mut out, &mut index) {
|
||||
Ok(Relist::No) => {}
|
||||
Ok(Relist::Page(p)) => {
|
||||
relist.insert(p);
|
||||
@@ -214,7 +215,7 @@ impl Replica {
|
||||
}
|
||||
|
||||
/// Apply one change. Returns what of the page list needs rebuilding.
|
||||
fn apply_one(&mut self, c: &Change, out: &mut Applied) -> Result<Relist, Error> {
|
||||
fn apply_one(&mut self, c: &Change, out: &mut Applied, index: &mut docprops::Index) -> Result<Relist, Error> {
|
||||
match c {
|
||||
Change::Create { id, kind, parent, position, props } => {
|
||||
frac::validate(position).map_err(|e| Error::Invalid(e.to_string()))?;
|
||||
@@ -235,7 +236,9 @@ impl Replica {
|
||||
self.doc.insert_page(node).map_err(doc_err)?;
|
||||
}
|
||||
(false, Some(p)) => {
|
||||
let at = self.slot(&self.doc.get(*p).map_err(doc_err)?.children, *id, position);
|
||||
// A new id sits in no sibling list yet: no scan for it, just the binary search.
|
||||
let siblings = &self.doc.get(*p).map_err(doc_err)?.children;
|
||||
let at = siblings.partition_point(|c| (self.key(*c), *c) < (position.as_str(), *id));
|
||||
self.doc.insert(node, *p, Some(at)).map_err(doc_err)?;
|
||||
}
|
||||
_ => return Err(Error::Invalid("pages, and only pages, have no parent".into())),
|
||||
@@ -297,7 +300,7 @@ impl Replica {
|
||||
Ok(if page { Relist::Page(*id) } else { Relist::No })
|
||||
}
|
||||
Change::SetDoc { .. } | Change::Put { .. } | Change::Remove { .. } => {
|
||||
Ok(if docprops::apply(&mut self.doc, c)? { Relist::All } else { Relist::No })
|
||||
Ok(if docprops::apply_indexed(&mut self.doc, c, index)? { Relist::All } else { Relist::No })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::change::{Change, Entry, Prop, Target};
|
||||
use crate::change::{Change, Entry, Family, Prop, Target};
|
||||
use crate::message::{
|
||||
Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_comments, reads_signals,
|
||||
};
|
||||
@@ -413,10 +413,14 @@ impl Server {
|
||||
// client learned what happened to it from the catch-up.
|
||||
ClientMsg::Changes { batch_id, .. } if self.issued.get(&from).is_some_and(|p| batch_id <= p.last_batch) => Vec::new(),
|
||||
ClientMsg::Changes { base_seq, batch_id, changes } => {
|
||||
let mut denied = true;
|
||||
let result = match self.limited.get(&from) {
|
||||
Some(Access::View) => Err(Error::Invalid(VIEW_ONLY.into())),
|
||||
Some(Access::Comment) if !changes.iter().all(Change::is_comment) => Err(Error::Invalid(COMMENT_ONLY.into())),
|
||||
_ => self.accept(from, base_seq, &changes),
|
||||
_ => {
|
||||
denied = false;
|
||||
self.accept(from, base_seq, &changes)
|
||||
}
|
||||
};
|
||||
if let Some(p) = self.issued.get_mut(&from) {
|
||||
p.note(batch_id, result.is_err());
|
||||
@@ -430,7 +434,9 @@ impl Server {
|
||||
}
|
||||
Err(e) => {
|
||||
self.note(Journal::Refused { client: from, batch_id });
|
||||
let fixes = self.fixes(&changes).unwrap_or_default();
|
||||
// A refusal for lack of permission costs no repair: the client's confirmed copy
|
||||
// already is the server's state, so the fixes would only repeat it.
|
||||
let fixes = if denied { Vec::new() } else { self.fixes(&changes).unwrap_or_default() };
|
||||
vec![Outgoing::To(from, ServerMsg::Reject { batch_id, reason: reason(&e), fixes })]
|
||||
}
|
||||
}
|
||||
@@ -557,6 +563,7 @@ impl Server {
|
||||
fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result<Vec<(Entry, bool)>, Error> {
|
||||
let before = self.state.document();
|
||||
let mut touched: BTreeSet<Entry> = BTreeSet::new();
|
||||
let mut listed: BTreeMap<Family, (BTreeSet<Entry>, BTreeSet<Entry>)> = BTreeMap::new();
|
||||
for c in changes {
|
||||
match c {
|
||||
Change::Put { entry, .. } | Change::Remove { entry } if entry.id_space().is_some() => {
|
||||
@@ -565,8 +572,10 @@ impl Server {
|
||||
// A whole list replaced (the fallback for lists whose ids aren't unique).
|
||||
Change::SetDoc { prop, .. } => {
|
||||
if let Some(Ok(family)) = prop.list() {
|
||||
touched.extend(docprops::entries(before, family));
|
||||
touched.extend(docprops::entries(after, family));
|
||||
let (b, a) = (docprops::entries(before, family), docprops::entries(after, family));
|
||||
touched.extend(b.iter().chain(&a).cloned());
|
||||
// Looked up in sets below: a linear search per entry would be quadratic.
|
||||
listed.entry(family).or_insert_with(|| (b.into_iter().collect(), a.into_iter().collect()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -574,7 +583,10 @@ impl Server {
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for entry in touched {
|
||||
let (was, now) = (docprops::has(before, &entry), docprops::has(after, &entry));
|
||||
let (was, now) = match entry.family().and_then(|f| listed.get(&f)) {
|
||||
Some((b, a)) => (b.contains(&entry), a.contains(&entry)),
|
||||
None => (docprops::has(before, &entry), docprops::has(after, &entry)),
|
||||
};
|
||||
if was == now {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -182,6 +182,26 @@ fn deleted_nodes_can_come_back_but_ids_are_never_handed_out_twice() {
|
||||
assert_eq!(s.document(), gone.document());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_viewers_refused_deletion_gets_no_repair() {
|
||||
let (d, _, frame) = doc();
|
||||
let mut s = Server::new(d, "tok", Limits::default());
|
||||
let (id, out) = s.join_as(hello("tok"), crate::message::Access::View).unwrap();
|
||||
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!() };
|
||||
let mut v = Client::join(welcome).unwrap();
|
||||
let del = edit(&mut v, |d| d.remove(frame).unwrap());
|
||||
let (reason, fixes) = rejected(
|
||||
&mut s,
|
||||
id,
|
||||
match del {
|
||||
ClientMsg::Changes { changes, .. } => changes,
|
||||
_ => panic!(),
|
||||
},
|
||||
);
|
||||
assert_eq!(reason, crate::VIEW_ONLY);
|
||||
assert!(fixes.is_empty(), "{fixes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixes_roll_a_rejected_client_back_to_the_server_state() {
|
||||
let (d, _, frame) = doc();
|
||||
|
||||
+24
-18
@@ -376,26 +376,32 @@ impl Gradient {
|
||||
s
|
||||
}
|
||||
|
||||
/// The colour at `t` (0..1) along the gradient.
|
||||
/// The colour at `t` (0..1) along the gradient. Sorts the stops on every call: to sample one
|
||||
/// gradient many times, sort once with [`Self::sorted_stops`] and use [`sample_sorted`].
|
||||
pub fn sample(&self, t: f32) -> Color {
|
||||
let stops = self.sorted_stops();
|
||||
let t = clamp01(t);
|
||||
let mut prev = match stops.first() {
|
||||
Some(s) => *s,
|
||||
None => return Color::BLACK,
|
||||
};
|
||||
if t <= prev.position {
|
||||
return prev.color;
|
||||
sample_sorted(&self.sorted_stops(), t)
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour at `t` (0..1) along stops already sorted by [`Gradient::sorted_stops`]: found by a
|
||||
/// binary search, so a renderer sampling a file's gradient per pixel doesn't scan or sort its
|
||||
/// stops each time.
|
||||
pub fn sample_sorted(stops: &[ColorStop], t: f32) -> Color {
|
||||
let t = clamp01(t);
|
||||
let Some(first) = stops.first() else { return Color::BLACK };
|
||||
if t <= first.position {
|
||||
return first.color;
|
||||
}
|
||||
// The first stop after the first one at or past `t`, and the stop before it.
|
||||
let rest = stops.get(1..).unwrap_or_default();
|
||||
let i = rest.partition_point(|s| s.position < t);
|
||||
match (rest.get(i), stops.get(i)) {
|
||||
(Some(s), Some(prev)) => {
|
||||
let span = s.position - prev.position;
|
||||
let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 };
|
||||
prev.color.lerp(s.color, f)
|
||||
}
|
||||
for s in stops.iter().skip(1) {
|
||||
if t <= s.position {
|
||||
let span = s.position - prev.position;
|
||||
let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 };
|
||||
return prev.color.lerp(s.color, f);
|
||||
}
|
||||
prev = *s;
|
||||
}
|
||||
prev.color
|
||||
_ => stops.last().map_or(Color::BLACK, |s| s.color),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,44 @@ fn gradient_sampling() {
|
||||
assert_eq!(g.sample(0.5).to_hex(), "808080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_sorted_stops_matches_a_scan() {
|
||||
// The scan `sample` used to make: the first stop at or past `t`, mixed with the one before.
|
||||
fn scan(stops: &[ColorStop], t: f32) -> Color {
|
||||
let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
|
||||
let mut prev = stops[0];
|
||||
if t <= prev.position {
|
||||
return prev.color;
|
||||
}
|
||||
for s in &stops[1..] {
|
||||
if t <= s.position {
|
||||
let span = s.position - prev.position;
|
||||
let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 };
|
||||
return prev.color.lerp(s.color, f);
|
||||
}
|
||||
prev = *s;
|
||||
}
|
||||
prev.color
|
||||
}
|
||||
let grey = |v: f32| Color { r: v, g: v, b: v, a: 1.0 };
|
||||
let positions = [0.3, 0.0, 0.3, 1.0, 0.7, f32::NAN, 0.5, 0.5, 0.9];
|
||||
let g = Gradient {
|
||||
kind: GradientKind::Diamond,
|
||||
stops: positions.iter().enumerate().map(|(i, p)| ColorStop { position: *p, color: grey(i as f32 / 10.0) }).collect(),
|
||||
from: (0.5, 0.5),
|
||||
to: (1.0, 0.5),
|
||||
width: (0.5, 1.0),
|
||||
};
|
||||
let sorted = g.sorted_stops();
|
||||
for i in -10..=110 {
|
||||
let t = i as f32 / 100.0;
|
||||
assert_eq!(sample_sorted(&sorted, t), scan(&sorted, t), "t = {t}");
|
||||
assert_eq!(g.sample(t), scan(&sorted, t), "t = {t}");
|
||||
}
|
||||
assert_eq!(sample_sorted(&sorted, f32::NAN), scan(&sorted, f32::NAN));
|
||||
assert_eq!(sample_sorted(&[], 0.5), Color::BLACK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_serde_shape() {
|
||||
let p = Paint::solid(Color::from_hex("FF0000").unwrap());
|
||||
|
||||
@@ -49,6 +49,47 @@ fn glass_values_are_clamped_and_reach_their_backdrop() {
|
||||
assert_eq!(hidden.backdrop_radius(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_frost_stays_within_the_reference_range() {
|
||||
// A file from before the 0–100 range draws its frost at 100, and reaches no further.
|
||||
let g = Glass { frost: 150.0, ..Glass::default() };
|
||||
assert_eq!(g.sanitized().frost, Glass::MAX_FROST);
|
||||
let e = Effect { kind: EffectKind::Glass(g), visible: true };
|
||||
let capped = Effect { kind: EffectKind::Glass(Glass { frost: Glass::MAX_FROST, ..Glass::default() }), visible: true };
|
||||
assert_eq!(e.backdrop_radius(), capped.backdrop_radius());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_fields_take_bound_numbers_as_the_panel_shows_them() {
|
||||
let mut g = Glass::default();
|
||||
for (field, x) in [
|
||||
("lightAngle", 30.0),
|
||||
("lightIntensity", 25.0),
|
||||
("refraction", 40.0),
|
||||
("depth", 12.0),
|
||||
("dispersion", 10.0),
|
||||
("radius", 8.0),
|
||||
("splay", 50.0),
|
||||
] {
|
||||
assert!(g.set_bound(field, x), "{field}");
|
||||
}
|
||||
assert_eq!((g.light_angle, g.light_intensity, g.refraction, g.depth, g.dispersion, g.frost, g.splay), (30.0, 0.25, 0.4, 12.0, 0.1, 8.0, 0.5));
|
||||
// Out of range: clamped to the field's range.
|
||||
for (field, x) in [("lightAngle", 270.0), ("lightIntensity", 250.0), ("depth", 0.0), ("radius", 150.0), ("splay", -5.0)] {
|
||||
assert!(g.set_bound(field, x), "{field}");
|
||||
}
|
||||
assert_eq!((g.light_angle, g.light_intensity, g.depth, g.frost, g.splay), (180.0, 1.0, 1.0, Glass::MAX_FROST, 0.0));
|
||||
// A field glass doesn't have, or a value that isn't a number, changes nothing.
|
||||
let before = g.clone();
|
||||
assert!(!g.set_bound("spread", 3.0));
|
||||
assert!(!g.set_bound("splay", f64::NAN));
|
||||
assert!(!g.set_bound("depth", f64::INFINITY));
|
||||
assert_eq!(g, before);
|
||||
for field in Glass::BOUND_FIELDS {
|
||||
assert!(Glass::default().set_bound(field, 1.0), "{field}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_colours() {
|
||||
let mut e = Effect::noise();
|
||||
|
||||
+19
-2
@@ -574,6 +574,19 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove every child of `id` with its subtree, in one pass over `id`'s child list (removing
|
||||
/// them one by one would search that list once per child).
|
||||
pub fn remove_children(&mut self, id: NodeId) -> DocResult<()> {
|
||||
let kids = std::mem::take(&mut self.get_mut(id)?.children);
|
||||
for c in kids {
|
||||
for d in self.subtree(c) {
|
||||
self.nodes.remove(&d);
|
||||
self.dirty.touch(d);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether sections may go in `parent`: a page or another section (never a frame or a group).
|
||||
pub fn takes_sections(&self, parent: NodeId) -> bool {
|
||||
self.node(parent).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
|
||||
@@ -1085,6 +1098,9 @@ impl Document {
|
||||
// lookups: a wide parent or a long page list doesn't make validation quadratic.
|
||||
let edges: std::collections::BTreeSet<(NodeId, NodeId)> = self.nodes.iter().flat_map(|(p, n)| n.children.iter().map(|c| (*p, *c))).collect();
|
||||
let page_ids: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
|
||||
if page_ids.len() != self.pages.len() {
|
||||
note(&mut issues, || "a page is listed twice".to_string());
|
||||
}
|
||||
for (id, n) in self.nodes.iter() {
|
||||
if n.id != *id {
|
||||
note(&mut issues, || format!("node key {id} has id {}", n.id));
|
||||
@@ -1178,9 +1194,10 @@ impl Document {
|
||||
if store != self.libraries.store {
|
||||
Arc::make_mut(&mut self.libraries).store = store;
|
||||
}
|
||||
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store);
|
||||
// A page listed twice would be imported, exported and walked once per listing.
|
||||
let mut listed = std::collections::BTreeSet::new();
|
||||
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store && listed.insert(*p));
|
||||
// Unlisted pages join the list; a set keeps many pages from making this quadratic (#1078).
|
||||
let mut listed: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
|
||||
for (k, n) in self.nodes.iter() {
|
||||
if n.is_page() && Some(*k) != store && listed.insert(*k) {
|
||||
self.pages.push(*k);
|
||||
|
||||
+40
-4
@@ -660,7 +660,7 @@ pub struct Glass {
|
||||
pub depth: f64,
|
||||
/// Colour splitting along the curved edge (0–1).
|
||||
pub dispersion: f64,
|
||||
/// Blur of the backdrop (a blur radius, document units).
|
||||
/// Blur of the backdrop (a blur radius, document units, 0–[`Self::MAX_FROST`]).
|
||||
pub frost: f64,
|
||||
/// How widely the light spreads over the glass (0–1).
|
||||
pub splay: f64,
|
||||
@@ -673,6 +673,34 @@ impl Default for Glass {
|
||||
}
|
||||
|
||||
impl Glass {
|
||||
/// The most frost a glass takes, as the reference tool's Frost field (0–100).
|
||||
pub const MAX_FROST: f64 = 100.0;
|
||||
|
||||
/// The fields a variable binds on a glass effect (`effects/<i>/<field>`), see [`Self::set_bound`].
|
||||
pub const BOUND_FIELDS: [&'static str; 7] = ["lightAngle", "lightIntensity", "refraction", "depth", "dispersion", "radius", "splay"];
|
||||
|
||||
/// Sets `field` (one of [`Self::BOUND_FIELDS`]) from a bound number variable, read as the
|
||||
/// Design panel's field shows it: intensity, refraction, dispersion and splay in percent, the
|
||||
/// frost as `radius`, each clamped to the field's range. False for a field glass doesn't have
|
||||
/// or a value that isn't finite (nothing changes).
|
||||
pub fn set_bound(&mut self, field: &str, x: f64) -> bool {
|
||||
if !x.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let pct = (x / 100.0).clamp(0.0, 1.0);
|
||||
match field {
|
||||
"lightAngle" => self.light_angle = x.clamp(-180.0, 180.0),
|
||||
"lightIntensity" => self.light_intensity = pct,
|
||||
"refraction" => self.refraction = pct,
|
||||
"depth" => self.depth = x.clamp(1.0, 1000.0),
|
||||
"dispersion" => self.dispersion = pct,
|
||||
"radius" => self.frost = x.clamp(0.0, Self::MAX_FROST),
|
||||
"splay" => self.splay = pct,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The values clamped to their ranges (non-finite ones at their defaults).
|
||||
pub fn sanitized(&self) -> Glass {
|
||||
let d = Glass::default();
|
||||
@@ -683,15 +711,23 @@ impl Glass {
|
||||
refraction: ok(self.refraction, d.refraction, 0.0, 1.0),
|
||||
depth: ok(self.depth, d.depth, 1.0, 1000.0),
|
||||
dispersion: ok(self.dispersion, d.dispersion, 0.0, 1.0),
|
||||
frost: ok(self.frost, d.frost, 0.0, 1000.0),
|
||||
frost: ok(self.frost, d.frost, 0.0, Self::MAX_FROST),
|
||||
splay: ok(self.splay, d.splay, 0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Furthest the curved edge moves the backdrop (document units).
|
||||
/// How far `dispersion` splits the colour channels, as a share of the bend: at full dispersion
|
||||
/// red moves up to this much further and blue as much less. Matched to the reference tool:
|
||||
/// across a black-to-white edge in the bend, red and blue part about 4.7 px at 100% and 2.4 px
|
||||
/// at 50% (at 2×, depth 20, refraction 80%).
|
||||
pub const DISPERSION_SPREAD: f64 = 0.16;
|
||||
|
||||
/// Furthest the curved edge moves the backdrop (document units): at most `depth` × `refraction`
|
||||
/// (all of it a bevel in from the edge), and red's dispersion steps up to
|
||||
/// [`Self::DISPERSION_SPREAD`] × `dispersion` times that again.
|
||||
pub fn max_bend(&self) -> f64 {
|
||||
let g = self.sanitized();
|
||||
g.refraction * g.depth * (1.0 + g.dispersion * 0.25)
|
||||
g.refraction * g.depth * (1.0 + Self::DISPERSION_SPREAD * g.dispersion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ use crate::{CollectionId, ModeId, VariableValue, Variables};
|
||||
pub const MAX_EXPR_DEPTH: usize = 32;
|
||||
/// Longest string an expression builds (characters).
|
||||
const MAX_STRING: usize = 10_000;
|
||||
/// The most string bytes one operation's operands may hold together (generous: results cap at `MAX_STRING`).
|
||||
const MAX_OPERAND_BYTES: usize = 1 << 20;
|
||||
|
||||
/// An expression over variables.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -115,7 +117,19 @@ impl Expr {
|
||||
}
|
||||
Expr::Op { op, args } => (*op, args),
|
||||
};
|
||||
let vals: Vec<VariableValue> = args.iter().map(|a| a.eval_at(vars, modes, depth + 1)).collect::<Option<_>>()?;
|
||||
// Retained operands are bounded by bytes, so repeated references to one long string can't multiply it.
|
||||
let mut text_bytes = 0usize;
|
||||
let mut vals = Vec::with_capacity(args.len());
|
||||
for a in args {
|
||||
let v = a.eval_at(vars, modes, depth + 1)?;
|
||||
if let VariableValue::String(s) = &v {
|
||||
text_bytes = text_bytes.saturating_add(s.len());
|
||||
if text_bytes > MAX_OPERAND_BYTES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
vals.push(v);
|
||||
}
|
||||
apply(op, &vals)
|
||||
}
|
||||
|
||||
@@ -259,10 +273,18 @@ fn apply(op: ExprOp, vals: &[VariableValue]) -> Option<VariableValue> {
|
||||
match op {
|
||||
Add if vals.iter().any(|v| matches!(v, VariableValue::String(_))) => {
|
||||
let mut s = String::new();
|
||||
let mut chars = 0;
|
||||
for v in vals {
|
||||
s.push_str(&as_text(v)?);
|
||||
// Every operand is checked (a colour makes the whole expression None); only the
|
||||
// characters it adds are counted, and appending stops at the cap.
|
||||
let t = as_text(v)?;
|
||||
if chars < MAX_STRING {
|
||||
let before = s.len();
|
||||
s.extend(t.chars().take(MAX_STRING - chars));
|
||||
chars += s[before..].chars().count();
|
||||
}
|
||||
}
|
||||
Some(VariableValue::String(s.chars().take(MAX_STRING).collect()))
|
||||
Some(VariableValue::String(s))
|
||||
}
|
||||
Add => fold(|a, b| Some(a + b)),
|
||||
Subtract => fold(|a, b| Some(a - b)),
|
||||
|
||||
@@ -124,6 +124,13 @@ fn deep_and_hostile_expressions_are_bounded() {
|
||||
let joined = Expr::op(ExprOp::Add, vec![s(&long), s(&long)]);
|
||||
let Some(VariableValue::String(t)) = eval(&joined) else { panic!() };
|
||||
assert_eq!(t.chars().count(), 10_000, "capped");
|
||||
// Past the cap a colour operand still fails the expression, and empty operands add nothing.
|
||||
let color = Expr::Value(VariableValue::Color(Color::BLACK));
|
||||
let mut ops = vec![s(&"x".repeat(10_000))];
|
||||
ops.extend((0..50).map(|_| s("")));
|
||||
assert!(matches!(eval(&Expr::op(ExprOp::Add, ops.clone())), Some(VariableValue::String(t)) if t.chars().count() == 10_000));
|
||||
ops.push(color);
|
||||
assert_eq!(eval(&Expr::op(ExprOp::Add, ops)), None, "a colour past the cap is still a type error");
|
||||
}
|
||||
|
||||
fn nav(id: u64) -> Action {
|
||||
@@ -199,3 +206,12 @@ fn serde_round_trip_and_old_files() {
|
||||
let old: Action = serde_json::from_str(r#"{"type":"setVariable","variable":"n","value":2}"#).unwrap_or(Action::Back);
|
||||
assert_eq!(old, Action::SetVariable { variable: "n".into(), value: serde_json::json!(2), expr: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operands_holding_too_many_string_bytes_are_refused() {
|
||||
let long = "x".repeat(300_000);
|
||||
let few = Expr::op(ExprOp::Add, vec![s(&long), s(&long)]);
|
||||
assert!(eval(&few).is_some());
|
||||
let many = Expr::op(ExprOp::Add, (0..8).map(|_| s(&long)).collect());
|
||||
assert_eq!(eval(&many), None);
|
||||
}
|
||||
@@ -348,6 +348,20 @@ fn repair_drops_cycles_and_repeated_children() {
|
||||
assert_eq!(bad.subtree(p), vec![p, r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_lists_a_page_once_however_often_the_file_lists_it() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let q = d.add_page("Two");
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
v["pages"] = serde_json::json!([p, q, p, q, p]);
|
||||
let mut bad: Document = serde_json::from_value(v).unwrap();
|
||||
assert!(bad.validate().iter().any(|i| i.contains("listed twice")), "{:?}", bad.validate());
|
||||
bad.repair();
|
||||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
assert_eq!(bad.pages, vec![p, q]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_caps_the_issue_list() {
|
||||
let mut d = Document::new("x");
|
||||
@@ -846,3 +860,17 @@ fn the_colour_walker_visits_calculation_literals() {
|
||||
let ColorOperand::Literal { literal } = from else { panic!("literal operand") };
|
||||
assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_children_removes_every_subtree_and_keeps_the_parent() {
|
||||
let mut doc = Document::new("T");
|
||||
let page = doc.first_page().unwrap();
|
||||
let frame = doc.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let inner = doc.create(frame, NodeKind::Frame, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let leaf = doc.create(inner, NodeKind::Rectangle, "Leaf", 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
let other = doc.create(page, NodeKind::Rectangle, "Other", 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
doc.remove_children(frame).unwrap();
|
||||
assert!(doc.contains(frame) && doc.contains(other));
|
||||
assert!(!doc.contains(inner) && !doc.contains(leaf));
|
||||
assert!(doc.get(frame).unwrap().children.is_empty());
|
||||
}
|
||||
@@ -234,6 +234,22 @@ pub enum LeadingTrim {
|
||||
CapHeight,
|
||||
}
|
||||
|
||||
/// Where lines break when text wraps to more than one line ("Wrap style controls where lines
|
||||
/// break when text wraps to more than one line", Explore text properties).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WrapStyle {
|
||||
/// "Figma fits as many words as possible on each line before wrapping to the next."
|
||||
#[default]
|
||||
Auto,
|
||||
/// "Figma distributes text as evenly as possible across every line, so each line is close to
|
||||
/// the same length."
|
||||
Balance,
|
||||
/// "Figma keeps each line full, but adjusts the last four lines to avoid an orphan -- a single
|
||||
/// word stranded alone on the last line."
|
||||
Pretty,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ListStyle {
|
||||
@@ -483,6 +499,10 @@ pub struct TextData {
|
||||
/// as fit).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub max_lines: u32,
|
||||
/// Where lines break when the text wraps: greedily (Auto), as evenly as the words allow
|
||||
/// (Balance), or full with no one word alone on the last line (Pretty).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub wrap_style: WrapStyle,
|
||||
/// OpenType feature toggles (e.g. "liga": false).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub features: std::collections::BTreeMap<String, bool>,
|
||||
|
||||
@@ -147,6 +147,10 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
|
||||
/// main layer's, for files from before `modes` was an override group. Copies follow their main
|
||||
/// layer's modes, so a layer on other modes chose them itself.
|
||||
pub fn mark_layer_modes(doc: &mut Document) {
|
||||
// Without an instance nothing can be inside one: skip the per-layer ancestry lookups.
|
||||
if !doc.nodes().any(|n| matches!(n.kind, NodeKind::Instance { .. })) {
|
||||
return;
|
||||
}
|
||||
let chose: Vec<NodeId> = doc
|
||||
.nodes()
|
||||
.filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
|
||||
|
||||
@@ -132,6 +132,7 @@ fn text_style_differs(x: &TextData, y: &TextData) -> bool {
|
||||
|| x.hanging_list != y.hanging_list
|
||||
|| x.max_lines != y.max_lines
|
||||
|| x.features != y.features
|
||||
|| x.wrap_style != y.wrap_style
|
||||
}
|
||||
|
||||
/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was
|
||||
|
||||
@@ -160,6 +160,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
|
||||
b.hanging_list = a.hanging_list;
|
||||
b.max_lines = a.max_lines;
|
||||
b.features = a.features.clone();
|
||||
b.wrap_style = a.wrap_style;
|
||||
}
|
||||
to.styles.text = from.styles.text.clone();
|
||||
}
|
||||
|
||||
@@ -277,8 +277,16 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele
|
||||
fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>) -> BTreeSet<VariableId> {
|
||||
let mut out = BTreeSet::new();
|
||||
let mut stack: Vec<VariableId> = seeds.into_iter().collect();
|
||||
if stack.is_empty() {
|
||||
return out;
|
||||
}
|
||||
// Indexed once (the first of equal ids, as `get_by_id` finds it): a scan per step is quadratic on a long alias chain.
|
||||
let mut by_id = std::collections::HashMap::new();
|
||||
for v in &vars.variables {
|
||||
by_id.entry(v.id.as_str()).or_insert(v);
|
||||
}
|
||||
while let Some(cur) = stack.pop() {
|
||||
let Some(v) = vars.get_by_id(&cur) else { continue };
|
||||
let Some(v) = by_id.get(cur.as_str()).copied() else { continue };
|
||||
if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -79,10 +79,13 @@ pub fn usage(doc: &Document, library: &str) -> Usage {
|
||||
let records: std::collections::BTreeMap<NodeId, NodeId> =
|
||||
doc.libraries.components.iter().filter(|r| r.library == library).map(|r| (r.root, r.key)).collect();
|
||||
if !records.is_empty() {
|
||||
// One component's asset root is looked up once, however many instances use it.
|
||||
let mut roots = std::collections::BTreeMap::<NodeId, Option<NodeId>>::new();
|
||||
for n in doc.nodes() {
|
||||
if let NodeKind::Instance { component, .. } = &n.kind
|
||||
&& store.is_none_or(|st| doc.page_of(n.id) != Some(st))
|
||||
&& let Some(key) = crate::library::asset_root(doc, *component).and_then(|r| records.get(&r))
|
||||
&& let Some(key) =
|
||||
roots.entry(*component).or_insert_with(|| crate::library::asset_root(doc, *component)).and_then(|r| records.get(&r))
|
||||
{
|
||||
let c = counts.entry(*key).or_default();
|
||||
*c = c.saturating_add(1);
|
||||
|
||||
@@ -33,6 +33,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
|
||||
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "color") => Some(Color),
|
||||
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "radius" | "spread" | "offsetX" | "offsetY") => Some(Number),
|
||||
(EffectKind::LayerBlur(_) | EffectKind::BackgroundBlur(_), "radius" | "startRadius") => Some(Number),
|
||||
(EffectKind::Glass(_), f) if figcraft_doc::Glass::BOUND_FIELDS.contains(&f) => Some(Number),
|
||||
_ => None,
|
||||
},
|
||||
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, "size" | "count" | "gutter" | "offset"))) => grids.get(i).map(|_| Number),
|
||||
|
||||
@@ -156,14 +156,20 @@ impl BatchScope {
|
||||
lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))
|
||||
}
|
||||
|
||||
/// Most step names a missing-reference error lists.
|
||||
const NAME_HINTS: usize = 12;
|
||||
|
||||
fn known_names(&self) -> String {
|
||||
let mut names: Vec<_> = self.names.iter().collect();
|
||||
names.sort_by_key(|(_, i)| **i);
|
||||
if names.is_empty() {
|
||||
return "no step has an `as` name".into();
|
||||
}
|
||||
let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
|
||||
list.join(", ")
|
||||
// A few of them: the list is built for every missing reference and, with `onError:
|
||||
// continue`, kept for each, so a long table would cost its length per failed step.
|
||||
let list: Vec<_> = names.iter().take(Self::NAME_HINTS).map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
|
||||
let more = names.len().saturating_sub(Self::NAME_HINTS);
|
||||
if more > 0 { format!("{}, {more} more", list.join(", ")) } else { list.join(", ") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ pub const NODE_KEYS: &[&str] = &[
|
||||
"visible",
|
||||
"width",
|
||||
"widthProfile",
|
||||
"wrapStyle",
|
||||
"x",
|
||||
"y",
|
||||
];
|
||||
@@ -442,6 +443,9 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
|
||||
}
|
||||
if t.wrap_style != figcraft_doc::WrapStyle::Auto {
|
||||
insert_prop(o, options, "wrapStyle", || json!(t.wrap_style));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
insert_prop(o, options, "styledRuns", || serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
}
|
||||
|
||||
@@ -98,6 +98,41 @@ fn effect_moves_and_removals_reject_junk() {
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
|
||||
}
|
||||
|
||||
fn glass(s: &Session, r: NodeId) -> figcraft_doc::Glass {
|
||||
match s.doc().unwrap().node(r).unwrap().effects.first().map(|e| &e.kind) {
|
||||
Some(EffectKind::Glass(g)) => g.clone(),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_fields_take_number_variables() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "effect.add", json!({"ids": [r], "type": "glass"}));
|
||||
// Percent fields read the variable as the Design panel shows them; `radius` is the frost.
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/splay", "variable": "large"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/radius", "variable": "small"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/depth", "variable": "large"}));
|
||||
let g = glass(&s, r);
|
||||
assert_eq!((g.splay, g.frost, g.depth), (0.3, 2.0, 30.0));
|
||||
// A new value follows, clamped to the field's range (frost stops at 100).
|
||||
ex(&mut s, "variable.set", json!({"variable": "small", "value": 150}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "large", "value": 75}));
|
||||
let g = glass(&s, r);
|
||||
assert_eq!((g.splay, g.frost, g.depth), (0.75, figcraft_doc::Glass::MAX_FROST, 75.0));
|
||||
// A colour variable doesn't fit a glass field.
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [r], "field": "effects/0/refraction", "variable": "brand"})).is_err());
|
||||
// An effect style binds them too, and every layer using it follows.
|
||||
let fx = ex_value(&mut s, "style.create", json!({"type": "effect", "name": "Pane", "from": r}))["id"].as_str().unwrap().to_string();
|
||||
ex(&mut s, "style.bind", json!({"style": fx, "field": "effects/0/refraction", "variable": "large"}));
|
||||
assert_eq!(glass(&s, r).refraction, 0.75);
|
||||
assert!(s.execute("style.bind", &json!({"style": fx, "field": "effects/0/spread", "variable": "large"})).is_err(), "glass has no spread");
|
||||
}
|
||||
|
||||
fn ex_value(s: &mut Session, cmd: &str, p: serde_json::Value) -> serde_json::Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
|
||||
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
|
||||
}
|
||||
|
||||
@@ -109,7 +109,9 @@ pub fn eligible(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
let ids: Vec<NodeId> = ids.iter().copied().filter(|i| doc.contains(*i)).collect();
|
||||
// Each id counts once: a repeated group must not be walked once per repeat.
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let ids: Vec<NodeId> = ids.iter().copied().filter(|i| doc.contains(*i) && seen.insert(*i)).collect();
|
||||
let mut out = Vec::new();
|
||||
for id in roots(doc, &ids) {
|
||||
walk(doc, id, 0, &mut out);
|
||||
|
||||
@@ -403,7 +403,9 @@ fn visible_handles(net: &VectorNetwork, ve: &VectorEdit) -> Vec<(usize, bool)> {
|
||||
}
|
||||
|
||||
/// Hit test in page coordinates with a tolerance in page units. Visible handles win, then
|
||||
/// vertices, then segments.
|
||||
/// vertices, then segments; between a handle and a point both within reach, the nearer one on
|
||||
/// the page, the handle on a tie (the handles of a short segment end a few pixels from their
|
||||
/// point, and a click on the point is on the point, #1875).
|
||||
pub fn hit(doc: &Document, ve: &VectorEdit, p: Point, tol: f64) -> Option<VHit> {
|
||||
let net = network(doc, ve.id)?;
|
||||
let w = doc.world_transform(ve.id);
|
||||
@@ -414,17 +416,31 @@ pub fn hit(doc: &Document, ve: &VectorEdit, p: Point, tol: f64) -> Option<VHit>
|
||||
if !(lp.x.is_finite() && lp.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
for (si, start) in visible_handles(net, ve) {
|
||||
let (Some(seg), Some(t)) = (net.segments.get(si), net.handle(si, start)) else { continue };
|
||||
let Some(v) = net.vertices.get(if start { seg.a } else { seg.b }) else { continue };
|
||||
if (Point::new(v.x + t.0, v.y + t.1) - lp).hypot() <= tl {
|
||||
return Some(VHit::Handle { seg: si, start });
|
||||
}
|
||||
}
|
||||
let nearest_vertex =
|
||||
net.vertices.iter().enumerate().map(|(i, v)| (i, (v.point() - lp).hypot())).filter(|(_, d)| *d <= tl).min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
if let Some((index, _)) = nearest_vertex {
|
||||
return Some(VHit::Vertex { index });
|
||||
// Candidates are within the tolerance as before; the nearest is judged on the page, where a
|
||||
// non-uniform scale doesn't distort it.
|
||||
let on_page = |q: Point| (w * q - p).hypot();
|
||||
let nearest_handle = visible_handles(net, ve)
|
||||
.into_iter()
|
||||
.filter_map(|(si, start)| {
|
||||
let (seg, t) = (net.segments.get(si)?, net.handle(si, start)?);
|
||||
let v = net.vertices.get(if start { seg.a } else { seg.b })?;
|
||||
let end = Point::new(v.x + t.0, v.y + t.1);
|
||||
((end - lp).hypot() <= tl).then(|| ((si, start), on_page(end)))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let nearest_vertex = net
|
||||
.vertices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, v)| (v.point() - lp).hypot() <= tl)
|
||||
.map(|(i, v)| (i, on_page(v.point())))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
match (nearest_handle, nearest_vertex) {
|
||||
// A tie (a handle's end on another point) goes to the handle, as handles always won before.
|
||||
(Some(((seg, start), dh)), Some((_, dv))) if dh <= dv => return Some(VHit::Handle { seg, start }),
|
||||
(Some(((seg, start), _)), None) => return Some(VHit::Handle { seg, start }),
|
||||
(_, Some((index, _))) => return Some(VHit::Vertex { index }),
|
||||
(None, None) => {}
|
||||
}
|
||||
use figcraft_geom::kurbo::ParamCurveNearest;
|
||||
let nearest_seg = (0..net.segments.len())
|
||||
|
||||
@@ -1072,12 +1072,18 @@ impl Session {
|
||||
}
|
||||
}
|
||||
VGesture::BendVertex { v, moved, smooth, .. } => {
|
||||
let id = ve.id;
|
||||
if moved {
|
||||
self.commit_interaction()?;
|
||||
} else if smooth {
|
||||
let id = ve.id;
|
||||
self.preview(|doc, _| edit(doc, id, |net| ops::make_sharp(net, v)))?;
|
||||
self.commit_interaction()?;
|
||||
} else if network(self.doc()?, id).is_some_and(|n| n.incident(v).len() == 2) {
|
||||
// "Click on a point or path where you want to add a curve" (Edit vector
|
||||
// layers): a sharp point between two segments gets handles along the line
|
||||
// between its neighbours (#1875); an endpoint has nothing to curve.
|
||||
self.preview(|doc, _| edit(doc, id, |net| ops::auto_smooth(net, v)))?;
|
||||
self.commit_interaction()?;
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,16 @@ fn bend_tool_smooths_and_sharpens_points() {
|
||||
let n = net(&s, id);
|
||||
let v = vertex_at(&n, 100.0, 0.0);
|
||||
assert!(n.incident(v).iter().all(|(s, st)| n.handle(*s, *st) == Some((0.0, 0.0))), "a click sharpens it");
|
||||
// Clicked again, the sharp corner curves: handles along the line between its neighbours
|
||||
// (0,0) and (100,100), a third of the way to each (the box refits around the bulge, so the
|
||||
// point is found by its index).
|
||||
click(&mut s, 100.0, 0.0);
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.mirroring(v), HandleMirroring::Angle);
|
||||
for (seg, st) in n.incident(v) {
|
||||
let h = n.handle(seg, st).unwrap();
|
||||
assert!((h.0.abs() - 100.0 / 3.0 / 2f64.sqrt()).abs() < 1e-6 && (h.0 - h.1).abs() < 1e-6, "along the neighbours' line: {h:?}");
|
||||
}
|
||||
// ⌘-drag on a segment with the Move tool bends it.
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "move"}));
|
||||
let mut down = ev(PointerKind::Down, 0.0, 50.0);
|
||||
@@ -130,6 +140,123 @@ fn bend_tool_smooths_and_sharpens_points() {
|
||||
assert!(b.x0 < -25.0, "bent outwards: {b:?}");
|
||||
}
|
||||
|
||||
/// #1875: a line whose middle point was moved off the line, clicked with the Bend tool, curves
|
||||
/// there ("Click on a point or path where you want to add a curve", Edit vector layers): the
|
||||
/// handles run along the line between the neighbours, a third of the way to each, in one undo
|
||||
/// step; an endpoint is left alone; a click on the curved point makes it sharp again.
|
||||
#[test]
|
||||
fn a_bend_click_curves_a_sharp_point_between_its_neighbours() {
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 30], [100, 0]], "closed": false}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "bend"}));
|
||||
let (v, end) = (vertex_at(&net(&s, id), 50.0, 30.0), vertex_at(&net(&s, id), 0.0, 0.0));
|
||||
let before = s.undo_depth().0;
|
||||
click(&mut s, 50.0, 30.0);
|
||||
let n = net(&s, id);
|
||||
let reach = (50f64.powi(2) + 30f64.powi(2)).sqrt() / 3.0;
|
||||
for (seg, st) in n.incident(v) {
|
||||
let h = n.handle(seg, st).unwrap();
|
||||
assert!((h.0.abs() - reach).abs() < 1e-6 && h.1.abs() < 1e-6, "along the neighbours' line, a third of the way: {h:?}");
|
||||
}
|
||||
assert_eq!(n.mirroring(v), HandleMirroring::Angle);
|
||||
assert_eq!(s.undo_depth().0, before + 1, "one undo step");
|
||||
// An endpoint has nothing to curve: the click changes nothing and leaves no undo step.
|
||||
click(&mut s, 0.0, 0.0);
|
||||
let n = net(&s, id);
|
||||
assert!(n.incident(end).iter().all(|(sg, st)| n.handle(*sg, *st) == Some((0.0, 0.0))));
|
||||
assert_eq!(s.undo_depth().0, before + 1);
|
||||
// The curved point clicked again is sharp again.
|
||||
click(&mut s, 50.0, 30.0);
|
||||
let n = net(&s, id);
|
||||
assert!(n.incident(v).iter().all(|(sg, st)| n.handle(*sg, *st) == Some((0.0, 0.0))));
|
||||
}
|
||||
|
||||
/// Between a handle's end and a point both within reach, the nearer on the page wins, also under a
|
||||
/// non-uniform scale (10× wide here), where local distances would pick the point.
|
||||
#[test]
|
||||
fn a_handle_and_a_point_are_compared_on_the_page() {
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [5, 0]], "closed": false}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
{
|
||||
let node = s.doc_mut().unwrap().get_mut(id).unwrap();
|
||||
let mut n = VectorNetwork::default();
|
||||
n.vertices.push(figcraft_doc::Vertex::new(0.0, 0.0));
|
||||
n.vertices.push(figcraft_doc::Vertex::new(5.0, 0.0));
|
||||
let mut seg = figcraft_doc::Segment::line(0, 1);
|
||||
seg.ta = (0.5, -0.9);
|
||||
n.segments.push(seg);
|
||||
node.kind = figcraft_doc::NodeKind::Vector { network: n };
|
||||
node.transform = figcraft_geom::Affine::scale_non_uniform(10.0, 1.0);
|
||||
}
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [0]}));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
// On the page the point is at (0, 0) and its handle's end at (5, -0.9): from (4, 0) the
|
||||
// handle's end is 1.3 away and the point 4, though locally the point is the nearer.
|
||||
assert_eq!(hit(&doc, &ve(&s), pt(4.0, 0.0), 6.0), Some(VHit::Handle { seg: 0, start: true }));
|
||||
assert_eq!(hit(&doc, &ve(&s), pt(0.0, 0.0), 6.0), Some(VHit::Vertex { index: 0 }));
|
||||
}
|
||||
|
||||
/// A handle whose end lies exactly on another point (snapped there) can still be taken there: on a
|
||||
/// tie the handle wins, as handles always did.
|
||||
#[test]
|
||||
fn a_handle_on_another_point_is_still_the_handles() {
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [10, 0]], "closed": false}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
{
|
||||
let node = s.doc_mut().unwrap().get_mut(id).unwrap();
|
||||
let mut n = VectorNetwork::default();
|
||||
n.vertices.push(figcraft_doc::Vertex::new(0.0, 0.0));
|
||||
n.vertices.push(figcraft_doc::Vertex::new(10.0, 0.0));
|
||||
let mut seg = figcraft_doc::Segment::line(0, 1);
|
||||
seg.ta = (10.0, 0.0);
|
||||
n.segments.push(seg);
|
||||
node.kind = figcraft_doc::NodeKind::Vector { network: n };
|
||||
node.transform = figcraft_geom::Affine::IDENTITY;
|
||||
}
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [0]}));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
// Where the points are on the page now (entering edit mode may refit the layer's box).
|
||||
let n = net(&s, id);
|
||||
let w = doc.world_transform(id);
|
||||
let (p0, p1) = (w * n.vertices[0].point(), w * n.vertices[1].point());
|
||||
// Point 0's handle ends on point 1: there the handle; on point 0, the point.
|
||||
assert_eq!(hit(&doc, &ve(&s), p1, 6.0), Some(VHit::Handle { seg: 0, start: true }));
|
||||
assert_eq!(hit(&doc, &ve(&s), p0, 6.0), Some(VHit::Vertex { index: 0 }));
|
||||
}
|
||||
|
||||
/// A short path's handles end a few pixels from their point: a click on the point is still on the
|
||||
/// point (the nearer of the two wins), so the Bend tool can sharpen it again, while a click on the
|
||||
/// handle's end still takes the handle.
|
||||
#[test]
|
||||
fn short_handles_leave_their_point_clickable() {
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [6, 10], [12, 0]], "closed": false}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "bend"}));
|
||||
let v = vertex_at(&net(&s, id), 6.0, 10.0);
|
||||
click(&mut s, 6.0, 10.0);
|
||||
let n = net(&s, id);
|
||||
let curved = |n: &VectorNetwork| n.incident(v).iter().all(|(sg, st)| n.handle(*sg, *st).is_some_and(|h| h != (0.0, 0.0)));
|
||||
assert!(curved(&n));
|
||||
let reach = (6f64.powi(2) + 10f64.powi(2)).sqrt() / 3.0;
|
||||
assert!(reach < 6.0, "the handles end within the 6 px hit tolerance: {reach}");
|
||||
// A click on the handle's end takes the handle (nothing changes); one on the point sharpens it.
|
||||
let (sg, st) = n.incident(v)[0];
|
||||
let h = n.handle(sg, st).unwrap();
|
||||
click(&mut s, 6.0 + h.0, 10.0 + h.1);
|
||||
assert!(curved(&net(&s, id)), "the handle's end is the handle's");
|
||||
click(&mut s, 6.0, 10.0);
|
||||
let n = net(&s, id);
|
||||
assert!(n.incident(v).iter().all(|(sg, st)| n.handle(*sg, *st) == Some((0.0, 0.0))), "the point is the point's: sharp again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_bucket_fills_and_unfills_areas() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -465,7 +465,8 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
|
||||
}
|
||||
EffectKind::Glass(g) => {
|
||||
g.depth *= k;
|
||||
g.frost *= k;
|
||||
// From the frost it draws with (an older file's past 100 draws at 100).
|
||||
g.frost = (g.sanitized().frost * k).min(figcraft_doc::Glass::MAX_FROST);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,10 +373,11 @@ fn listed<'s>(cmd: &str, s: &'s Session, p: &Value, who: &str, people: &[&str])
|
||||
|
||||
/// Add the names `text` @mentions to `named` as `{name, known, you}`, each once.
|
||||
fn push_mentions(named: &mut Vec<Value>, text: &str, people: &[&str], user: &str) {
|
||||
// `known` and `you` follow from the name, so the name alone tells a repeat.
|
||||
let mut seen: std::collections::BTreeSet<String> = named.iter().filter_map(|v| v.get("name")?.as_str().map(str::to_string)).collect();
|
||||
for (name, known) in mentioned(text, people) {
|
||||
let v = json!({"name": name, "known": known, "you": known && name.eq_ignore_ascii_case(user)});
|
||||
if !named.contains(&v) {
|
||||
named.push(v);
|
||||
if seen.insert(name.to_string()) {
|
||||
named.push(json!({"name": name, "known": known, "you": known && name.eq_ignore_ascii_case(user)}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ fn set_track(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(CMD, format!("node {id} is not a grid auto layout frame")));
|
||||
};
|
||||
let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize).min(MAX_TRACKS);
|
||||
if !(0.0..count as f64).contains(&index) {
|
||||
return Err(bad(CMD, format!("`index` must be 0..{count}")));
|
||||
if index.fract() != 0.0 || !(0.0..count as f64).contains(&index) {
|
||||
return Err(bad(CMD, format!("`index` must be an integer in 0..{count}")));
|
||||
}
|
||||
let i = index as usize;
|
||||
let track = match kind {
|
||||
@@ -256,6 +256,7 @@ mod tests {
|
||||
json!({"ids": [f], "axis": "diagonal", "index": 0, "sizing": "fixed"}),
|
||||
json!({"ids": [f], "axis": "columns", "index": 9, "sizing": "fixed"}),
|
||||
json!({"ids": [f], "axis": "columns", "index": -1, "sizing": "hug"}),
|
||||
json!({"ids": [f], "axis": "columns", "index": 1.5, "sizing": "fixed", "value": 50}),
|
||||
json!({"ids": [f], "axis": "columns", "index": 0, "sizing": "stretchy"}),
|
||||
json!({"ids": [kids[1]], "axis": "columns", "index": 0, "sizing": "hug"}),
|
||||
] {
|
||||
|
||||
@@ -115,6 +115,8 @@ pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
let norm: String = {
|
||||
let mut out = String::new();
|
||||
let mut up = false;
|
||||
// Once, not per character: a long value made this quadratic before it was refused.
|
||||
let no_lowercase = s.chars().all(|c| !c.is_lowercase() || !c.is_alphabetic());
|
||||
for (i, ch) in s.trim().chars().enumerate() {
|
||||
if ch == '_' || ch == '-' || ch == ' ' {
|
||||
up = true;
|
||||
@@ -125,7 +127,7 @@ pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
} else if up {
|
||||
out.extend(ch.to_uppercase());
|
||||
up = false;
|
||||
} else if s.chars().all(|c| !c.is_lowercase() || !c.is_alphabetic()) {
|
||||
} else if no_lowercase {
|
||||
out.extend(ch.to_lowercase());
|
||||
} else {
|
||||
out.push(ch);
|
||||
@@ -623,7 +625,15 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
|
||||
doc.get_mut(id)?.stroke.miter_limit = StrokeProps::miter_limit_for(deg);
|
||||
}
|
||||
"dashes" => {
|
||||
let d: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default();
|
||||
let d: Vec<f64> = if v.is_null() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let a = v.as_array().ok_or_else(|| bad(cmd, "`dashes` must be an array of numbers"))?;
|
||||
a.iter()
|
||||
.map(|x| if x.is_boolean() { None } else { f64_of(x).map(|n| n.max(0.0)) })
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(|| bad(cmd, "`dashes` must be an array of numbers"))?
|
||||
};
|
||||
doc.get_mut(id)?.stroke.dashes = d;
|
||||
}
|
||||
"dashCap" => {
|
||||
@@ -953,6 +963,9 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
|
||||
*if k == "hangingList" { &mut t.hanging_list } else { &mut t.hanging_punctuation } = on;
|
||||
}
|
||||
"leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?,
|
||||
"wrapStyle" | "textWrap" => {
|
||||
text_mut(cmd, doc.get_mut(id)?)?.wrap_style = enum_of(v).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?
|
||||
}
|
||||
"maxLines" => {
|
||||
let lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32;
|
||||
let n = doc.get_mut(id)?;
|
||||
@@ -1197,7 +1210,7 @@ pub fn override_group(k: &str) -> &'static str {
|
||||
"name" => "name",
|
||||
"characters" | "text" => "characters",
|
||||
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
|
||||
| "hangingPunctuation" | "hangingList" => "textStyle",
|
||||
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" => "textStyle",
|
||||
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
||||
"width" | "height" | "w" | "h" => "size",
|
||||
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
|
||||
|
||||
@@ -31,8 +31,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("proto.interaction.remove", "Remove interaction", "{id?, index}", true, |s, p| {
|
||||
let cmd = "proto.interaction.remove";
|
||||
let k = req_num(cmd, p, "index")?;
|
||||
if k < 0.0 {
|
||||
return Err(bad(cmd, "`index` must be ≥ 0"));
|
||||
if !(0.0..1e9).contains(&k) || k.fract() != 0.0 {
|
||||
return Err(bad(cmd, "`index` must be a whole number in 0..1e9"));
|
||||
}
|
||||
let k = k as usize;
|
||||
let mut hit = false;
|
||||
@@ -406,8 +406,8 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "proto.interaction.set";
|
||||
let p = &super::proto_actions::with_expressions(cmd, &s.doc()?.variables, p)?;
|
||||
let k = req_num(cmd, p, "index")?;
|
||||
if k < 0.0 {
|
||||
return Err(bad(cmd, "`index` must be ≥ 0"));
|
||||
if !(0.0..1e9).contains(&k) || k.fract() != 0.0 {
|
||||
return Err(bad(cmd, "`index` must be a whole number in 0..1e9"));
|
||||
}
|
||||
let k = k as usize;
|
||||
let whole: Option<Interaction> = parse(cmd, p, "interaction")?;
|
||||
@@ -536,6 +536,20 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_index_is_rejected() {
|
||||
let mut s = Session::new();
|
||||
let a = frame(&mut s, 0.0);
|
||||
let b = frame(&mut s, 200.0);
|
||||
s.execute("proto.interaction.add", &json!({"id": a, "dest": b})).unwrap();
|
||||
s.execute("proto.interaction.add", &json!({"id": a, "dest": b})).unwrap();
|
||||
assert!(s.execute("proto.interaction.remove", &json!({"id": a, "index": 0.5})).is_err());
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 2);
|
||||
let before = s.doc().unwrap().node(a).unwrap().interactions[0].clone();
|
||||
assert!(s.execute("proto.interaction.set", &json!({"id": a, "index": 0.5, "easing": "linear"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions[0], before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_add_rename_remove() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"style.bind",
|
||||
"Bind variable in style",
|
||||
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>`, a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
|
||||
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
|
||||
true,
|
||||
bind_style
|
||||
),
|
||||
@@ -197,7 +197,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.bind",
|
||||
"Bind variable",
|
||||
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|fills/<i>/stops/<j>/color (gradient stops)|effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color>|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties; variant properties take string variables naming a value)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
|
||||
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|fills/<i>/stops/<j>/color (gradient stops)|effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color|lightAngle|lightIntensity|refraction|depth|dispersion|splay> (glass: `radius` is its frost; intensity, refraction, dispersion and splay in percent)|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties; variant properties take string variables naming a value)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
|
||||
true,
|
||||
bind
|
||||
),
|
||||
@@ -1370,6 +1370,9 @@ pub(crate) fn set_effect_field(effects: &mut [Effect], i: usize, prop: &str, val
|
||||
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "color", _, VariableValue::Color(c)) => s.color = *c,
|
||||
(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "radius", Some(x), _) => b.radius = x.max(0.0),
|
||||
(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "startRadius", Some(x), _) => b.start_radius = x.max(0.0),
|
||||
(EffectKind::Glass(g), field, Some(x), _) => {
|
||||
g.set_bound(field, x);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,6 +308,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
set_list(s, p, l, false)
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.wrapStyle",
|
||||
"Wrap style",
|
||||
"{style: auto|balance|pretty, ids?} where lines break when the text wraps: Auto fits as many words as fit on each line, Balance makes the lines close to the same length, Pretty keeps lines full but leaves no one word alone on the last",
|
||||
true,
|
||||
|s, p| {
|
||||
let w: figcraft_doc::WrapStyle =
|
||||
get(p, "style").and_then(super::props::enum_of).ok_or_else(|| bad("text.wrapStyle", "`style`: auto|balance|pretty"))?;
|
||||
para(s, p, |t| t.wrap_style = w)
|
||||
}
|
||||
),
|
||||
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
|
||||
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
|
||||
para(s, p, |t| t.paragraph_indent = v)
|
||||
|
||||
@@ -338,6 +338,15 @@ fn overridable_properties_stay_through_main_edits_and_reset() {
|
||||
|n| text(n).paragraph_spacing.to_string(),
|
||||
"a text property like the others",
|
||||
),
|
||||
(
|
||||
"wrap style",
|
||||
Layer::Title,
|
||||
"textStyle",
|
||||
json!({"wrapStyle": "balance"}),
|
||||
Some(json!({"wrapStyle": "pretty"})),
|
||||
|n| format!("{:?}", text(n).wrap_style),
|
||||
"a text property like the others (#1827)",
|
||||
),
|
||||
(
|
||||
"padding",
|
||||
Layer::Row,
|
||||
|
||||
@@ -189,14 +189,21 @@ fn fill_layers(s: &mut Session, copy: NodeId, (key, value): (&str, &Value), miss
|
||||
/// Export each copy into `dir` as `<name>.<format>` at `scale`, adding `files` (and `errors`) to
|
||||
/// `out`.
|
||||
fn export_copies(s: &mut Session, ids: &[(NodeId, String)], dir: &str, (format, scale): (&str, f64), out: &mut Value) {
|
||||
let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::<String>::new());
|
||||
let (mut files, mut errors) = (Vec::new(), Vec::new());
|
||||
let mut used = std::collections::BTreeSet::<String>::new();
|
||||
// The next number to try per base, so many copies of one name don't retry from 2 each time.
|
||||
let mut next = std::collections::BTreeMap::<String, u64>::new();
|
||||
for (copy, name) in ids {
|
||||
// One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively,
|
||||
// as macOS and Windows file systems compare names).
|
||||
let base = file_name(name);
|
||||
let stem =
|
||||
(1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base);
|
||||
used.push(stem.to_lowercase());
|
||||
let from = next.get(&base.to_lowercase()).copied().unwrap_or(1);
|
||||
let (k, stem) = (from..)
|
||||
.map(|k| (k, if k == 1 { base.clone() } else { format!("{base} {k}") }))
|
||||
.find(|(_, n)| !used.contains(&n.to_lowercase()))
|
||||
.unwrap_or((from, base.clone()));
|
||||
next.insert(base.to_lowercase(), k + 1);
|
||||
used.insert(stem.to_lowercase());
|
||||
let path = std::path::Path::new(dir).join(format!("{stem}.{format}")).to_string_lossy().to_string();
|
||||
match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": scale, "path": path})) {
|
||||
Ok(_) => files.push(path),
|
||||
|
||||
@@ -69,3 +69,18 @@ fn inspect_shows_it_and_copied_properties_carry_it() {
|
||||
s.execute("edit.pasteProperties", &json!({"ids": [b]})).unwrap();
|
||||
assert_eq!(cap(&s, b), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_set_rejects_bad_dashes_and_keeps_the_stroke() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s);
|
||||
let dashes = |s: &Session| s.doc().unwrap().node(l).unwrap().stroke.dashes.clone();
|
||||
for bad in [json!([5, "x", 20]), json!([5, true, 20]), json!([5, false, 20]), json!("abc"), json!(5)] {
|
||||
assert!(s.execute("node.set", &json!({"id": l, "props": {"dashes": bad}})).is_err());
|
||||
assert_eq!(dashes(&s), vec![20.0, 20.0], "a rejected value changes nothing");
|
||||
}
|
||||
s.execute("node.set", &json!({"id": l, "props": {"dashes": [5, 20]}})).unwrap();
|
||||
assert_eq!(dashes(&s), vec![5.0, 20.0]);
|
||||
s.execute("node.set", &json!({"id": l, "props": {"dashes": null}})).unwrap();
|
||||
assert!(dashes(&s).is_empty());
|
||||
}
|
||||
@@ -34,7 +34,7 @@ pub use css::{CssNames, css_var_name, css_var_of};
|
||||
use css::{css, tok};
|
||||
pub(crate) use flutter::{FLUTTER_IMPORT, Place, flutter, flutter_indent};
|
||||
use html::html;
|
||||
pub(crate) use html::{Markup, class_name};
|
||||
pub(crate) use html::{Markup, Names, class_name};
|
||||
pub(crate) use swift::swift;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -302,6 +302,11 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok
|
||||
TextAlignH::Right => put("text-align", "right".into()),
|
||||
TextAlignH::Justified => put("text-align", "justify".into()),
|
||||
}
|
||||
match tx.wrap_style {
|
||||
figcraft_doc::WrapStyle::Auto => {}
|
||||
figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()),
|
||||
figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()),
|
||||
}
|
||||
match s.case {
|
||||
TextCase::Original => {}
|
||||
TextCase::Upper => put("text-transform", "uppercase".into()),
|
||||
|
||||
@@ -30,7 +30,7 @@ fn kind_word(n: &Node) -> &str {
|
||||
/// `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` (per-codepoint transliteration, the slug
|
||||
/// libraries' way); a name left with nothing (symbols only) takes `kind`, and one starting with a
|
||||
/// digit keeps the `layer-` prefix.
|
||||
pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec<String>) -> String {
|
||||
pub(crate) fn class_name(name: &str, kind: &str, used: &mut Names) -> String {
|
||||
let mut base = String::new();
|
||||
for c in deunicode::deunicode(name).chars().flat_map(char::to_lowercase) {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
@@ -57,11 +57,30 @@ pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec<String>) -> Stri
|
||||
} else {
|
||||
base.to_string()
|
||||
};
|
||||
let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
|
||||
used.push(name.clone());
|
||||
let from = used.next.get(&base).copied().unwrap_or(1);
|
||||
let (k, name) = (from..)
|
||||
.map(|k| (k, if k == 1 { base.clone() } else { format!("{base}-{k}") }))
|
||||
.find(|(_, n)| !used.used.contains(n))
|
||||
.unwrap_or((from, base.clone()));
|
||||
used.next.insert(base, k + 1);
|
||||
used.insert(name.clone());
|
||||
name
|
||||
}
|
||||
|
||||
/// The names [`class_name`] has given out, and the next suffix to try per base, so many layers
|
||||
/// sharing one name don't each retry every suffix from 2 (that was cubic in their number).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Names {
|
||||
used: std::collections::BTreeSet<String>,
|
||||
next: std::collections::BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
impl Names {
|
||||
pub fn insert(&mut self, name: String) {
|
||||
self.used.insert(name);
|
||||
}
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('\n', "<br>")
|
||||
}
|
||||
@@ -90,7 +109,10 @@ pub(super) fn html(doc: &Document, id: NodeId) -> String {
|
||||
let ctx = Ctx::new(doc, id);
|
||||
let mut out = Markup::new(&ctx, CodeLang::Html);
|
||||
out.node(id, None, 0, "");
|
||||
format!("{}\n<style>\n{}{}</style>\n", out.markup, out.tokens.root_rule(), out.styles)
|
||||
// Document text can reach the stylesheet through any property: `<\/` reads back as `</` inside a CSS
|
||||
// string and can never close the element early.
|
||||
let css = format!("{}{}", out.tokens.root_rule(), out.styles).replace("</", "<\\/");
|
||||
format!("{}\n<style>\n{css}</style>\n", out.markup)
|
||||
}
|
||||
|
||||
/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue
|
||||
@@ -101,7 +123,7 @@ pub(crate) struct Markup<'c, 'a> {
|
||||
ctx: &'c Ctx<'a>,
|
||||
/// Html, React, Vue or Leptos.
|
||||
lang: CodeLang,
|
||||
used: Vec<String>,
|
||||
used: Names,
|
||||
pub markup: String,
|
||||
pub styles: String,
|
||||
pub tokens: Tokens,
|
||||
@@ -158,7 +180,7 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
Markup {
|
||||
ctx,
|
||||
lang,
|
||||
used: Vec::new(),
|
||||
used: Names::default(),
|
||||
markup: String::new(),
|
||||
styles: String::new(),
|
||||
tokens: Tokens::default(),
|
||||
@@ -273,7 +295,7 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
let root = n.id == self.ctx.root;
|
||||
let class = match self.root_class.take().filter(|_| root) {
|
||||
Some(c) => {
|
||||
self.used.push(c.clone());
|
||||
self.used.insert(c.clone());
|
||||
c
|
||||
}
|
||||
None => {
|
||||
@@ -329,7 +351,7 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
let open = open("div");
|
||||
let slot_ident = slot.map(|p| match self.ctx.comp {
|
||||
Some(c) => crate::component_codegen::slot_name(c, p),
|
||||
None => class_name(&p.ident, "slot", &mut Vec::new()),
|
||||
None => class_name(&p.ident, "slot", &mut Names::default()),
|
||||
});
|
||||
if let Some(p) = slot.filter(|_| kids.is_empty()) {
|
||||
let content = if leptos {
|
||||
|
||||
@@ -11,7 +11,9 @@ use figcraft_doc::{Document, NodeId, NodeKind, PropDef};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::codegen::{CodeLang, Ctx, FLUTTER_IMPORT, Markup, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift};
|
||||
use crate::codegen::{
|
||||
CodeLang, Ctx, FLUTTER_IMPORT, Markup, Names, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift,
|
||||
};
|
||||
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
|
||||
use crate::params::{id_of, string, targets};
|
||||
use crate::{bad, spec};
|
||||
@@ -616,14 +618,14 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
||||
/// The `<slot name="…">` of a slot parameter: unique among the component's slot parameters, unlike
|
||||
/// `class_name` called with a fresh list per parameter (two idents can lowercase alike).
|
||||
pub(crate) fn slot_name(c: &Component, p: &Param) -> String {
|
||||
let mut used = Vec::new();
|
||||
let mut used = Names::default();
|
||||
for q in c.params.iter().filter(|q| matches!(q.kind, ParamKind::Slot)) {
|
||||
let name = class_name(&q.ident, "slot", &mut used);
|
||||
if q.ident == p.ident {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
class_name(&p.ident, "slot", &mut Vec::new())
|
||||
class_name(&p.ident, "slot", &mut Names::default())
|
||||
}
|
||||
|
||||
/// The values an instance sets for `c`'s parameters, in order (`None` for other layers): variant
|
||||
@@ -720,7 +722,7 @@ pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, dept
|
||||
.filter(|(name, _, _)| is_slot(name))
|
||||
.map(|(name, expr, _)| {
|
||||
let slot = c.params.iter().find(|q| q.ident == *name);
|
||||
let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Vec::new()));
|
||||
let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Names::default()));
|
||||
format!("{pad} <template #{name}>\n{expr}{pad} </template>\n")
|
||||
})
|
||||
.collect();
|
||||
@@ -823,7 +825,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
|
||||
|
||||
/// `data-*` attribute names for variant parameters, unique among them (in order).
|
||||
fn data_attrs(variants: &[&Param]) -> Vec<String> {
|
||||
let mut used = Vec::new();
|
||||
let mut used = Names::default();
|
||||
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, "prop", &mut used))).collect()
|
||||
}
|
||||
|
||||
@@ -832,7 +834,7 @@ fn data_attrs(variants: &[&Param]) -> Vec<String> {
|
||||
fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) {
|
||||
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
|
||||
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
|
||||
let root_class = class_name(&root_name, "component", &mut Vec::new());
|
||||
let root_class = class_name(&root_name, "component", &mut Names::default());
|
||||
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
|
||||
let attr_names = data_attrs(&variants);
|
||||
let pass = |root: NodeId| {
|
||||
|
||||
@@ -288,8 +288,10 @@ pub fn layer_changes(before: &Document, now: &Document, id: NodeId) -> Vec<PropC
|
||||
fn order(before: &Document, now: &Document, id: NodeId) -> Option<(usize, usize)> {
|
||||
let parent = now.node(id)?.parent?;
|
||||
let (pb, pa) = (before.node(parent)?, now.node(parent)?);
|
||||
let common_b: Vec<NodeId> = pb.children.iter().rev().copied().filter(|c| pa.children.contains(c)).collect();
|
||||
let common_a: Vec<NodeId> = pa.children.iter().rev().copied().filter(|c| pb.children.contains(c)).collect();
|
||||
let (in_b, in_a): (std::collections::BTreeSet<NodeId>, std::collections::BTreeSet<NodeId>) =
|
||||
(pb.children.iter().copied().collect(), pa.children.iter().copied().collect());
|
||||
let common_b: Vec<NodeId> = pb.children.iter().rev().copied().filter(|c| in_a.contains(c)).collect();
|
||||
let common_a: Vec<NodeId> = pa.children.iter().rev().copied().filter(|c| in_b.contains(c)).collect();
|
||||
let (ib, ia) = (common_b.iter().position(|c| *c == id)?, common_a.iter().position(|c| *c == id)?);
|
||||
(ib != ia).then_some((ib + 1, ia + 1))
|
||||
}
|
||||
|
||||
@@ -475,7 +475,7 @@ pub fn properties_with(doc: &Document, id: NodeId, fmt: ColorFormat) -> Vec<Sect
|
||||
pct(g.refraction),
|
||||
num(g.depth),
|
||||
pct(g.dispersion),
|
||||
num(g.frost),
|
||||
num(g.sanitized().frost),
|
||||
pct(g.splay)
|
||||
),
|
||||
),
|
||||
|
||||
@@ -310,6 +310,8 @@ fn decl(key: &str, value: &str) -> Vec<(String, String)> {
|
||||
("text-transform", "uppercase") => kw("uppercase"),
|
||||
("text-transform", "lowercase") => kw("lowercase"),
|
||||
("text-transform", "capitalize") => kw("capitalize"),
|
||||
("text-wrap", "balance") => kw("text-balance"),
|
||||
("text-wrap", "pretty") => kw("text-pretty"),
|
||||
("text-decoration", "underline") => kw("underline"),
|
||||
("text-decoration", "line-through") => kw("line-through"),
|
||||
_ => one(format!("[{key}:{}]", arb(value).trim_matches(['[', ']']))),
|
||||
|
||||
@@ -95,6 +95,32 @@ fn scaling_scales_grain_and_glass() {
|
||||
assert_eq!((g.refraction, nz.density), (d.refraction, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_a_frost_from_an_older_file_starts_from_what_it_draws() {
|
||||
// A frost of 150, saved before the 0–100 range, draws at 100: halved, it draws at 50.
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": "glass"})).unwrap();
|
||||
s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "frost": 150}})).unwrap();
|
||||
s.execute("transform.scale", &json!({"ids": [r], "factor": 0.5})).unwrap();
|
||||
let EffectKind::Glass(g) = &s.doc().unwrap().node(r).unwrap().effects[0].kind else { panic!() };
|
||||
assert_eq!(g.frost, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_shows_a_frost_from_an_older_file_as_it_draws() {
|
||||
// A frost of 150, saved before the 0–100 range, draws at 100: Dev Mode says 100 as CSS does.
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": "glass"})).unwrap();
|
||||
s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "frost": 150}})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let sections = crate::devinspect::properties(doc, r);
|
||||
let glass = §ions.iter().find(|s| s.title == "Effects").unwrap().rows[0];
|
||||
assert!(glass.value.contains(" frost 100 "), "{}", glass.value);
|
||||
assert!(code_for(doc, r, CodeLang::Css).contains("backdrop-filter: blur(50px);"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_and_css_show_the_new_effects() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -127,12 +127,15 @@ pub fn topics(v: Option<&Value>) -> Result<Vec<String>, String> {
|
||||
for t in list {
|
||||
let Some(s) = t.as_str() else { return Err("`topics` must be an array of strings".into()) };
|
||||
match s {
|
||||
// Each once: the list is scanned for every retained event under the log's lock, so
|
||||
// a long list of repeats would multiply that work.
|
||||
"comments" | "document" | "all" if out.iter().any(|t| t == s) => {}
|
||||
"comments" | "document" | "all" => out.push(s.to_string()),
|
||||
other => return Err(format!("unknown topic `{other}` (comments, document, all)")),
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push("all".into());
|
||||
if out.is_empty() || out.iter().any(|t| t == "all") {
|
||||
out = vec!["all".into()];
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -193,6 +193,10 @@ fn topics_parse_and_reject_unknown_names() {
|
||||
assert_eq!(events::topics(Some(&json!([]))).unwrap(), vec!["all".to_string()]);
|
||||
assert_eq!(events::topics(Some(&json!(["comments", "document"]))).unwrap(), vec!["comments".to_string(), "document".to_string()]);
|
||||
assert!(events::topics(Some(&json!(["nope"]))).is_err());
|
||||
// Repeats collapse (the list is scanned per event under the log's lock), and `all` is all.
|
||||
let many: Vec<&str> = std::iter::repeat_n("comments", 10_000).chain(["document"]).collect();
|
||||
assert_eq!(events::topics(Some(&json!(many))).unwrap(), vec!["comments".to_string(), "document".to_string()]);
|
||||
assert_eq!(events::topics(Some(&json!(["comments", "all", "document"]))).unwrap(), vec!["all".to_string()]);
|
||||
assert!(events::topics(Some(&json!("comments"))).is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,16 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
|
||||
if DENIED.iter().any(|p| command.starts_with(p)) && !ALLOWED.contains(&command) {
|
||||
return Some(format!("`{command}` isn't available to plugins"));
|
||||
}
|
||||
// A preview runs its inner command, which the plugin's permissions and the denied list must cover too.
|
||||
// An inner command that can't be previewed (a nested preview) is refused by the preview itself,
|
||||
// before it runs: descending into its parameters first would only rescan them.
|
||||
if command == "text.preview"
|
||||
&& let Some(inner) = get(params, "command").and_then(Value::as_str)
|
||||
&& crate::textpreview::PREVIEWABLE.contains(&inner)
|
||||
&& let Some(why) = refusal(m, inner, get(params, "params").unwrap_or(&Value::Null))
|
||||
{
|
||||
return Some(why);
|
||||
}
|
||||
// A widget's own data is FigCraft's to write, so no plugin can forge or retarget a widget.
|
||||
if writes_widget_data(params, 0) {
|
||||
return Some(format!("plugins can't write `{}…` layer data", crate::widgets::DATA_PREFIX));
|
||||
|
||||
@@ -157,8 +157,9 @@ fn state_of(doc: &Document, w: NodeId) -> Map<String, Value> {
|
||||
/// fields), each once, in layer order.
|
||||
fn names(doc: &Document, w: NodeId, key: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(key).cloned()) {
|
||||
if !out.contains(&t) {
|
||||
if seen.insert(t.clone()) {
|
||||
out.push(t);
|
||||
}
|
||||
}
|
||||
@@ -243,9 +244,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
|
||||
if !is_widget(n) || n.plugin_data.get(PLUGIN).map(String::as_str) != Some(plugin) {
|
||||
return Err(bad(cmd, "the layer is no longer this plugin's widget"));
|
||||
}
|
||||
for c in n.children.clone() {
|
||||
doc.remove(c)?;
|
||||
}
|
||||
doc.remove_children(w)?;
|
||||
crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?;
|
||||
let n = doc.get_mut(w)?;
|
||||
n.locked = false;
|
||||
|
||||
@@ -73,6 +73,20 @@ fn non_ascii_layer_names_transliterate_into_readable_classes() {
|
||||
assert!(code.contains("<p class=\"rogeuin\">") && code.contains("<p class=\"rogeuin-2\">"), "{code}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_layers_with_one_name_get_numbered_in_linear_time() {
|
||||
// Each name used to retry every suffix from 2 against a list: cubic in their number.
|
||||
let mut used = crate::codegen::Names::default();
|
||||
let start = std::time::Instant::now();
|
||||
let names: Vec<String> = (0..20_000).map(|_| crate::codegen::class_name("Card / Title", "frame", &mut used)).collect();
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
assert_eq!(names[0], "card-title");
|
||||
assert_eq!(names[1], "card-title-2");
|
||||
assert_eq!(names[19_999], "card-title-20000");
|
||||
let distinct: std::collections::BTreeSet<&String> = names.iter().collect();
|
||||
assert_eq!(distinct.len(), names.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_scripts_and_symbol_names_transliterate_or_fall_back_to_the_kind() {
|
||||
let mut s = Session::new();
|
||||
@@ -163,3 +177,16 @@ fn rem_preserves_text_property_default() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stylesheet_never_contains_a_closing_style_tag_from_document_text() {
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
let t = id(s.execute("text.create", &json!({"text": "x", "x": 0, "y": 0})).unwrap());
|
||||
s.execute("node.set", &json!({"id": t, "props": {"fontFamily": "a</style><script>alert(1)</script>"}})).unwrap();
|
||||
s.execute("node.reparent", &json!({"ids": [t], "parent": f})).unwrap();
|
||||
let code = code_for(s.doc().unwrap(), f, CodeLang::Html);
|
||||
let (_, css) = code.split_once("<style>").unwrap();
|
||||
assert_eq!(css.matches("</style>").count(), 1, "{code}");
|
||||
assert!(!code.contains("<script>"), "{code}");
|
||||
}
|
||||
@@ -277,6 +277,11 @@ fn renaming_several_layers_by_the_documented_codes() {
|
||||
assert_eq!(r(None, "$n", Some(1e30), "a"), "1000000000000");
|
||||
assert_eq!(r(None, "$n", Some(f64::NAN), "a"), "1");
|
||||
assert_eq!(r(Some("é"), "e", None, "café é"), "cafe e");
|
||||
// A template is expanded once per match, so its length is capped (1,000 characters).
|
||||
let long = "$nn".repeat(400);
|
||||
assert!(crate::rename::BatchRename::new(Some("a"), false, &long, None, None).is_err());
|
||||
assert!(crate::rename::BatchRename::new(None, false, "x", Some(&long), None).is_err_and(|e| e.contains("pattern")));
|
||||
assert!(crate::rename::BatchRename::new(Some("a"), false, &"$nn".repeat(300), None, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -153,6 +153,30 @@ fn read_only_plugins_and_allowlists_are_enforced() {
|
||||
assert_eq!(node(&s, a).name, "Rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_is_held_to_the_permissions_of_the_command_it_shows() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0);
|
||||
// Only text.preview is allowed: the node.set it would run is not.
|
||||
install(
|
||||
&mut s,
|
||||
manifest("org.test.previewer", "write", &["text.preview"]),
|
||||
&script(&[("text.preview", json!({"command": "node.set", "params": {"id": a, "props": {"name": "x"}}}))], OnRefusal::Fail, ""),
|
||||
);
|
||||
let e = run_err(&mut s, "org.test.previewer");
|
||||
assert!(e.contains("`node.set` isn't in the commands"), "{e}");
|
||||
assert_eq!(node(&s, a).name, "Rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_preview_is_refused_by_the_preview_itself() {
|
||||
let mut s = Session::new();
|
||||
let nested = json!({"command": "text.preview", "params": {"command": "node.set", "params": {"props": {"name": "x"}}}});
|
||||
install(&mut s, manifest("org.test.nested", "write", &["text.preview"]), &script(&[("text.preview", nested)], OnRefusal::Fail, ""));
|
||||
let e = run_err(&mut s, "org.test.nested");
|
||||
assert!(e.contains("can't be previewed"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_dialogs_history_and_plugins_are_off_limits() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -9,6 +9,9 @@ use figcraft_doc::{Document, NodeId};
|
||||
|
||||
/// Longest match pattern accepted (characters).
|
||||
const MAX_FIND: usize = 1_000;
|
||||
/// Longest replacement or pattern template accepted (characters): each is expanded again for
|
||||
/// every match, so its length multiplies the work of a rename.
|
||||
const MAX_TEMPLATE: usize = 1_000;
|
||||
/// Longest name a rename produces (characters).
|
||||
const MAX_NAME: usize = 10_000;
|
||||
/// Widest zero padding of a number code.
|
||||
@@ -50,6 +53,12 @@ impl BatchRename {
|
||||
}
|
||||
Some(f) => Some(Find::Text(f.to_string())),
|
||||
};
|
||||
if replace.chars().count() > MAX_TEMPLATE {
|
||||
return Err(format!("the replacement is longer than {MAX_TEMPLATE} characters"));
|
||||
}
|
||||
if pattern.is_some_and(|p| p.chars().count() > MAX_TEMPLATE) {
|
||||
return Err(format!("the pattern is longer than {MAX_TEMPLATE} characters"));
|
||||
}
|
||||
let start = start.filter(|s| s.is_finite()).map_or(1, |s| s.clamp(0.0, 1e12).round() as u64);
|
||||
Ok(Self { find, replace: replace.to_string(), pattern: pattern.map(str::to_string), start })
|
||||
}
|
||||
|
||||
@@ -217,6 +217,15 @@ fn remove_mode_and_escape() {
|
||||
assert!(s.execute("shapeBuilder.mode", &json!({"mode": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_named_many_times_is_walked_once() {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = squares(&mut s);
|
||||
let g = id(&ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
let r = ex(&mut s, "shapeBuilder.regions", json!({"ids": vec![g; 500]}));
|
||||
assert_eq!(r["sources"], json!([a, b]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_rotated_and_ineligible_layers() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -371,6 +371,72 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
|
||||
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_and_pretty_wrap_styles_break_lines_as_documented() {
|
||||
// Explore text properties: "Auto: Figma fits as many words as possible on each line before
|
||||
// wrapping to the next." "Balance: Figma distributes text as evenly as possible across every
|
||||
// line, so each line is close to the same length." "Pretty: Figma keeps each line full, but
|
||||
// adjusts the last four lines to avoid an orphan -- a single word stranded alone on the last
|
||||
// line." The synthetic font: 8 px per character at 16 px.
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "aaa bbb ccc ddd eee", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 136}})).unwrap();
|
||||
let lines = |s: &Session| -> Vec<(usize, usize)> {
|
||||
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
|
||||
l.lines.iter().map(|l| (l.char_start, l.char_end)).collect()
|
||||
};
|
||||
// 136 px holds 17 characters: Auto fills the first line with four words and strands the fifth.
|
||||
assert_eq!(lines(&s), [(0, 16), (16, 19)]);
|
||||
// Balance: as even as the words allow, three and two.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 12), (12, 19)]);
|
||||
assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["wrapStyle"], json!("balance"));
|
||||
// Pretty: the stranded word gets company from the line before.
|
||||
s.execute("text.wrapStyle", &json!({"ids": [t], "style": "pretty"})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 12), (12, 19)]);
|
||||
// Three lines of two-letter words in a 72 px box (nine characters): Auto strands "gg"; Pretty
|
||||
// narrows the line before it alone ("aa bb cc" / "dd ee" / "ff gg"); Balance's narrowest
|
||||
// width that still gives three lines (64 px) fills the first two lines as Auto does.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "aa bb cc dd ee ff gg", "width": 72, "wrapStyle": "auto"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 18), (18, 20)]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 15), (15, 20)]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 18), (18, 20)]);
|
||||
// A last line of two words is no orphan: Pretty leaves Auto's lines; a text on one line is
|
||||
// left alone whatever the style.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "aa bb cc dd ee ff gg hh", "wrapStyle": "pretty"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 18), (18, 23)]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"width": 400, "wrapStyle": "balance"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 23)]);
|
||||
// Dev Mode's CSS says `text-wrap` for Balance and Pretty and nothing for Auto; junk is refused.
|
||||
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string();
|
||||
assert!(css(&mut s).contains("text-wrap: balance;"), "{}", css(&mut s));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty"}})).unwrap();
|
||||
assert!(css(&mut s).contains("text-wrap: pretty;"));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "auto"}})).unwrap();
|
||||
assert!(!css(&mut s).contains("text-wrap"));
|
||||
assert!(s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "zigzag"}})).is_err());
|
||||
assert!(s.execute("text.wrapStyle", &json!({"ids": [t], "style": "even"})).is_err());
|
||||
// A word as wide as the box is never broken in two by Balance or Pretty: 80 px holds the ten
|
||||
// letters, and every narrower width Balance could try would have to split them.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "abcdefghij klm", "width": 80, "wrapStyle": "balance"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 11), (11, 14)]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 11), (11, 14)]);
|
||||
// Hanging punctuation: a line starting with an opening mark has that much more room, and
|
||||
// Pretty narrows it by as much, so the line before the orphan still gives a word away.
|
||||
s.execute(
|
||||
"node.set",
|
||||
&json!({"id": t, "props": {"characters": "aa bb cc (dd ee ff gg", "width": 72, "hangingPunctuation": true, "wrapStyle": "auto"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 19), (19, 21)]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 16), (16, 21)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() {
|
||||
// "Max lines is available only if truncate text is enabled, and: Text resizing is set to
|
||||
|
||||
@@ -72,6 +72,9 @@ use kiwi::{Kv, Schema};
|
||||
/// Largest `canvas.fig` member read, and largest decompressed chunk.
|
||||
const MAX_CANVAS: u64 = 256 * 1024 * 1024;
|
||||
const MAX_CHUNK: u64 = 512 * 1024 * 1024;
|
||||
/// Largest decompressed schema chunk: Figma's schema unpacks to well under 1 MB, so a schema that
|
||||
/// inflates past this is refused before its definitions are decoded.
|
||||
const MAX_SCHEMA: u64 = 16 * 1024 * 1024;
|
||||
/// Largest image member read.
|
||||
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
|
||||
/// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on
|
||||
@@ -324,7 +327,7 @@ fn decode_canvas(canvas: &[u8]) -> Result<(Schema, Vec<u8>), FormatError> {
|
||||
let (Some(schema), Some(message)) = (it.next(), it.next()) else {
|
||||
return Err(corrupt("canvas.fig needs a schema and a message chunk"));
|
||||
};
|
||||
let schema = Schema::decode(&inflate(schema)?).map_err(|e| corrupt(e.to_string()))?;
|
||||
let schema = Schema::decode(&inflate_within(schema, MAX_SCHEMA)?).map_err(|e| corrupt(e.to_string()))?;
|
||||
Ok((schema, inflate(message)?))
|
||||
}
|
||||
|
||||
@@ -347,18 +350,23 @@ fn chunks(canvas: &[u8]) -> Result<Vec<&[u8]>, FormatError> {
|
||||
|
||||
/// Decompress a chunk: zstd (by its magic number), else raw deflate, else zlib.
|
||||
fn inflate(data: &[u8]) -> Result<Vec<u8>, FormatError> {
|
||||
fn capped(r: impl Read) -> Option<Vec<u8>> {
|
||||
inflate_within(data, MAX_CHUNK)
|
||||
}
|
||||
|
||||
/// [`inflate`] refusing a chunk that unpacks to more than `max` bytes.
|
||||
fn inflate_within(data: &[u8], max: u64) -> Result<Vec<u8>, FormatError> {
|
||||
let capped = |r: &mut dyn Read| -> Option<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
r.take(MAX_CHUNK + 1).read_to_end(&mut out).ok()?;
|
||||
(out.len() as u64 <= MAX_CHUNK).then_some(out)
|
||||
}
|
||||
r.take(max.saturating_add(1)).read_to_end(&mut out).ok()?;
|
||||
(out.len() as u64 <= max).then_some(out)
|
||||
};
|
||||
if data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) {
|
||||
let mut src = data;
|
||||
let dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?;
|
||||
return capped(dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk"));
|
||||
let mut dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?;
|
||||
return capped(&mut dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk"));
|
||||
}
|
||||
capped(flate2::read::DeflateDecoder::new(data))
|
||||
.or_else(|| capped(flate2::read::ZlibDecoder::new(data)))
|
||||
capped(&mut flate2::read::DeflateDecoder::new(data))
|
||||
.or_else(|| capped(&mut flate2::read::ZlibDecoder::new(data)))
|
||||
.ok_or_else(|| corrupt("damaged or oversized deflate chunk"))
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,10 @@ impl<'d> Exporter<'d> {
|
||||
NodeKind::Group => f.push(("resizeToFit", b(true))),
|
||||
NodeKind::Section { contents_hidden } => f.push(("sectionContentsHidden", b(*contents_hidden))),
|
||||
NodeKind::Ellipse { start, sweep, inner } => {
|
||||
f.push(("arcData", o(vec![("startingAngle", fl(*start)), ("endingAngle", fl(start + sweep)), ("innerRadius", fl(*inner))])))
|
||||
// Within a turn, where a 32-bit angle still holds the start and the sweep exactly
|
||||
// enough; a start past a turn draws the same.
|
||||
let start = crate::rest::start_within_a_turn(*start);
|
||||
f.push(("arcData", o(vec![("startingAngle", fl(start)), ("endingAngle", fl(start + sweep)), ("innerRadius", fl(*inner))])))
|
||||
}
|
||||
NodeKind::Polygon { count } => f.push(("count", u(*count))),
|
||||
NodeKind::Star { count, inner_ratio } => f.extend([("count", u(*count)), ("starInnerScale", fl(*inner_ratio))]),
|
||||
|
||||
@@ -234,7 +234,7 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
EffectKind::Glass(g) => {
|
||||
self.warn("glass effects keep only their frost");
|
||||
f.extend([("type", e("GLASS")), ("radius", fl(g.frost))]);
|
||||
f.extend([("type", e("GLASS")), ("radius", fl(g.sanitized().frost))]);
|
||||
}
|
||||
}
|
||||
o(f)
|
||||
|
||||
@@ -1243,6 +1243,25 @@ fn local_samples_round_trip() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glass_frost_is_written_as_it_draws() {
|
||||
// A frost saved before the 0–100 range (or not a number) is written at what it draws: 100, or
|
||||
// the default.
|
||||
for (frost, written) in [(12.0, 12.0), (500.0, Glass::MAX_FROST), (f64::NAN, Glass::default().frost)] {
|
||||
let mut doc = Document::new("glass");
|
||||
let page = doc.first_page().unwrap();
|
||||
let r = doc.create(page, NodeKind::Rectangle, "Pane", 0.0, 0.0, 24.0, 24.0).unwrap();
|
||||
doc.get_mut(r).unwrap().effects = vec![Effect { kind: EffectKind::Glass(Glass { frost, ..Glass::default() }), visible: true }];
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
let archive = Archive::open(&bytes).unwrap();
|
||||
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
|
||||
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||||
let pane = msg.get("nodeChanges").unwrap().list().iter().find(|nc| nc.get("name").and_then(Kv::str) == Some("Pane")).unwrap();
|
||||
let radius = pane.get("effects").unwrap().list()[0].get("radius").and_then(Kv::f64).unwrap();
|
||||
assert_eq!(radius, written, "frost {frost}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vector_drawable_export_setting_is_written_as_svg_with_a_warning() {
|
||||
let mut doc = Document::new("vd");
|
||||
@@ -1611,3 +1630,27 @@ fn a_whole_ellipse_starting_anywhere_comes_back_whole() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An ellipse whose start has gone round many times (a start typed in degrees past a few hundred
|
||||
/// turns) comes back drawn the same: the same sweep, and a start the same way round.
|
||||
#[test]
|
||||
fn an_ellipse_starting_many_turns_round_comes_back_the_same() {
|
||||
let tau = std::f64::consts::TAU;
|
||||
// Not all of them hold exactly as 32-bit floats, the way a start typed in degrees doesn't.
|
||||
for start in [999.0, 1500.0, -5000.0, 1e6, 1e6 + 0.1, 1234.567] {
|
||||
for sweep in [tau, -tau, 1.0, -2.5, 1.3, tau - 0.1] {
|
||||
let mut doc = Document::new("turns");
|
||||
let page = doc.first_page().unwrap();
|
||||
doc.create(page, NodeKind::Ellipse { start, sweep, inner: 0.0 }, "Ellipse", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
let (back, _) = import(&bytes, "x").unwrap();
|
||||
let kinds: Vec<NodeKind> =
|
||||
back.ids().filter_map(|id| back.node(id)).map(|n| n.kind.clone()).filter(|k| matches!(k, NodeKind::Ellipse { .. })).collect();
|
||||
let Some(NodeKind::Ellipse { start: s, sweep: w, .. }) = kinds.first() else { panic!("start {start}: no ellipse") };
|
||||
// Where the arc starts, the same way round (as close as a 32-bit angle holds it).
|
||||
let off = (s - start).rem_euclid(tau);
|
||||
assert!(off.min(tau - off) < 1e-5, "start {start}, sweep {sweep}: came back starting at {s}");
|
||||
assert!((w - sweep).abs() < 1e-5, "start {start}, sweep {sweep}: came back sweeping {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! Everything here reads untrusted bytes: reads are bounds-checked, nesting is capped, array
|
||||
//! counts can't exceed the bytes left, and the total number of decoded values is capped.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
/// Deepest struct/message nesting decoded.
|
||||
@@ -32,6 +33,9 @@ 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;
|
||||
/// Most fields in a schema, every definition together: Figma's own schema has a few thousand, and
|
||||
/// without a total a schema of [`MAX_DEFS`] wide definitions could hold 100 million field records.
|
||||
const MAX_SCHEMA_FIELDS: usize = 200_000;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct KiwiError(pub String);
|
||||
@@ -252,6 +256,7 @@ impl Schema {
|
||||
}
|
||||
let mut defs = Vec::with_capacity(count);
|
||||
let mut raw_types = Vec::new();
|
||||
let mut total_fields = 0usize;
|
||||
for _ in 0..count {
|
||||
let name = r.string()?;
|
||||
let kind = match r.byte()? {
|
||||
@@ -261,7 +266,8 @@ impl Schema {
|
||||
k => return err(format!("schema: unknown definition kind {k}")),
|
||||
};
|
||||
let n = r.var_uint()? as usize;
|
||||
if n > MAX_FIELDS || n > r.left() {
|
||||
total_fields = total_fields.saturating_add(n);
|
||||
if n > MAX_FIELDS || n > r.left() || total_fields > MAX_SCHEMA_FIELDS {
|
||||
return err("schema: too many fields");
|
||||
}
|
||||
let mut fields = Vec::with_capacity(n);
|
||||
@@ -308,7 +314,7 @@ impl Schema {
|
||||
|
||||
/// [`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 };
|
||||
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget, ids: vec![None; self.defs.len()] };
|
||||
d.def(root, 0)
|
||||
}
|
||||
}
|
||||
@@ -322,6 +328,10 @@ struct Decoder<'s, 'b> {
|
||||
schema: &'s Schema,
|
||||
r: Reader<'b>,
|
||||
budget: usize,
|
||||
/// Per definition, the index of the first field with each id (message field ids, enum
|
||||
/// values), built the first time the definition is decoded: a message can carry many tags
|
||||
/// against a wide definition, and a scan per tag would multiply the two.
|
||||
ids: Vec<Option<HashMap<u32, usize>>>,
|
||||
}
|
||||
|
||||
impl<'s> Decoder<'s, '_> {
|
||||
@@ -330,6 +340,19 @@ impl<'s> Decoder<'s, '_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The first field of definition `i` with id (or enum value) `id`.
|
||||
fn field_by_id(&mut self, i: usize, id: u32) -> Option<&'s Field> {
|
||||
let fields = &self.schema.defs.get(i)?.fields;
|
||||
let ids = self.ids.get_mut(i)?.get_or_insert_with(|| {
|
||||
let mut m = HashMap::with_capacity(fields.len());
|
||||
for (j, f) in fields.iter().enumerate() {
|
||||
m.entry(f.value).or_insert(j);
|
||||
}
|
||||
m
|
||||
});
|
||||
fields.get(*ids.get(&id)?)
|
||||
}
|
||||
|
||||
fn def(&mut self, i: usize, depth: usize) -> Result<Kv<'s>, KiwiError> {
|
||||
if depth > MAX_DEPTH {
|
||||
return err("message: nested too deeply");
|
||||
@@ -340,7 +363,7 @@ impl<'s> Decoder<'s, '_> {
|
||||
match d.kind {
|
||||
Kind::Enum => {
|
||||
let v = self.r.var_uint()?;
|
||||
Ok(d.fields.iter().find(|f| f.value == v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str())))
|
||||
Ok(self.field_by_id(i, v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str())))
|
||||
}
|
||||
Kind::Struct => {
|
||||
let mut out = Vec::with_capacity(d.fields.len());
|
||||
@@ -358,7 +381,7 @@ impl<'s> Decoder<'s, '_> {
|
||||
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)))?;
|
||||
let f = self.field_by_id(i, id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?;
|
||||
out.push((f.name.as_str(), self.field(f, depth)?));
|
||||
}
|
||||
}
|
||||
@@ -379,8 +402,9 @@ impl<'s> Decoder<'s, '_> {
|
||||
if n > self.r.left() {
|
||||
return err("message: array longer than the data");
|
||||
}
|
||||
// The field itself counts, so empty arrays can't pile up for free.
|
||||
self.spend(1)?;
|
||||
if ty == Type::Byte {
|
||||
self.spend(1)?;
|
||||
return Ok(Kv::Bytes(self.r.take(n)?.to_vec()));
|
||||
}
|
||||
self.spend(n)?;
|
||||
|
||||
@@ -1179,6 +1179,60 @@ fn scalar_fields_count_against_the_value_budget() {
|
||||
assert!(sc.decode_message_within(0, &data, 1001).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_arrays_count_against_the_value_budget() {
|
||||
// 1000 empty `uint[]` fields: each field is a value even with no elements, so a small budget
|
||||
// can't be outgrown by piling up empty containers.
|
||||
let fields = vec![Field { name: "a".into(), ty: Some(Type::Uint), array: true, value: 1 }];
|
||||
let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] };
|
||||
let mut data = [1u8, 0].repeat(1000);
|
||||
data.push(0);
|
||||
let e = sc.decode_message_within(0, &data, 10).unwrap_err();
|
||||
assert_eq!(e.0, "message: too many values");
|
||||
assert!(sc.decode_message_within(0, &data, 1001).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_tags_find_their_field_by_id_in_a_wide_definition() {
|
||||
// A wide definition: every tag is looked up by id, and the first field with an id wins, as
|
||||
// before the lookup was indexed.
|
||||
let mut fields: Vec<Field> = (1..=5000).map(|i| Field { name: format!("f{i}"), ty: Some(Type::Bool), array: false, value: i }).collect();
|
||||
fields.push(Field { name: "shadow".into(), ty: Some(Type::Bool), array: false, value: 7 });
|
||||
let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] };
|
||||
let mut w = encode::Writer::default();
|
||||
for _ in 0..20_000 {
|
||||
w.var_uint(5000);
|
||||
w.byte(1);
|
||||
}
|
||||
w.var_uint(7);
|
||||
w.byte(0);
|
||||
w.var_uint(0);
|
||||
let Kv::Object(out) = sc.decode_message(0, &w.0).unwrap() else { panic!() };
|
||||
assert_eq!(out.len(), 20_001);
|
||||
assert!(out.iter().take(20_000).all(|(n, v)| *n == "f5000" && *v == Kv::Bool(true)));
|
||||
assert_eq!(out.last().map(|(n, _)| *n), Some("f7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schema_is_refused_past_its_total_field_count() {
|
||||
// 25 definitions of 10 000 fields each: every one is within the per-definition cap, but
|
||||
// together they pass the schema's total.
|
||||
let mut w = encode::Writer::default();
|
||||
w.var_uint(25);
|
||||
for d in 0..25 {
|
||||
w.string(&format!("D{d}"));
|
||||
w.byte(0);
|
||||
w.var_uint(10_000);
|
||||
for i in 0..10_000u32 {
|
||||
w.string("");
|
||||
w.var_int(0);
|
||||
w.byte(0);
|
||||
w.var_uint(i);
|
||||
}
|
||||
}
|
||||
assert_eq!(Schema::decode(&w.0).unwrap_err().0, "schema: too many fields");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_refused() {
|
||||
// A message that contains itself, nested past the depth cap.
|
||||
|
||||
@@ -1028,6 +1028,11 @@ pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Resul
|
||||
let tmp = std::path::PathBuf::from(name);
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(mut file) => {
|
||||
// The replacement keeps the destination's permissions from the start, so a private
|
||||
// file's content is never readable under default ones (the open handle still writes).
|
||||
if let Ok(old) = std::fs::metadata(path) {
|
||||
let _ = file.set_permissions(old.permissions());
|
||||
}
|
||||
if let Err(e) = file.write_all(bytes) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
|
||||
+78
-14
@@ -2030,27 +2030,51 @@ fn token_theme_groups<'a>(value: &'a Value, tokens: &BTreeMap<&str, BTreeMap<Str
|
||||
}
|
||||
|
||||
/// Drops the resolved tokens that alias a variable which isn't being made, until none does; how
|
||||
/// many were dropped.
|
||||
/// many were dropped. Each token is looked at once to start with, and again only when a variable
|
||||
/// it aliases goes: a chain of aliases dropped one link per round would otherwise rescan every
|
||||
/// token per link.
|
||||
fn drop_dangling_aliases(
|
||||
resolved: &mut BTreeMap<&str, (usize, VariableType, BTreeMap<String, VariableValue>)>,
|
||||
var_id: &dyn Fn(&str) -> String,
|
||||
) -> usize {
|
||||
let mut dropped = 0;
|
||||
loop {
|
||||
let ids: BTreeSet<String> = resolved.keys().map(|p| var_id(p)).collect();
|
||||
let dangling: Vec<&str> = resolved
|
||||
.iter()
|
||||
.filter(|(_, (_, _, values))| values.values().any(|v| matches!(v, VariableValue::Alias(target) if !ids.contains(target))))
|
||||
.map(|(path, _)| *path)
|
||||
.collect();
|
||||
if dangling.is_empty() {
|
||||
return dropped;
|
||||
// How many tokens make each variable id, and the tokens aliasing each id.
|
||||
let mut makers: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for path in resolved.keys() {
|
||||
*makers.entry(var_id(path)).or_default() += 1;
|
||||
}
|
||||
let mut aliased_by: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
let mut gone: Vec<&str> = Vec::new();
|
||||
for (path, (_, _, values)) in resolved.iter() {
|
||||
let mut dangling = false;
|
||||
for v in values.values() {
|
||||
if let VariableValue::Alias(target) = v {
|
||||
dangling |= !makers.contains_key(target);
|
||||
aliased_by.entry(target.as_str()).or_default().push(*path);
|
||||
}
|
||||
}
|
||||
dropped += dangling.len();
|
||||
for path in dangling {
|
||||
resolved.remove(path);
|
||||
if dangling {
|
||||
gone.push(*path);
|
||||
}
|
||||
}
|
||||
let mut dropped: BTreeSet<&str> = gone.iter().copied().collect();
|
||||
while let Some(path) = gone.pop() {
|
||||
let id = var_id(path);
|
||||
let Some(left) = makers.get_mut(&id) else { continue };
|
||||
*left -= 1;
|
||||
if *left > 0 {
|
||||
continue;
|
||||
}
|
||||
makers.remove(&id);
|
||||
for &by in aliased_by.get(id.as_str()).into_iter().flatten() {
|
||||
if dropped.insert(by) {
|
||||
gone.push(by);
|
||||
}
|
||||
}
|
||||
}
|
||||
for path in &dropped {
|
||||
resolved.remove(path);
|
||||
}
|
||||
dropped.len()
|
||||
}
|
||||
|
||||
/// Moves a base token whose expression refers to a themed token into that token's collection, so
|
||||
@@ -6513,3 +6537,43 @@ mod tests {
|
||||
assert_eq!(button.interactions.len(), 1, "the prototype link survives the round trip");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dangling_alias_tests {
|
||||
use super::*;
|
||||
|
||||
type Resolved<'a> = BTreeMap<&'a str, (usize, VariableType, BTreeMap<String, VariableValue>)>;
|
||||
|
||||
fn token(alias: Option<&str>) -> (usize, VariableType, BTreeMap<String, VariableValue>) {
|
||||
let value = alias.map_or(VariableValue::Number(1.0), |a| VariableValue::Alias(format!("v:{a}")));
|
||||
(0, VariableType::Number, [("m".to_string(), value)].into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_ending_in_a_missing_variable_is_dropped_whole() {
|
||||
// t0 → t1 → … → t1999 → missing: every link goes, in one pass along the chain.
|
||||
let names: Vec<String> = (0..2000).map(|i| format!("t{i}")).collect();
|
||||
let mut resolved: Resolved = BTreeMap::new();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
let next = names.get(i + 1).map_or("missing", String::as_str);
|
||||
resolved.insert(n, token(Some(next)));
|
||||
}
|
||||
resolved.insert("plain", token(None));
|
||||
resolved.insert("to-plain", token(Some("plain")));
|
||||
let var_id = |p: &str| format!("v:{p}");
|
||||
assert_eq!(drop_dangling_aliases(&mut resolved, &var_id), 2000);
|
||||
assert_eq!(resolved.keys().copied().collect::<Vec<_>>(), ["plain", "to-plain"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variable_made_by_two_tokens_stays_while_one_does() {
|
||||
// `a` and `b` both make `v:x`; `a` dangles, `b` doesn't, so `c` (aliasing `v:x`) stays.
|
||||
let mut resolved: Resolved = BTreeMap::new();
|
||||
resolved.insert("a", token(Some("missing")));
|
||||
resolved.insert("b", token(None));
|
||||
resolved.insert("c", token(Some("x")));
|
||||
let var_id = |p: &str| if p == "a" || p == "b" { "v:x".to_string() } else { format!("v:{p}") };
|
||||
assert_eq!(drop_dangling_aliases(&mut resolved, &var_id), 1);
|
||||
assert_eq!(resolved.keys().copied().collect::<Vec<_>>(), ["b", "c"]);
|
||||
}
|
||||
}
|
||||
@@ -626,15 +626,27 @@ impl<'a> Ctx<'a> {
|
||||
"ELLIPSE" => {
|
||||
let a = j.get("arcData");
|
||||
let arc = |k: &str| a.and_then(|a| num(a, k));
|
||||
let start = arc("startingAngle").unwrap_or(0.0).clamp(-1e3, 1e3);
|
||||
let end = arc("endingAngle").map_or(start + std::f64::consts::TAU, |e| e.clamp(-1e3, 1e3));
|
||||
let tau = std::f64::consts::TAU;
|
||||
// Capped only so the difference stays finite: the sweep is the difference of the
|
||||
// angles as written, wherever they lie.
|
||||
let start = arc("startingAngle").unwrap_or(0.0).clamp(-1e15, 1e15);
|
||||
// No end goes all the way round, however far out the start is.
|
||||
let end = arc("endingAngle").map(|e| e.clamp(-1e15, 1e15));
|
||||
// The angles are 32-bit floats: a whole turn from anywhere but zero comes out a
|
||||
// hair short of 2π as often as not. Within their precision of a turn, it is one.
|
||||
let precision = (start.abs() + end.abs() + tau) * f64::from(f32::EPSILON);
|
||||
let sweep = end - start;
|
||||
let sweep = if sweep.abs() >= tau - precision { tau.copysign(sweep) } else { sweep };
|
||||
NodeKind::Ellipse { start, sweep: sweep.clamp(-tau, tau), inner: arc("innerRadius").unwrap_or(0.0).clamp(0.0, 1.0) }
|
||||
// Far enough out (thousands of radians) that precision is too coarse to tell a
|
||||
// whole turn from a nearly whole arc, the sweep is kept as written.
|
||||
let sweep = end.map_or(tau, |end| {
|
||||
let precision = (start.abs() + end.abs() + tau) * f64::from(f32::EPSILON);
|
||||
let sweep = end - start;
|
||||
let whole = precision <= WHOLE_TURN_SLACK && sweep.abs() >= tau - precision;
|
||||
if whole { tau.copysign(sweep) } else { sweep }
|
||||
});
|
||||
NodeKind::Ellipse {
|
||||
start: start_within_a_turn(start),
|
||||
sweep: sweep.clamp(-tau, tau),
|
||||
inner: arc("innerRadius").unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
// Point counts aren't part of the documented JSON: the outline is exact when present.
|
||||
"REGULAR_POLYGON" | "STAR" if point_count().is_none() && has_geometry => NodeKind::Vector { network: VectorNetwork::default() },
|
||||
@@ -1515,3 +1527,22 @@ pub fn missing_image_placeholder() -> Option<&'static [u8]> {
|
||||
pub fn is_missing_image(data: &ImageData) -> bool {
|
||||
missing_image_placeholder().is_some_and(|png| data.bytes.as_slice() == png)
|
||||
}
|
||||
|
||||
/// The most a sweep may fall short of a turn and still be read as a whole one (radians, about
|
||||
/// 0.06°): far more than 32-bit angles round by within a few hundred turns of zero, and far less
|
||||
/// than any arc anyone draws.
|
||||
const WHOLE_TURN_SLACK: f64 = 1e-3;
|
||||
|
||||
/// An ellipse's start brought within a turn, drawn the same: a start already within one is kept
|
||||
/// as it is, one past a turn comes to [0, 2π). It is reduced the way the renderer's sine and
|
||||
/// cosine see it, exactly, rather than by dividing by a rounded 2π, which drifts a little with
|
||||
/// every turn.
|
||||
pub(crate) fn start_within_a_turn(start: f64) -> f64 {
|
||||
let tau = std::f64::consts::TAU;
|
||||
if !start.is_finite() || start.abs() < tau {
|
||||
return start;
|
||||
}
|
||||
// `rem_euclid` rounds a hair below zero up to 2π itself.
|
||||
let within = start.sin().atan2(start.cos()).rem_euclid(tau);
|
||||
if within < tau { within } else { 0.0 }
|
||||
}
|
||||
@@ -756,3 +756,44 @@ fn the_fig_import_sets_its_own_layer_cap() {
|
||||
assert_eq!(doc.node_count(), 5);
|
||||
assert!(warnings.iter().any(|w| w.contains("more than 4 layers")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// Angles written many turns round keep the arc they draw: the sweep is the difference as
|
||||
/// written, and the start comes within a turn of zero, the same way round.
|
||||
#[test]
|
||||
fn an_arc_written_many_turns_round_keeps_its_sweep() {
|
||||
let tau = std::f64::consts::TAU;
|
||||
for (start, end) in [
|
||||
(999.0, 999.0 + tau),
|
||||
(1500.0, 1501.0),
|
||||
(-5000.0, -5000.0 - 2.5),
|
||||
(1e6, 1e6 + tau),
|
||||
(1e6, 1e6 + 5.9),
|
||||
(1e6, 1e6 + 6.1),
|
||||
(3e7, 3e7),
|
||||
(-4e5, -4e5 + tau - 0.01),
|
||||
(-182.212373908208, -182.212373908208 + tau),
|
||||
] {
|
||||
let mut arc = json!({"id": "Arc", "name": "Arc", "type": "ELLIPSE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}});
|
||||
arc["arcData"] = json!({"startingAngle": start, "endingAngle": end, "innerRadius": 0});
|
||||
let (doc, _) = one(json!([arc]));
|
||||
let NodeKind::Ellipse { start: s, sweep, .. } = named(&doc, "Arc").kind else { panic!("{start}: not an ellipse") };
|
||||
let want = end - start;
|
||||
let want = if (want.abs() - tau).abs() < 1e-6 { tau.copysign(want) } else { want };
|
||||
assert!((sweep - want).abs() < 1e-9, "{start}..{end}: sweep {sweep}, not {want}");
|
||||
assert!(s.abs() < tau, "{start}: start {s} left past a turn");
|
||||
let off = (s - start).rem_euclid(tau);
|
||||
assert!(off.min(tau - off) < 1e-6, "{start}: start {s} isn't the same way round");
|
||||
}
|
||||
}
|
||||
|
||||
/// An arc with a start but no end goes all the way round, however far out the start is.
|
||||
#[test]
|
||||
fn an_arc_without_an_end_is_whole_wherever_it_starts() {
|
||||
for start in [0.0, 2.0, -5000.0, 1e8, 1e15] {
|
||||
let mut arc = json!({"id": "Arc", "name": "Arc", "type": "ELLIPSE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}});
|
||||
arc["arcData"] = json!({"startingAngle": start, "innerRadius": 0});
|
||||
let (doc, _) = one(json!([arc]));
|
||||
let NodeKind::Ellipse { sweep, .. } = named(&doc, "Arc").kind else { panic!("{start}: not an ellipse") };
|
||||
assert_eq!(sweep, std::f64::consts::TAU, "start {start}");
|
||||
}
|
||||
}
|
||||
@@ -226,11 +226,16 @@ impl<'b> Archive<'b> {
|
||||
};
|
||||
let refs = document.get("pages").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
let mut pages = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for r in refs.iter().take(MAX_PAGES) {
|
||||
let Some(id) = r.get("_ref").and_then(Value::as_str).and_then(|s| s.strip_prefix("pages/")).filter(|id| valid_id(id)) else {
|
||||
warnings.push("pages the document lists without a valid reference were skipped".into());
|
||||
continue;
|
||||
};
|
||||
// A page listed twice is read once: a repeated reference must not repeat the unpacking.
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
match json_member(&mut z, &format!("pages/{id}.json"), max_member, &mut total) {
|
||||
Ok(page) if page.is_object() => pages.push((id.to_string(), page)),
|
||||
Ok(_) => warnings.push("pages that aren't JSON objects were skipped".into()),
|
||||
|
||||
@@ -35,6 +35,13 @@ fn pages_become_pages_in_document_order() {
|
||||
assert_eq!(page_names(&doc), ["Beta", "Alpha"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_listed_twice_is_imported_once() {
|
||||
let z = sketch_zip("50.2", 103, document(&["A", "A", "A"]), &[page("A", "Alpha", vec![])], &[]);
|
||||
let (doc, _) = import(&z, "f").unwrap();
|
||||
assert_eq!(page_names(&doc), ["Alpha"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_than_48_is_refused() {
|
||||
let z = sketch_zip("47.1", 95, document(&["P"]), &[page("P", "P", vec![])], &[]);
|
||||
|
||||
@@ -123,3 +123,17 @@ fn a_failed_write_leaves_the_destination_alone_and_cleans_up() {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_replaced_file_keeps_its_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let d = dir("mode");
|
||||
let path = d.join("a.figcraft");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
write_file_atomic(&path, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -629,14 +629,26 @@ fn distribute(items: &mut [Item], mut open: Vec<usize>, free: f64) {
|
||||
.collect();
|
||||
let violation: f64 = sized.iter().map(|(_, want, v)| v - want).sum();
|
||||
let frozen = |want: f64, v: f64| violation.abs() < 1e-9 || (violation > 0.0 && v > want + 1e-9) || (violation < 0.0 && v < want - 1e-9);
|
||||
let mut progressed = false;
|
||||
for (i, want, v) in &sized {
|
||||
if frozen(*want, *v)
|
||||
&& let Some(it) = items.get_mut(*i)
|
||||
{
|
||||
it.main = *v;
|
||||
free -= *v;
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
if !progressed {
|
||||
// Several clampings each under the tolerance added up past it, so none froze: settle
|
||||
// them all as clamped rather than go round again with nothing changed.
|
||||
for (i, _, v) in &sized {
|
||||
if let Some(it) = items.get_mut(*i) {
|
||||
it.main = *v;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
free = free.max(0.0);
|
||||
open = sized.into_iter().filter(|(_, want, v)| !frozen(*want, *v)).map(|(i, _, _)| i).collect();
|
||||
}
|
||||
|
||||
@@ -447,3 +447,24 @@ fn grid_hostile_values() {
|
||||
assert!(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_children_whose_tiny_clampings_add_up_past_the_tolerance_still_settle() {
|
||||
// Three fill children sharing 300 px, each with a minimum 5e-10 over its 100 px share: no
|
||||
// single clamping passes the 1e-9 tolerance, but together they do, so no child froze and the
|
||||
// distribution loop went round forever without changing anything.
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 300.0, 100.0).unwrap();
|
||||
d.get_mut(f).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Horizontal, gap: 0.0, padding: [0.0; 4], ..Default::default() });
|
||||
let kids: Vec<NodeId> = (0..3).map(|i| d.create(f, NodeKind::Rectangle, "K", 0.0, 0.0, 10.0, 20.0 + i as f64).unwrap()).collect();
|
||||
for k in &kids {
|
||||
let n = d.get_mut(*k).unwrap();
|
||||
n.child_layout.sizing_h = Sizing::Fill;
|
||||
n.child_layout.min_w = Some(100.0 + 5e-10);
|
||||
}
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
for k in &kids {
|
||||
assert!((pos(&d, *k).2 - 100.0).abs() < 1e-6, "{:?}", pos(&d, *k));
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ pub fn get(name: &str, args: &Value) -> Result<Value, String> {
|
||||
if !(args.is_object() || args.is_null()) {
|
||||
return Err("`arguments` must be an object of strings".into());
|
||||
}
|
||||
let mut text = p.template.replace("{verify}", VERIFY);
|
||||
let mut values: Vec<(&str, String)> = vec![("verify", VERIFY.to_string())];
|
||||
for a in p.args {
|
||||
let v = match args.get(a.name) {
|
||||
Some(Value::String(s)) if !s.trim().is_empty() => s.clone(),
|
||||
@@ -142,7 +142,49 @@ pub fn get(name: &str, args: &Value) -> Result<Value, String> {
|
||||
Some(Value::String(_)) => p.defaults.iter().find(|(k, _)| *k == a.name).map(|(_, d)| (*d).to_string()).unwrap_or_default(),
|
||||
Some(_) => return Err(format!("argument `{}` must be a string", a.name)),
|
||||
};
|
||||
text = text.replace(&format!("{{{}}}", a.name), &v);
|
||||
values.push((a.name, v));
|
||||
}
|
||||
// One pass over the template, so an argument's text is never read as `{…}` syntax itself (a
|
||||
// value holding placeholders used to be expanded again by every later replacement).
|
||||
let mut text = String::with_capacity(p.template.len());
|
||||
let mut rest = p.template;
|
||||
while let Some(open) = rest.find('{') {
|
||||
text.push_str(rest.get(..open).unwrap_or_default());
|
||||
let after = rest.get(open..).unwrap_or_default();
|
||||
let found = after.find('}').and_then(|close| {
|
||||
let key = after.get(1..close)?;
|
||||
values.iter().find(|(k, _)| *k == key).map(|(_, v)| (close, v.as_str()))
|
||||
});
|
||||
match found {
|
||||
Some((close, v)) => {
|
||||
text.push_str(v);
|
||||
rest = after.get(close + 1..).unwrap_or_default();
|
||||
}
|
||||
None => {
|
||||
text.push('{');
|
||||
rest = after.get(1..).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
text.push_str(rest);
|
||||
Ok(json!({"description": p.description, "messages": [{"role": "user", "content": {"type": "text", "text": text}}]}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn arguments_are_text_not_template_syntax() {
|
||||
let text = |args: Value| get("mobile_screen", &args).unwrap()["messages"][0]["content"]["text"].as_str().unwrap().to_string();
|
||||
let plain = text(json!({"screen": "login"}));
|
||||
assert!(plain.contains("Design a login screen") && plain.contains("iPhone 16") && plain.ends_with(VERIFY), "{plain}");
|
||||
assert!(!plain.contains("{screen}") && !plain.contains("{verify}"), "{plain}");
|
||||
// A value made of placeholders stays as typed, however many there are: it is never
|
||||
// expanded again by the replacements after it (that multiplied the output without bound).
|
||||
let bomb = "{device}{brand_color}{verify}".repeat(1000);
|
||||
let out = text(json!({"screen": bomb.clone()}));
|
||||
assert!(out.contains(&bomb));
|
||||
assert!(out.len() < 3 * bomb.len(), "{}", out.len());
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,9 @@ pub fn outline(path: &BezPath, width: f64, props: &StrokeProps, brush: &Brush) -
|
||||
let p = preset(&brush.preset)?;
|
||||
let unit = shape(p.id)?;
|
||||
let lines = polylines(path)?;
|
||||
let scale = |t: f64| width * props.profile().map_or(1.0, |pr| pr.scale_at(t));
|
||||
// Sanitized once: at most 64 points, so each sample's symmetry scan stays cheap.
|
||||
let profile = props.profile().map(|pr| pr.sanitized());
|
||||
let scale = |t: f64| width * profile.as_ref().map_or(1.0, |pr| pr.scale_at(t));
|
||||
let mut out = BezPath::new();
|
||||
match p.kind {
|
||||
BrushKind::Stretch => {
|
||||
|
||||
@@ -72,3 +72,14 @@ fn a_width_profile_scales_the_brush() {
|
||||
let (first, last) = (parts.first().unwrap().bounding_box(), parts.last().unwrap().bounding_box());
|
||||
assert!(last.width() > first.width() * 3.0, "{first:?} {last:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsanitized_profile_is_bounded_before_sampling() {
|
||||
// 100k points, all past the 64-point cap and out of range: the brush reads the clamped profile.
|
||||
let raw = WidthProfile { points: (0..100_000).map(|i| figcraft_doc::WidthPoint::new(f64::from(i), 1e300)).collect() };
|
||||
let b = brush("taper");
|
||||
let mut p = props(1.0, &b);
|
||||
p.width_profile = Some(raw);
|
||||
let out = outline(&line(50.0), 1.0, &p, &b).unwrap();
|
||||
assert!(out.bounding_box().height() <= 2.0 * WidthProfile::MAX_SCALE);
|
||||
}
|
||||
+13
-3
@@ -1,7 +1,7 @@
|
||||
//! Text: real, selectable text in embedded (subset) fonts, positioned glyph by glyph from the
|
||||
//! `figcraft_text` layout the renderer uses, with outline fallbacks.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -56,14 +56,24 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
|
||||
let styles: BTreeSet<usize> = layout.glyphs.iter().map(|g| g.style).chain(layout.decorations.iter().map(|d| d.style)).collect();
|
||||
// Every run's fills, then the inner shadows over them, then the strokes on top.
|
||||
let mut stroked = Vec::new();
|
||||
// Glyphs and decorations are grouped by style in one pass; each style's paths are built (and
|
||||
// dropped) inside its own iteration, so only one style's paths are held at a time.
|
||||
let mut decoration_of: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
for (i, d) in layout.decorations.iter().enumerate() {
|
||||
decoration_of.entry(d.style).or_default().push(i);
|
||||
}
|
||||
let mut glyph_of: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
for (i, g) in layout.glyphs.iter().enumerate() {
|
||||
glyph_of.entry(g.style).or_default().push(i);
|
||||
}
|
||||
for k in styles {
|
||||
let fills = layout.styles.get(k).and_then(|st| st.fills.as_ref()).unwrap_or(&n.fills);
|
||||
let mut decorations = BezPath::new();
|
||||
for d in layout.decorations.iter().filter(|d| d.style == k) {
|
||||
for d in decoration_of.remove(&k).unwrap_or_default().into_iter().filter_map(|i| layout.decorations.get(i)) {
|
||||
decorations.extend(d.path());
|
||||
}
|
||||
let mut outlines = BezPath::new();
|
||||
for g in layout.glyphs.iter().filter(|g| g.style == k) {
|
||||
for g in glyph_of.remove(&k).unwrap_or_default().into_iter().filter_map(|i| layout.glyphs.get(i)) {
|
||||
outlines.extend(g.path());
|
||||
}
|
||||
outlines.extend(decorations.iter());
|
||||
|
||||
@@ -1031,6 +1031,11 @@ fn masked_area(ctx: &Ctx, pix: &Pixmap, mask_id: NodeId, content: &[NodeId], xf:
|
||||
/// Mask `m` drawn as if it weren't a mask into a `w` × `h` pixmap at `xf` (a vector mask: its
|
||||
/// outline, fully opaque).
|
||||
fn mask_pixels(ctx: &Ctx, m: &Node, (w, h): (u32, u32), xf: Affine, depth: usize) -> Option<Pixmap> {
|
||||
// A mask's content is drawn here, not through `draw_layer`, so a chain of masks inside
|
||||
// masks needs the nesting limit checked here too, or it runs down the stack.
|
||||
if depth > crate::MAX_EXPORT_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let mut mask_px = Pixmap::new(w, h)?;
|
||||
let mut m2 = m.clone();
|
||||
m2.is_mask = false;
|
||||
|
||||
@@ -78,6 +78,20 @@ fn decoding_is_capped() {
|
||||
assert!(images::decode_frames(&huge).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_partial_frame_plays_when_two_whole_frames_fit() {
|
||||
// Canvas 4000 x 3000 (48 MB a frame, two fit the cap) with a 3500 x 2800 second frame: the
|
||||
// decoder's canvas, composited output and partial frame together need more than the cap.
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = GifEncoder::new(&mut out);
|
||||
enc.set_repeat(Repeat::Infinite).unwrap();
|
||||
let frame = |w, h, c| Frame::from_parts(RgbaImage::from_pixel(w, h, image::Rgba(c)), 0, 0, Delay::from_numer_denom_ms(100, 1));
|
||||
enc.encode_frames([frame(4000, 3000, RED), frame(3500, 2800, GREEN)]).unwrap();
|
||||
}
|
||||
assert_eq!(images::decode_frames(&out).map(|f| f.len()), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_frame_draws_unless_time_moves_on() {
|
||||
let mut d = Document::new("t");
|
||||
@@ -98,3 +112,16 @@ fn the_first_frame_draws_unless_time_moves_on() {
|
||||
d.get_mut(id).unwrap().visible = false;
|
||||
assert!(images::animation_clock(&d, page, 0.15).is_none(), "hidden layers don't keep the presenter awake");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_descriptor_bigger_than_the_canvas_is_refused_before_it_is_allocated() {
|
||||
// A 4 × 4 canvas whose first frame descriptor claims 16,383 × 16,383 (a gigabyte of RGBA):
|
||||
// the decoder's limits refuse the frame buffer instead of allocating it.
|
||||
let mut bytes = gif(&[(RED, 100), (GREEN, 100)], 4, 4);
|
||||
let at = bytes.windows(9).position(|w| w == [0x2c, 0, 0, 0, 0, 4, 0, 4, 0]).expect("an image descriptor for a 4 × 4 frame");
|
||||
let (w, h) = ((16_383u16).to_le_bytes(), (16_383u16).to_le_bytes());
|
||||
bytes[at + 5..at + 9].copy_from_slice(&[w[0], w[1], h[0], h[1]]);
|
||||
let start = std::time::Instant::now();
|
||||
assert!(images::decode_frames(&bytes).is_none());
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
}
|
||||
+175
-17
@@ -1,9 +1,12 @@
|
||||
//! The glass effect: the backdrop seen through a pane whose edges curve down like a lens.
|
||||
//!
|
||||
//! Within `depth` of the outline the surface curves: the backdrop there is bent towards the inside
|
||||
//! (strongest at the very edge, `refraction` × `depth` far), each colour channel by a slightly
|
||||
//! different amount (`dispersion`, a rainbow fringe), and the rim catches the light: brightest
|
||||
//! where it faces `light_angle`, half as bright on the opposite side, spread wider by `splay`.
|
||||
//! Within `depth` of the outline the surface curves like a thick lens: the backdrop there is bent
|
||||
//! towards the inside, most a [`BEVEL`] in from the edge (`refraction` × `depth` far), falling off
|
||||
//! as the 3.5th power of the way out of the curve and gone `depth` further in, with a fifth as
|
||||
//! much on the bevel itself ([`bend_at`]). Each colour channel bends by a different amount
|
||||
//! (`dispersion`, a rainbow fringe: red furthest, blue least, each smeared along the bend), and the
|
||||
//! rim catches the light: brightest where it faces `light_angle`, half as bright on the opposite
|
||||
//! side, spread wider by `splay`.
|
||||
//! `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean
|
||||
//! distance transform of the shape's coverage.
|
||||
//!
|
||||
@@ -162,6 +165,70 @@ fn smooth_normals(dist: &[f32], w: usize, h: usize, r: usize) -> Vec<(f32, f32)>
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The bevel along a glass's edge (document units): where the bend eases off before the edge.
|
||||
pub(crate) const BEVEL: f64 = 1.0;
|
||||
|
||||
/// How sharply the bend falls off going in: the 3.5th power of the way out of the curve.
|
||||
const PROFILE_EXP: f32 = 3.5;
|
||||
|
||||
/// The furthest the backdrop moves, as a share of `depth`.
|
||||
const MAX_BEND: f32 = 0.999;
|
||||
|
||||
/// The bend on the bevel itself, as a share of [`MAX_BEND`] × `depth` × `refraction`.
|
||||
const RIM_BEND: f32 = 0.2;
|
||||
|
||||
/// Steps of the dispersion window: each channel is sampled this many times along the bend, weighted
|
||||
/// by a triangle (the two end steps weigh nothing and are skipped).
|
||||
const DISPERSION_STEPS: usize = 8;
|
||||
|
||||
/// `x` from `a` to `b` as 0 to 1, clamped (a span under 1e-5 counts as 1e-5).
|
||||
fn lin(a: f32, b: f32, x: f32) -> f32 {
|
||||
((x - a) / (b - a).max(1e-5)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// How far the backdrop moves at `x` pixels in from the edge, for a glass `depth` and [`BEVEL`]
|
||||
/// `bevel` deep (pixels) and `refraction` (0–1): a thick lens's profile, the most a bevel in from
|
||||
/// the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further
|
||||
/// in, and a fifth as much on the bevel itself, eased between the two over its outer 70%.
|
||||
pub(crate) fn bend_at(x: f32, depth: f32, bevel: f32, refraction: f32) -> f32 {
|
||||
// Past the curve (and the bevel's easing) nothing bends: the flat inside of the pane.
|
||||
if x >= depth.max(1e-4) + bevel {
|
||||
return 0.0;
|
||||
}
|
||||
let sd = -x;
|
||||
let t = lin(-depth - bevel, -bevel, sd);
|
||||
let most = MAX_BEND * depth;
|
||||
let core = (t.powf(PROFILE_EXP) * depth * refraction).clamp(-most, most);
|
||||
let rim = refraction * RIM_BEND * most;
|
||||
let u = lin(-bevel - 1e-4, -0.3 * bevel, sd);
|
||||
core + (rim - core) * u
|
||||
}
|
||||
|
||||
/// The backdrop seen at pixel coordinates (`x`, `y`) moved by (`dx`, `dy`), its channels split by
|
||||
/// `k` (dispersion, 0–1): red moves up to `k` further, blue up to `k` less, each smeared along the
|
||||
/// move by a triangular window of [`DISPERSION_STEPS`]. One sample when `k` is too small to show.
|
||||
fn dispersed(frosted: &Pixmap, x: f32, y: f32, dx: f32, dy: f32, k: f32) -> [f32; 4] {
|
||||
let at = |s: f32| sample(frosted, x + dx * s, y + dy * s);
|
||||
if k < 0.001 {
|
||||
let s = at(1.0);
|
||||
return [s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]];
|
||||
}
|
||||
let last = (DISPERSION_STEPS - 1) as f32;
|
||||
let (mut acc, mut sum) = ([0.0f32; 4], 0.0f32);
|
||||
for i in 1..DISPERSION_STEPS - 1 {
|
||||
let t = i as f32 / last - 0.5;
|
||||
let w = 1.0 - (2.0 * t).abs();
|
||||
let (r, g, b) = (at(1.0 + (t + 0.5) * k), at(1.0 + t * k), at(1.0 + (t - 0.5) * k));
|
||||
acc[0] += r[0] * w;
|
||||
acc[1] += g[1] * w;
|
||||
acc[2] += b[2] * w;
|
||||
acc[3] += r[3].max(g[3]).max(b[3]) * w;
|
||||
sum += w;
|
||||
}
|
||||
let a = acc[3] / sum;
|
||||
[(acc[0] / sum).min(a), (acc[1] / sum).min(a), (acc[2] / sum).min(a), a]
|
||||
}
|
||||
|
||||
/// Bilinear sample of premultiplied `p` at pixel coordinates `(x, y)` (centres at +0.5), each
|
||||
/// channel 0..=255; transparent outside.
|
||||
fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] {
|
||||
@@ -411,20 +478,20 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
|
||||
let EffectFrame { shape, back, pad, size: (w, h), xf, scale } = frame;
|
||||
let g = g.sanitized();
|
||||
let depth = (g.depth * scale).clamp(1.0, 1024.0);
|
||||
let bevel = (BEVEL * scale).min(1024.0);
|
||||
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0);
|
||||
let reach = light_reach(band, g.light_intensity);
|
||||
let r = (depth * 0.1).clamp(1.0, 8.0) as usize;
|
||||
// Coverage with a margin, so wherever the layer is cut every distance the pixels read is
|
||||
// exact: the bend's (within `depth`), the light's (within `reach`) and those the normals are
|
||||
// smoothed from (2r + 1 further).
|
||||
let m = depth.max(reach).ceil() as u32 + 4 * r as u32 + 4;
|
||||
// exact: the bend's (within `depth` + `bevel`), the light's (within `reach`) and those the
|
||||
// normals are smoothed from (2r + 1 further).
|
||||
let m = (depth + bevel).max(reach).ceil() as u32 + 4 * r as u32 + 4;
|
||||
let (fields, shift) = shape_fields(shape, xf, w, h, m, r)?;
|
||||
let mut frosted = back.clone();
|
||||
fx::blur(&mut frosted, g.frost / 2.0 * scale);
|
||||
let ang = g.light_angle.to_radians();
|
||||
let light = (ang.sin() as f32, -ang.cos() as f32);
|
||||
let bend_max = (g.refraction * depth) as f32;
|
||||
let fringe = (g.dispersion * 0.25) as f32;
|
||||
let (refraction, dispersion, bevel) = (g.refraction as f32, (g.dispersion * Glass::DISPERSION_SPREAD) as f32, bevel as f32);
|
||||
let power = (6.0 - 5.0 * g.splay) as f32;
|
||||
let (band, reach) = (band as f32, reach as f32);
|
||||
// The falloff, lowered by its value at `reach` and scaled back to 1 at the edge, so the light
|
||||
@@ -441,19 +508,15 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
|
||||
if c <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let t = (d / depth).min(1.0);
|
||||
let bend = bend_max * (1.0 - t) * (1.0 - t);
|
||||
let bend = bend_at(d, depth, bevel, refraction);
|
||||
let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5);
|
||||
let base = if bend == 0.0 {
|
||||
// Nothing bends (past `depth`, or no refraction): the three channels' samples are
|
||||
// at the same point, so one sample.
|
||||
// Nothing bends (past `depth` + `bevel`, or no refraction): the channels' samples
|
||||
// are at the same point, so one sample.
|
||||
let s = sample(&frosted, bx, by);
|
||||
[s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]]
|
||||
} else {
|
||||
let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k);
|
||||
let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe));
|
||||
let a = rs[3].max(gs[3]).max(bs[3]);
|
||||
[rs[0].min(a), gs[1].min(a), bs[2].min(a), a]
|
||||
dispersed(&frosted, bx, by, inward.0 * bend, inward.1 * bend, dispersion)
|
||||
};
|
||||
// The rim catches the light where it faces it, and half as much opposite. It fades out
|
||||
// by `reach` and is none past it, wherever the layer is cut.
|
||||
@@ -479,6 +542,101 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The bend at depth 20, refraction 0.8 and a 1-unit bevel, x units in from the edge: the
|
||||
/// reference profile's values.
|
||||
#[test]
|
||||
fn the_bend_follows_the_thick_lens_profile() {
|
||||
let table = [
|
||||
(0.10, 3.1968),
|
||||
(0.25, 3.1968),
|
||||
(0.50, 6.8543),
|
||||
(0.75, 11.4263),
|
||||
(1.00, 15.9982),
|
||||
(2.0, 13.3707),
|
||||
(4.0, 9.0591),
|
||||
(6.0, 5.8457),
|
||||
(8.0, 3.5426),
|
||||
(10.0, 1.9742),
|
||||
(16.0, 0.1250),
|
||||
(20.0, 0.0004),
|
||||
(21.0, 0.0),
|
||||
(40.0, 0.0),
|
||||
];
|
||||
for (x, want) in table {
|
||||
let got = bend_at(x, 20.0, 1.0, 0.8);
|
||||
assert!((got - want).abs() < 1e-3, "x {x}: {got} vs {want}");
|
||||
}
|
||||
// No refraction, no bend; never more than 0.999 × depth, never outwards.
|
||||
assert_eq!(bend_at(0.5, 20.0, 1.0, 0.0), 0.0);
|
||||
for i in 0..400 {
|
||||
let x = i as f32 * 0.1;
|
||||
let b = bend_at(x, 20.0, 1.0, 1.0);
|
||||
assert!((0.0..=0.999 * 20.0).contains(&b), "x {x}: {b}");
|
||||
}
|
||||
// A depth thinner than the bevel (zoomed far in) still gives numbers.
|
||||
assert!(bend_at(0.5, 1.0, 8.0, 1.0).is_finite());
|
||||
// `Glass::max_bend`, which sizes the backdrop read around a pane, bounds the whole bend.
|
||||
for refraction in [0.3, 0.8, 1.0] {
|
||||
let most = (0..800).map(|i| bend_at(i as f32 * 0.05, 20.0, 1.0, refraction as f32)).fold(0.0f32, f32::max);
|
||||
let bound = Glass { refraction, depth: 20.0, dispersion: 0.0, ..Glass::default() }.max_bend();
|
||||
assert!(f64::from(most) <= bound + 1e-4, "refraction {refraction}: {most} past {bound}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispersion off is one sample at the moved point; on, the channels split along the move, red
|
||||
/// further and blue less, and a flat backdrop stays flat.
|
||||
#[test]
|
||||
fn dispersion_splits_the_channels_along_the_bend() {
|
||||
// A 40 × 1 ramp: red, green and blue all rise with x.
|
||||
let mut p = Pixmap::new(40, 1).unwrap();
|
||||
for (i, px) in p.data_mut().chunks_exact_mut(4).enumerate() {
|
||||
let v = (i * 6) as u8;
|
||||
px.copy_from_slice(&[v, v, v, 255]);
|
||||
}
|
||||
let one = sample(&p, 10.5 + 8.0, 0.5);
|
||||
let off = dispersed(&p, 10.5, 0.5, 8.0, 0.0, 0.0);
|
||||
assert_eq!(off, [one[0], one[1], one[2], one[3]]);
|
||||
let on = dispersed(&p, 10.5, 0.5, 8.0, 0.0, 1.0);
|
||||
assert!(on[0] > on[1] && on[1] > on[2], "{on:?}");
|
||||
assert!((on[1] - one[1]).abs() < 1e-3, "green stays centred on the move: {on:?} vs {one:?}");
|
||||
let mut flat = Pixmap::new(40, 1).unwrap();
|
||||
flat.fill(tiny_skia::Color::from_rgba8(90, 120, 200, 255));
|
||||
let f = dispersed(&flat, 10.5, 0.5, 8.0, 0.0, 1.0);
|
||||
assert!(f.iter().zip([90.0, 120.0, 200.0, 255.0]).all(|(a, b)| (a - b).abs() < 1e-3), "{f:?}");
|
||||
// One white pixel on black: only the weighted steps that reach it pick it up, red and green
|
||||
// from opposite ends of their windows (the same amount), blue none.
|
||||
let mut impulse = Pixmap::new(40, 1).unwrap();
|
||||
impulse.fill(tiny_skia::Color::BLACK);
|
||||
impulse.data_mut()[20 * 4..21 * 4].copy_from_slice(&[255, 255, 255, 255]);
|
||||
let spread = dispersed(&impulse, 10.5, 0.5, 8.0, 0.0, 1.0);
|
||||
assert!((spread[0] - 33.392857).abs() < 1e-3 && (spread[1] - 33.392857).abs() < 1e-3, "{spread:?}");
|
||||
assert_eq!(spread[2], 0.0);
|
||||
}
|
||||
|
||||
/// At full dispersion red's furthest step stays within `Glass::max_bend` of the pixel, all
|
||||
/// along the bend, so the backdrop read around a pane covers it: one white pixel further out
|
||||
/// than that is never seen.
|
||||
#[test]
|
||||
fn full_dispersion_reads_no_further_than_max_bend() {
|
||||
let bound = Glass { refraction: 1.0, depth: 20.0, dispersion: 1.0, ..Glass::default() }.max_bend() as f32;
|
||||
let k = Glass::DISPERSION_SPREAD as f32;
|
||||
let mut furthest = 0usize;
|
||||
for i in 0..100 {
|
||||
let bend = bend_at(i as f32 * 0.25, 20.0, 1.0, 1.0);
|
||||
for at in 0..40 {
|
||||
let mut impulse = Pixmap::new(40, 1).unwrap();
|
||||
impulse.fill(tiny_skia::Color::BLACK);
|
||||
impulse.data_mut()[at * 4..at * 4 + 4].copy_from_slice(&[255, 255, 255, 255]);
|
||||
let s = dispersed(&impulse, 0.5, 0.5, bend, 0.0, k);
|
||||
if s[0].max(s[1]).max(s[2]) > 0.0 {
|
||||
furthest = furthest.max(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A sample `o` pixels along picks up the pixel `at` while |o − at| < 1.
|
||||
assert!(furthest > 0 && (furthest as f32) < bound + 1.0, "pixel {furthest} seen, past {bound}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_of_a_square() {
|
||||
let mut p = Pixmap::new(21, 21).unwrap();
|
||||
|
||||
@@ -293,12 +293,21 @@ pub fn decode_frames(bytes: &[u8]) -> Option<Vec<(Pixmap, f64)>> {
|
||||
if image::guess_format(bytes).ok()? != image::ImageFormat::Gif {
|
||||
return None;
|
||||
}
|
||||
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
|
||||
let mut dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
|
||||
let (w, h) = dec.dimensions();
|
||||
let frame_bytes = u64::from(w) * u64::from(h) * 4;
|
||||
if frame_bytes == 0 || w > MAX_SIDE || h > MAX_SIDE {
|
||||
return None;
|
||||
}
|
||||
// A frame's own descriptor can ask for a far larger buffer than the canvas: the decoder
|
||||
// reserves each frame and canvas buffer against these limits before allocating it.
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_SIDE);
|
||||
limits.max_image_height = Some(MAX_SIDE);
|
||||
// The decoder charges its canvas, its composited output and the partial frame against this
|
||||
// together, besides the frames kept (capped by `most` below): one frame of working room.
|
||||
limits.max_alloc = Some(MAX_ANIMATION_BYTES.saturating_add(frame_bytes));
|
||||
dec.set_limits(limits).ok()?;
|
||||
let most = (MAX_ANIMATION_BYTES / frame_bytes).min(MAX_FRAMES as u64) as usize;
|
||||
let mut out = Vec::new();
|
||||
for f in dec.into_frames().take(most) {
|
||||
|
||||
@@ -139,3 +139,24 @@ fn a_deep_nest_of_isolated_layers_stays_within_the_budget() {
|
||||
// The outer levels still draw (the innermost, past the budget, are left out).
|
||||
assert_ne!(&pix.data()[..4], &[255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_nested_inside_masks_stop_at_the_nesting_limit() {
|
||||
// A mask's content is drawn straight from `mask_pixels`, not through `draw_layer`, so a mask
|
||||
// group inside a mask group inside… used to recurse past the limit and down the stack.
|
||||
let mut d = Document::new("deep masks");
|
||||
let p = d.first_page().unwrap();
|
||||
let mut parent = p;
|
||||
for _ in 0..2000 {
|
||||
let g = d.create(parent, NodeKind::Group, "Masking group", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
let n = d.get_mut(g).unwrap();
|
||||
n.is_mask = true;
|
||||
n.mask_type = MaskType::Alpha;
|
||||
parent = g;
|
||||
}
|
||||
shape(&mut d, parent, NodeKind::Rectangle, (0.0, 0.0, 100.0, 100.0), "2040E0");
|
||||
shape(&mut d, p, NodeKind::Rectangle, (0.0, 0.0, 100.0, 100.0), "E04020");
|
||||
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 50, height: 50 };
|
||||
let pix = render(&d, p, &v, false);
|
||||
assert_eq!(pix.width(), 50);
|
||||
}
|
||||
@@ -79,11 +79,13 @@ fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option<Arc<Pixmap>> {
|
||||
const N: u32 = 256;
|
||||
let mut p = Pixmap::new(N, N)?;
|
||||
let half = f64::from(N) / 2.0;
|
||||
// Sorted once for the 65 536 samples below, not once per sample.
|
||||
let stops = g.sorted_stops();
|
||||
for (i, px) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let x = ((i as u32 % N) as f64 + 0.5 - half) / half;
|
||||
let y = ((i as u32 / N) as f64 + 0.5 - half) / half;
|
||||
let t = (x.abs() + y.abs()) as f32;
|
||||
let c = g.sample(t);
|
||||
let c = figcraft_color::sample_sorted(&stops, t);
|
||||
let a = c.a * opacity;
|
||||
let q = |v: f32| (v * a * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
px.copy_from_slice(&[q(c.r), q(c.g), q(c.b), (a * 255.0).round().clamp(0.0, 255.0) as u8]);
|
||||
|
||||
@@ -103,11 +103,34 @@ pub(crate) struct Ctx<'a> {
|
||||
pattern_depth: usize,
|
||||
/// Tiles drawn so far, by pattern and scale (see [`paint`]'s `render_tile`).
|
||||
tiles: HashMap<(*const usvg::Pattern, u64), Option<paint::Tile>>,
|
||||
/// The document image key of each embedded image's bytes already added, so an image a
|
||||
/// pattern paints on many shapes is hashed once, not once per shape.
|
||||
image_keys: HashMap<*const Vec<u8>, String>,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
pub(crate) fn new(doc: &'a mut Document) -> Self {
|
||||
Ctx { doc, count: 0, anonymous: HashSet::new(), notes: BTreeSet::new(), fonts: None, pattern_depth: 0, tiles: HashMap::new() }
|
||||
Ctx {
|
||||
doc,
|
||||
count: 0,
|
||||
anonymous: HashSet::new(),
|
||||
notes: BTreeSet::new(),
|
||||
fonts: None,
|
||||
pattern_depth: 0,
|
||||
tiles: HashMap::new(),
|
||||
image_keys: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`add_image`] for `bytes`, once per distinct buffer.
|
||||
pub(crate) fn image(&mut self, bytes: std::sync::Arc<Vec<u8>>, w: f64, h: f64) -> String {
|
||||
if let Some(k) = self.image_keys.get(&std::sync::Arc::as_ptr(&bytes)) {
|
||||
return k.clone();
|
||||
}
|
||||
let ptr = std::sync::Arc::as_ptr(&bytes);
|
||||
let key = add_image(self.doc, bytes, w, h);
|
||||
self.image_keys.insert(ptr, key.clone());
|
||||
key
|
||||
}
|
||||
|
||||
pub(crate) fn full(&self) -> bool {
|
||||
@@ -605,7 +628,7 @@ fn image(cx: &mut Ctx, img: &usvg::Image, id: &str, parent: NodeId, base: Affine
|
||||
let size = img.size();
|
||||
let (iw, ih) = (f64::from(size.width()), f64::from(size.height()));
|
||||
let (transform, w, h) = place(world, Point::ZERO, iw, ih).unwrap_or((world, iw, ih));
|
||||
let hash = add_image(cx.doc, bytes, iw, ih);
|
||||
let hash = cx.image(bytes, iw, ih);
|
||||
let mut n = Node::new(cx.doc.alloc_id(), NodeKind::Rectangle, layer_name(id, "Image"), w, h);
|
||||
n.transform = transform;
|
||||
let mut fill = Paint::image(hash, ImageScaleMode::Fill);
|
||||
|
||||
@@ -44,6 +44,11 @@ fn inflate(data: &[u8], limit: usize) -> Result<Vec<u8>, String> {
|
||||
if out.len() > limit {
|
||||
return Err(format!("compressed SVG expands past {} MiB", limit >> 20));
|
||||
}
|
||||
// usvg gunzips what it is handed, with no limit: one layer is unpacked here, within the
|
||||
// budget, and what comes out must be the document itself.
|
||||
if gzipped(&out) {
|
||||
return Err("compressed SVG holds another compressed file, not a document".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -72,7 +77,15 @@ fn embedded(used: &Used, limits: &Limits, data: &[u8], opts: &Options) -> Option
|
||||
return None;
|
||||
}
|
||||
let left = limits.embedded.saturating_sub(used.bytes.load(Ordering::Relaxed));
|
||||
let plain = inflate(data, left).ok()?;
|
||||
let plain = match inflate(data, left) {
|
||||
Ok(plain) => plain,
|
||||
// What a refused document unpacked before it was refused was work too: it uses up what is
|
||||
// left, so the next image can't repeat it.
|
||||
Err(_) => {
|
||||
used.bytes.fetch_add(left, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
used.bytes.fetch_add(plain.len(), Ordering::Relaxed);
|
||||
if super::too_deep(&plain) {
|
||||
return None;
|
||||
|
||||
@@ -111,3 +111,31 @@ fn linked_files_and_other_mime_types_stay_unresolved() {
|
||||
);
|
||||
assert_eq!(layers(data.as_bytes(), Limits::default()).unwrap(), layers(svg("").as_bytes(), Limits::default()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_compressed_twice_is_refused_not_unpacked_again() {
|
||||
// One gzip layer is unpacked within the budget; usvg would unpack a second one without any.
|
||||
let twice = gzip(&gzip(svg(&" ".repeat(1 << 20)).as_bytes()));
|
||||
let e = layers(&twice, Limits { document: 4 << 20, ..Limits::default() }).unwrap_err();
|
||||
assert!(e.contains("another compressed file"), "{e}");
|
||||
// Embedded, the same is dropped.
|
||||
let inner = svg(r##"<rect width="10" height="10"/>"##);
|
||||
let data = svg(&format!(
|
||||
r##"<image width="10" height="10" xlink:href="data:image/svg+xml;base64,{}"/>"##,
|
||||
base64::engine::general_purpose::STANDARD.encode(gzip(&gzip(inner.as_bytes())))
|
||||
))
|
||||
.into_bytes();
|
||||
assert_eq!(layers(&data, Limits::default()).unwrap(), layers(svg("").as_bytes(), Limits::default()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_embedded_bomb_uses_up_the_budget_for_the_images_after_it() {
|
||||
let bomb = gzip(svg(&" ".repeat(1 << 20)).as_bytes());
|
||||
let inner = svg(r##"<rect width="10" height="10"/>"##);
|
||||
let image = |href: String| format!(r##"<image width="10" height="10" xlink:href="{href}"/>"##);
|
||||
let bomb_uri = format!("data:image/svg+xml;base64,{}", base64::engine::general_purpose::STANDARD.encode(&bomb));
|
||||
let data = svg(&format!("{}{}", image(bomb_uri).repeat(8), image(uri(inner.as_bytes())))).into_bytes();
|
||||
let limits = Limits { embedded: 4096, ..Limits::default() };
|
||||
let empty = layers(svg("").as_bytes(), limits).unwrap();
|
||||
assert_eq!(layers(&data, limits).unwrap(), empty, "the first bomb spends the allowance; nothing after it unpacks");
|
||||
}
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user