Merge main into #1507

This commit is contained in:
bflatastic
2026-10-29 23:23:12 +07:00
168 changed files with 5058 additions and 719 deletions
Generated
+89
View File
@@ -939,11 +939,46 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-atomic"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"rustix 1.1.5",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-font-catalog"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"fontique",
"skrifa 0.47.0",
"thiserror 2.0.21",
"windows-registry",
]
[[package]]
name = "craft-font-store"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-atomic",
"craft-font-catalog",
"flate2",
"serde",
"serde_json",
"sha2",
"skrifa 0.47.0",
"thiserror 2.0.21",
"ureq",
]
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
@@ -1746,6 +1781,8 @@ version = "0.12.1"
dependencies = [
"base64 0.22.1",
"craft-docpath",
"craft-font-catalog",
"craft-font-store",
"deunicode",
"figcraft-collab",
"figcraft-color",
@@ -1867,6 +1904,7 @@ dependencies = [
"crc32fast",
"figcraft-color",
"figcraft-doc",
"figcraft-format",
"figcraft-geom",
"figcraft-pathops",
"figcraft-text",
@@ -1923,6 +1961,7 @@ name = "figcraft-text"
version = "0.12.1"
dependencies = [
"core-text",
"craft-font-catalog",
"figcraft-doc",
"figcraft-geom",
"harfrust 0.13.3",
@@ -2116,6 +2155,28 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "fontique"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a"
dependencies = [
"hashbrown 0.17.1",
"linebender_resource_handle",
"memmap2",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"parlance",
"read-fonts 0.41.0",
"roxmltree 0.21.2",
"smallvec",
"windows",
"windows-core",
"yeslogic-fontconfig-sys",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -3425,6 +3486,16 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.13.2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -3683,6 +3754,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "parlance"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e"
[[package]]
name = "pdf-writer"
version = "0.15.0"
@@ -5359,6 +5436,7 @@ dependencies = [
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
@@ -6504,6 +6582,17 @@ dependencies = [
"serde_json",
]
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
dependencies = [
"dlib",
"once_cell",
"pkg-config",
]
[[package]]
name = "yoke"
version = "0.8.4"
+5 -1
View File
@@ -42,6 +42,10 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
@@ -136,7 +140,7 @@ lto = "thin"
codegen-units = 4
# The browser build only (apps/figcraft-web/index.html: data-cargo-profile). Optimised for size:
# packaging/web/package.sh fails a .wasm over 25 MiB - 512 KiB (Cloudflare's per-file cap is 25 MiB).
# packaging/web/package.sh fails a .wasm over 24 MiB (Cloudflare's per-file cap is 25 MiB).
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
[profile.web-release]
inherits = "release"
+21 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -355,6 +355,26 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged |
| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged |
| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged |
| 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged |
| 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged |
| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. |
| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged |
| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. |
| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged |
| 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged |
| 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). |
| 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged |
| 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged |
| 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged |
| 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged |
| 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged |
| 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged |
| 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. |
| 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged |
| 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) |
| 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged |
| 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged |
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
+2 -2
View File
@@ -43,8 +43,8 @@ Options (also keys of the --config TOML file):
--data DIR the data directory (default ./figcraft-data)
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
max_connections, max_people, compact_after, compact_mb, max_plugins,
max_plugin_mb, max_plugins_mb; for a public server: open_publish,
max_connections, max_people, compact_after, compact_mb, max_log_mb,
max_plugins, max_plugin_mb, max_plugins_mb; for a public server: open_publish,
operator_contact, terms_url, trusted_proxy, idle_days, max_total_mb,
max_files_per_ip, max_mb_per_ip, max_connections_per_ip,
connects_per_minute, publishes_per_hour, messages_per_second
+14 -7
View File
@@ -39,6 +39,9 @@ pub struct Config {
pub compact_after: usize,
/// …or once the log is this large (MiB).
pub compact_mb: u64,
/// Most MiB of recent batches a file in use keeps in memory for apps that reconnect (one
/// further behind catches up with the whole file).
pub max_log_mb: u64,
/// Most plugin versions the plugin directory keeps (0 turns it off).
pub max_plugins: usize,
/// Largest plugin bundle, in MiB.
@@ -84,6 +87,7 @@ impl Default for Config {
max_people: 256,
compact_after: 1000,
compact_mb: 16,
max_log_mb: 64,
max_plugins: 500,
max_plugin_mb: 16,
max_plugins_mb: 1024,
@@ -209,6 +213,7 @@ impl Config {
("max_people", self.max_people as u64),
("compact_after", self.compact_after as u64),
("compact_mb", self.compact_mb),
("max_log_mb", self.max_log_mb),
("max_plugin_mb", self.max_plugin_mb),
("max_plugins_mb", self.max_plugins_mb),
] {
@@ -216,7 +221,7 @@ impl Config {
return Err(format!("`{name}` must be at least 1"));
}
}
if self.file_mb() > 4096 || self.compact_mb > 4096 {
if self.file_mb() > 4096 || self.compact_mb > 4096 || self.max_log_mb > 4096 {
return Err("sizes are in MiB and at most 4096".into());
}
// A bundle travels in one message (at most 64 MiB) next to its entry.
@@ -274,17 +279,19 @@ impl Config {
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
}
/// The collaboration core's part: when open, messages as large as a file at most, and the
/// batches kept for reconnecting clients (in memory, and in the file's snapshot) at most a
/// quarter of a file's bytes (256 KiB at least).
/// The collaboration core's part: the batches kept for reconnecting clients (in memory, and
/// in the file's snapshot) at most `max_log_mb`; when open, messages as large as a file at
/// most, and those batches also at most a quarter of a file's bytes (256 KiB at least).
fn core(&self) -> figcraft_collab::Limits {
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
let bytes = |mb: u64| usize::try_from(mb.saturating_mul(1 << 20)).unwrap_or(usize::MAX);
let base =
figcraft_collab::Limits { max_peers: self.max_people, max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() };
if !self.open_publish {
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
}
let max = self.open_message();
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
let log = (bytes(self.file_mb()) / 4).max(256 << 10).min(base.max_log_bytes);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: log, ..base }
}
/// The transport's part. A private server keeps the defaults; open to the public, connections
+13
View File
@@ -584,6 +584,19 @@ fn files_above_64_mib_fit_every_limit() {
assert_eq!(NetLimits::default().max_server_message, 1 << 30);
}
#[test]
fn the_batches_kept_for_reconnecting_apps_follow_max_log_mb() {
let log = |cfg: Config| cfg.store().core.max_log_bytes;
assert_eq!(log(Config::default()), 64 << 20, "a private server's log is bounded too");
assert_eq!(log(Config { max_log_mb: 8, ..Config::default() }), 8 << 20);
// Open to the public: also at most a quarter of a file (16 MiB unless set).
assert_eq!(log(Config { open_publish: true, ..Config::default() }), 4 << 20);
assert_eq!(log(Config { open_publish: true, max_log_mb: 1, ..Config::default() }), 1 << 20);
for mb in [0, 4097] {
assert!(Config { max_log_mb: mb, ..Config::default() }.check().is_err(), "max_log_mb = {mb}");
}
}
#[test]
fn large_files_warn_the_operator() {
let public_url = Some("wss://figcraft.example.com/".into());
+6
View File
@@ -0,0 +1,6 @@
# The browser build (trunk runs cargo from this folder; packaging/web/package.sh). With simd128 the
# glyph rasterizer's SIMD code compiles for wasm SIMD instead of its much larger scalar fallback:
# about 1.6 MB less wasm. Every current browser has wasm SIMD (Safari since 16.4). The plugin
# workspace is built from the repository root, so its modules stay without SIMD for the plugin runtime.
[target.wasm32-unknown-unknown]
rustflags = ["-Ctarget-feature=+simd128"]
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
<title>FigCraft</title>
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue" data-weak-refs />
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue --enable-simd" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; overscroll-behavior: none; background: #f5f5f5; }
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
+62 -2
View File
@@ -131,9 +131,10 @@ fn document_level_data_round_trips() {
x: 1.0,
y: 2.0,
anchor: None,
first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![] },
first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![], agent: false },
resolved: false,
replies: vec![],
// An AI agent's reply reaches the others with its name and marker.
replies: vec![Message { agent: true, ..Message::new("Claude (agent)", "Done", 2) }],
read_by: None,
});
b.images.insert("hash".into(), ImageData { bytes: Arc::new(vec![1, 2, 3, 250]), width: 2, height: 1 });
@@ -250,3 +251,62 @@ fn invalid_changes_are_refused_and_lenient_mode_skips_them() {
let mut r = a.clone();
assert!(r.apply(&[Change::Delete { id: NodeId(12345) }, Change::Remove { entry: Entry::Comment(1) }], Mode::Strict).is_ok());
}
/// Where `id` goes among `kids`, as placement worked before it searched: after every other sibling
/// that sorts before it by `(key, id)`, counted one by one.
fn place_counting(kids: &mut Vec<NodeId>, keys: &BTreeMap<NodeId, String>, id: NodeId) {
kids.retain(|c| *c != id);
let key = |c: NodeId| keys.get(&c).map_or("", String::as_str);
let at = kids.iter().filter(|c| (key(**c), **c) < (key(id), id)).count();
kids.insert(at, id);
}
#[test]
fn many_sibling_placements_keep_the_order_of_counting_them() {
// The reference counts, so it's quadratic: placing 20,000 here took 20 s before the search
// and 0.3 s after (debug build).
const N: u64 = 5_000;
let mut doc = Document::new("Order");
let page = doc.first_page().unwrap();
let parents = [page, doc.create(page, NodeKind::Frame, "A", 0.0, 0.0, 10.0, 10.0).unwrap()];
let mut r = Replica::new(doc);
let mut rng = crate::sim::Rng::new(1065);
// Few whole keys and the fractions between them: many ties (broken by id), many fractions.
let whole = crate::frac::n_between(None, None, 12).unwrap();
let mut pool = whole.clone();
pool.extend(whole.windows(2).map(|w| crate::frac::between(Some(&w[0]), Some(&w[1])).unwrap()));
let mut keys: BTreeMap<NodeId, String> = parents.iter().map(|p| (*p, r.key(*p).to_string())).collect();
let mut kids: BTreeMap<NodeId, Vec<NodeId>> = parents.iter().map(|p| (*p, r.document().node(*p).unwrap().children.clone())).collect();
let mut parent_of = BTreeMap::new();
// One batch of creations in a scrambled id order, under either parent.
let mut creates = Vec::new();
for i in 0..N {
let (id, p, key) = (NodeId(1_000_000 + (i * 7919) % N), parents[rng.below(2)], pool[rng.below(pool.len())].clone());
keys.insert(id, key.clone());
parent_of.insert(id, p);
place_counting(kids.get_mut(&p).unwrap(), &keys, id);
creates.push(Change::Create { id, kind: Json::of(&NodeKind::Rectangle).unwrap(), parent: Some(p), position: key, props: vec![] });
}
// Then a batch of moves: to another key (often a tie), or under either parent (its own too).
let mut moves = Vec::new();
for _ in 0..N / 2 {
let id = NodeId(1_000_000 + rng.below(N as usize) as u64);
if rng.chance(0.5) {
let key = pool[rng.below(pool.len())].clone();
keys.insert(id, key.clone());
place_counting(kids.get_mut(&parent_of[&id]).unwrap(), &keys, id);
moves.push(Change::Set { id, prop: Prop::Position, value: Json::of(&key).unwrap() });
} else {
let p = parents[rng.below(2)];
let old = parent_of.insert(id, p).unwrap();
kids.get_mut(&old).unwrap().retain(|c| *c != id);
place_counting(kids.get_mut(&p).unwrap(), &keys, id);
moves.push(Change::Set { id, prop: Prop::Parent, value: Json::of(&Some(p)).unwrap() });
}
}
r.apply(&creates, Mode::Strict).unwrap();
r.apply(&moves, Mode::Strict).unwrap();
for p in parents {
assert_eq!(r.document().node(p).unwrap().children, kids[&p], "children of {p}");
}
}
+3 -2
View File
@@ -88,7 +88,8 @@ pub struct Limits {
pub max_log_batches: usize,
/// Most changes in those batches together.
pub max_log_changes: usize,
/// About how many bytes those batches may hold together ([`server::weight`]).
/// About how many bytes those batches may hold together ([`server::weight`]). 64 MiB by
/// default, so a long busy session (on a desktop host too) keeps a bounded log.
pub max_log_bytes: usize,
}
@@ -102,7 +103,7 @@ impl Default for Limits {
max_name: 64,
max_log_batches: 10_000,
max_log_changes: 1_000_000,
max_log_bytes: usize::MAX,
max_log_bytes: 64 << 20,
}
}
}
+37
View File
@@ -133,6 +133,43 @@ fn a_long_absence_catches_up_with_a_snapshot() {
assert_eq!(a.view(), s.replica());
}
#[test]
fn the_default_log_is_bounded_in_bytes_and_a_client_behind_it_catches_up_with_a_snapshot() {
// The defaults are what a desktop host runs with.
let limits = Limits::default();
assert_eq!(limits.max_log_bytes, 64 << 20);
let mut s = Server::new(base(), "tok", limits);
let mut a = join(&mut s);
let mut b = join(&mut s);
let page = s.document().first_page().unwrap();
a.disconnect();
s.leave(a.id());
// Nine renames of 8 MiB each: more than the log holds, in a few batches.
for i in 0..9 {
let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = format!("{i}{}", "y".repeat(8 << 20))).unwrap();
let out = s.receive(b.id(), m);
deliver(&mut b, &out);
assert!(s.log_bytes() <= limits.max_log_bytes, "{} bytes kept", s.log_bytes());
}
assert!(s.log_bytes() > 0, "recent batches are still kept");
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!() };
let (_, out) = s.join(h).unwrap();
assert!(matches!(&out[0], Outgoing::To(_, ServerMsg::Welcome { catch_up: CatchUp::Snapshot(_), .. })));
deliver(&mut a, &out);
assert_eq!(a.view(), s.replica());
// Within the log, a short absence still gets the missed batch only.
a.disconnect();
s.leave(a.id());
let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = "Later".into()).unwrap();
let out = s.receive(b.id(), m);
deliver(&mut b, &out);
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!() };
let (_, out) = s.join(h).unwrap();
assert!(matches!(&out[0], Outgoing::To(_, ServerMsg::Welcome { catch_up: CatchUp::Delta(d), .. }) if d.len() == 1));
deliver(&mut a, &out);
assert_eq!(a.view(), s.replica());
}
#[test]
fn after_a_snapshot_undo_leaves_what_others_changed_meanwhile() {
let mut s = Server::new(base(), "tok", Limits { max_log_batches: 2, ..Limits::default() });
+8 -2
View File
@@ -175,9 +175,15 @@ impl Replica {
}
/// Where `id` with `key` goes among `siblings` (sorted by `(key, id)`; `id` itself skipped):
/// how many of them sort before it.
/// how many of them sort before it. A binary search on each side of `id`, which may still
/// sit where its old key put it.
fn slot(&self, siblings: &[NodeId], id: NodeId, key: &str) -> usize {
siblings.iter().filter(|c| **c != id && (self.key(**c), **c) < (key, id)).count()
let before = |c: &NodeId| (self.key(*c), *c) < (key, id);
let (head, tail) = match siblings.iter().position(|c| *c == id) {
Some(i) => (siblings.get(..i).unwrap_or_default(), siblings.get(i + 1..).unwrap_or_default()),
None => (siblings, &[][..]),
};
head.partition_point(before) + tail.partition_point(before)
}
/// Apply `changes` in order. Node changes come first in a batch made by [`diff`](crate::diff);
+18 -13
View File
@@ -557,6 +557,7 @@ impl Server {
let doc = self.state.document();
let mut props: BTreeMap<NodeId, BTreeSet<Prop>> = BTreeMap::new();
let mut gone: BTreeSet<NodeId> = BTreeSet::new();
let (mut asked, mut sent) = (BTreeSet::new(), BTreeSet::new());
let mut out = Vec::new();
for c in changes {
match c {
@@ -569,9 +570,11 @@ impl Server {
Change::Delete { id } => {
gone.insert(*id);
}
// The same target has the same fix: look each one up once, and send each fix once.
other => {
if let Some(fix) = docprops::current(doc, other)?
&& !out.contains(&fix)
if Target::of(other).is_some_and(|t| asked.insert(t))
&& let Some(fix) = docprops::current(doc, other)?
&& Target::of(&fix).is_some_and(|t| sent.insert(t))
{
out.push(fix);
}
@@ -589,19 +592,21 @@ impl Server {
nodes.extend(t.write(self.state.value(&t)?.as_ref()));
}
}
let mut restored = std::collections::BTreeSet::new();
// Each deleted node's subtree, pre-order, every node once: a restored node's whole subtree
// went out with it, so the walk stops there (deleting a deep chain stays linear).
let mut restored = BTreeSet::new();
for id in gone {
match doc.node(id) {
Some(_) => {
for d in doc.subtree(id) {
if restored.insert(d)
&& let Some(n) = doc.node(d)
{
nodes.push(create_change(n, self.state.key(d))?);
}
}
if !doc.contains(id) {
nodes.push(Change::Delete { id });
continue;
}
let mut stack = vec![id];
while let Some(d) = stack.pop() {
let Some(n) = doc.node(d) else { continue };
if restored.insert(d) {
nodes.push(create_change(n, self.state.key(d))?);
stack.extend(n.children.iter().rev());
}
None => nodes.push(Change::Delete { id }),
}
}
nodes.extend(out);
+38 -1
View File
@@ -2,7 +2,7 @@
use figcraft_doc::{Document, Node, NodeId, NodeKind};
use crate::change::{Change, Json, Prop};
use crate::change::{Change, Entry, Json, Prop};
use crate::message::{ClientMsg, Hello, PROTOCOL, Presence, ServerMsg};
use crate::replica::Mode;
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
@@ -217,6 +217,43 @@ fn fixes_roll_a_rejected_client_back_to_the_server_state() {
assert_eq!(b.view(), s.replica());
}
#[test]
fn a_refused_deep_delete_restores_each_node_once_in_linear_time() {
const DEPTH: usize = 20_000;
let (mut d, _, frame) = doc();
let mut chain = vec![frame];
while chain.len() < DEPTH {
let parent = *chain.last().unwrap();
chain.push(d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 1.0, 1.0).unwrap());
}
let mut s = Server::new(d, "tok", Limits::default());
let a = join(&mut s);
// Every node of the chain deleted, plus a rename of a node nobody has: refused whole.
let missing = NodeId(u64::MAX >> 1);
let mut changes: Vec<Change> = chain.iter().map(|&id| Change::Delete { id }).collect();
changes.push(Change::Set { id: missing, prop: Prop::Name, value: Json("x".into()) });
// As many comment threads put, each twice: they go back to not existing, once each.
let put = |c: u64| Change::Put { entry: Entry::Comment(c), value: Json(serde_json::json!({})) };
changes.extend((1..=DEPTH as u64).chain(1..=DEPTH as u64).map(put));
let started = std::time::Instant::now();
let (_, fixes) = rejected(&mut s, a.id(), changes);
let took = started.elapsed();
assert_eq!(fixes.first(), Some(&Change::Delete { id: missing }));
let restored: Vec<NodeId> = fixes[1..=DEPTH]
.iter()
.map(|c| match c {
Change::Create { id, .. } => *id,
other => panic!("not a creation: {other:?}"),
})
.collect();
assert_eq!(restored, chain, "each deleted node comes back once, parents first");
let removed: Vec<Change> = (1..=DEPTH as u64).map(|c| Change::Remove { entry: Entry::Comment(c) }).collect();
assert_eq!(fixes[DEPTH + 1..], removed[..], "each entry once, in batch order");
// Walking every deleted node's subtree again and comparing each fix with all the earlier
// ones took 27 s in a debug build; once each takes 0.1 s.
assert!(took < std::time::Duration::from_secs(10), "{took:?}");
}
#[test]
fn remote_batches_apply_under_pending_ones() {
let (d, _, frame) = doc();
+15 -1
View File
@@ -486,9 +486,23 @@ impl ImagePaint {
{
return c;
}
self.fitted_crop(iw, ih, w, h, false)
}
/// The crop transform that keeps the picture where its scale mode draws it: the crop in effect
/// in Crop mode, the Fit placement in Fit mode, else (Fill, Tile) the Fill placement.
pub fn placement_crop(&self, iw: f64, ih: f64, w: f64, h: f64) -> [f64; 6] {
match self.scale_mode {
ImageScaleMode::Crop => self.effective_crop(iw, ih, w, h),
mode => self.fitted_crop(iw, ih, w, h, mode == ImageScaleMode::Fit),
}
}
/// The Fill placement (`fit`: the Fit placement) as a crop transform.
fn fitted_crop(&self, iw: f64, ih: f64, w: f64, h: f64, fit: bool) -> [f64; 6] {
let (rw, rh) = self.rotated_size(iw.max(1.0), ih.max(1.0));
let (w, h) = (w.max(1e-6), h.max(1e-6));
let s = (w / rw).max(h / rh);
let s = if fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) };
let (x0, y0) = ((w - rw * s) / 2.0, (h - rh * s) / 2.0);
[w / (rw * s), 0.0, 0.0, h / (rh * s), -x0 / (rw * s), -y0 / (rh * s)]
}
+19 -9
View File
@@ -26,6 +26,9 @@ pub struct Message {
pub edited: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reactions: Vec<Reaction>,
/// Written by an AI agent (a command run for an MCP client), not by a person in the app.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub agent: bool,
}
/// A reply in a comment thread.
@@ -37,7 +40,7 @@ fn is_zero(v: &u64) -> bool {
impl Message {
pub fn new(author: impl Into<String>, text: impl Into<String>, created: u64) -> Self {
Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new() }
Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new(), agent: false }
}
/// `user` left the `emoji` reaction.
@@ -180,10 +183,16 @@ impl Comment {
}
}
/// The byte ranges of `@Name` mentions of `people` in `text` (names match ignoring ASCII case; the
/// longest name wins; a mention starts the text or follows a non-alphanumeric character and ends
/// before one).
/// The byte ranges of `@Name` mentions of `people` in `text` (see [`mentions`]).
pub fn mention_ranges(text: &str, people: &[&str]) -> Vec<Range<usize>> {
mentions(text, people).into_iter().filter(|(_, who)| who.is_some()).map(|(r, _)| r).collect()
}
/// Every `@` mention in `text`: its byte range and the one of `people` it names (names match
/// ignoring ASCII case; the longest name wins), or `None` for a name nobody in `people` has (then
/// the letters and digits after the `@`). A mention starts the text or follows a non-alphanumeric
/// character and ends before one, so `a@b.com` mentions nobody.
pub fn mentions<'p>(text: &str, people: &[&'p str]) -> Vec<(Range<usize>, Option<&'p str>)> {
let mut out = Vec::new();
let mut from = 0;
while let Some(off) = text.get(from..).and_then(|t| t.find('@')) {
@@ -194,15 +203,16 @@ pub fn mention_ranges(text: &str, people: &[&str]) -> Vec<Range<usize>> {
continue;
}
let rest = text.get(at + 1..).unwrap_or("");
let best = people
let who = people
.iter()
.copied()
.filter(|n| !n.is_empty())
.filter(|n| rest.get(..n.len()).is_some_and(|h| h.eq_ignore_ascii_case(n)))
.filter(|n| rest.get(n.len()..).and_then(|t| t.chars().next()).is_none_or(|c| !c.is_alphanumeric()))
.map(|n| n.len())
.max();
if let Some(len) = best {
out.push(at..at + 1 + len);
.max_by_key(|n| n.len());
let len = who.map_or_else(|| rest.find(|c: char| !c.is_alphanumeric()).unwrap_or(rest.len()), str::len);
if len > 0 {
out.push((at..at + 1 + len, who));
from = at + 1 + len;
}
}
+52
View File
@@ -445,6 +445,21 @@ pub struct TextData {
/// data small.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<Box<TextPath>>,
/// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout).
/// Layout follows them only while the box width and everything that moves a line break are
/// as they were saved, so an edit that could change the wrap drops them.
#[serde(skip_serializing_if = "Option::is_none")]
pub saved_breaks: Option<Box<SavedBreaks>>,
}
/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a
/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct SavedBreaks {
pub starts: Vec<usize>,
pub width: f64,
pub for_text: u64,
}
/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the
@@ -503,6 +518,43 @@ impl TextData {
Self { characters: characters.into(), ..Default::default() }
}
/// A stable hash of what decides where lines break: everything but paints, links, style links,
/// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it
/// stays the same across builds and can be saved).
pub fn layout_key(&self) -> u64 {
let clear = |s: &mut TextStyle| {
(s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None);
};
let mut t = TextData {
align_v: TextAlignV::default(),
paragraph_spacing: 0.0,
list_spacing: 0.0,
leading_trim: LeadingTrim::default(),
max_lines: 0,
saved_breaks: None,
..self.clone()
};
clear(&mut t.style);
t.runs.iter_mut().for_each(|r| clear(&mut r.style));
t.normalize();
serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
}
/// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box
/// `width` wide.
pub fn save_breaks(&mut self, starts: Vec<usize>, width: f64) {
self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() }));
}
/// The saved line starts (empty: no wrapped line) while they still hold: the same box width and
/// layout content, and starts increasing inside the text.
pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> {
let b = self.saved_breaks.as_deref()?;
let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count());
let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0);
(inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice())
}
/// How far text on a path paints past its path's box (glyphs stand on the path, lines after
/// the first run beside it): about one and a half of the largest size per line; 0 off a path.
pub fn path_reach(&self) -> f64 {
+6
View File
@@ -33,6 +33,12 @@ deunicode = { workspace = true }
same-file = { workspace = true }
web-time = "1.1.0"
# Downloading a missing font (desktop only; the web keeps its built-in fonts). The bundled index
# (~620 KB) lets the Missing fonts window tell which fonts can be downloaded without a request.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-font-store = { workspace = true, features = ["bundled-index"] }
craft-font-catalog = { workspace = true }
[dev-dependencies]
zip = { workspace = true }
wat = { workspace = true }
+4
View File
@@ -39,6 +39,7 @@ use figcraft_doc::{
use figcraft_geom::Affine;
use serde_json::Value;
mod cycles;
mod diff;
mod guard;
mod instances;
@@ -47,6 +48,9 @@ mod rebase;
mod sync;
mod variants;
pub use cycles::{
break_component_cycles, check_component_cycles, component_cycle, contains_itself_error, cycle_text, new_cycle, would_contain_itself,
};
pub use diff::{diff_groups, property_owners, set_boolean_default, variable_groups};
pub use guard::{check_override_edit, check_structure_edit, refused_override, unlink_layers};
pub(crate) use instances::copy_group;
@@ -0,0 +1,214 @@
//! A component can't contain its own instances, directly or through other components (#1084):
//! every instance would show itself without end. Each variant is a component of its own here: one
//! holding an instance of another variant of its set is fine unless that one leads back to it.
//! Commands that would close such a loop are refused ([`check_component_cycles`]);
//! files that hold one are broken on opening ([`break_component_cycles`]); and a rebuild never
//! expands an instance inside a copy of itself ([`expansion_layers`]), for loops that arrive some
//! other way (two edits merged in a live session).
use super::patch::rebuild_instances;
use super::{BTreeMap, BTreeSet, Document, NodeId, NodeKind, PROP_BINDING, PropDef, inside_instance, root_instance};
/// The components each main component holds instances of.
type Graph = BTreeMap<NodeId, BTreeSet<NodeId>>;
fn is_component(doc: &Document, id: NodeId) -> bool {
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
}
/// The main components `id` is (in).
fn hosts(doc: &Document, id: NodeId) -> BTreeSet<NodeId> {
std::iter::once(id).chain(doc.ancestors(id)).filter(|a| is_component(doc, *a)).collect()
}
/// The components instance `id` shows: its main, and those its instance swap properties pick
/// (they swap a layer inside it when it's rebuilt).
fn shown(doc: &Document, id: NodeId) -> Vec<NodeId> {
let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return Vec::new() };
let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|n| &n.kind) else { return Vec::new() };
let swaps =
defs.iter().filter(|(_, d)| matches!(d, PropDef::InstanceSwap { .. })).filter_map(|(k, _)| props.get(k).and_then(crate::params::id_of));
std::iter::once(*component).chain(swaps).filter(|c| is_component(doc, *c)).collect()
}
fn graph(doc: &Document) -> Graph {
let mut g = Graph::new();
for n in doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })) {
let shown = shown(doc, n.id);
for h in hosts(doc, n.id) {
g.entry(h).or_default().extend(shown.iter().copied());
}
}
g
}
/// A loop in `g`, its first component repeated last (`A, B, A`).
fn find_cycle(g: &Graph) -> Option<Vec<NodeId>> {
let next = |u: &NodeId| -> Vec<NodeId> { g.get(u).map(|s| s.iter().rev().copied().collect()).unwrap_or_default() };
let mut done: BTreeSet<NodeId> = BTreeSet::new();
for start in g.keys() {
if done.contains(start) {
continue;
}
// Depth first: the path so far, and the components each step on it has left to visit.
let mut path = vec![*start];
let mut todo = vec![next(start)];
while let Some(left) = todo.last_mut() {
match left.pop() {
Some(u) if path.contains(&u) => {
let at = path.iter().position(|p| *p == u)?;
let mut cycle = path.split_off(at);
cycle.push(u);
return Some(cycle);
}
Some(u) if done.contains(&u) => {}
Some(u) => {
todo.push(next(&u));
path.push(u);
}
None => {
todo.pop();
done.extend(path.pop());
}
}
}
}
None
}
/// A loop of components whose mains contain each other's instances, if the document has one.
pub fn component_cycle(doc: &Document) -> Option<Vec<NodeId>> {
find_cycle(&graph(doc))
}
/// A loop as its names: `Card → Button → Card`.
fn cycle_names(doc: &Document, cycle: &[NodeId]) -> String {
cycle.iter().map(|u| doc.node(*u).map_or_else(|| u.to_string(), |n| format!("\"{}\"", n.name))).collect::<Vec<_>>().join(" → ")
}
/// Whether an instance of `component` under `parent` would contain itself: `parent` is (in) a
/// main that instance shows, directly or through other components.
pub fn would_contain_itself(doc: &Document, component: NodeId, parent: NodeId) -> bool {
let hosts = hosts(doc, parent);
if hosts.is_empty() {
return false;
}
let g = graph(doc);
let mut seen = BTreeSet::new();
let mut todo = vec![component];
while let Some(u) = todo.pop() {
if hosts.contains(&u) {
return true;
}
if seen.insert(u) {
todo.extend(g.get(&u).into_iter().flatten().copied());
}
}
false
}
/// Why an instance of `component` can't go where it would contain itself.
pub fn contains_itself_error(doc: &Document, component: NodeId) -> String {
let name = doc.node(component).map(|n| n.name.clone()).unwrap_or_default();
format!("an instance of \"{name}\" can't go inside \"{name}\" or a component it contains: a component can't contain itself")
}
/// The loop `after` has and `before` didn't, if layers `touched` (edited, added or removed) closed
/// one: only layers in main components can.
pub fn new_cycle<'a>(before: &Document, after: &Document, mut touched: impl Iterator<Item = &'a NodeId>) -> Option<String> {
if !touched.any(|d| !hosts(after, *d).is_empty()) {
return None;
}
cycle_text(after).filter(|_| component_cycle(before).is_none())
}
/// The document's loop of components containing each other's instances ([`component_cycle`]),
/// as its names.
pub fn cycle_text(doc: &Document) -> Option<String> {
component_cycle(doc).map(|c| cycle_names(doc, &c))
}
/// Refuse an edit that makes components contain each other's instances (it is rolled back). A
/// document that already held a loop stays editable.
pub fn check_component_cycles(before: &Document, after: &Document) -> Result<(), String> {
match new_cycle(before, after, after.dirty().iter()) {
Some(names) => Err(format!("a component can't contain an instance of itself, directly or through other components: {names}")),
None => Ok(()),
}
}
/// The instance to detach to cut `from → to` out of a loop: one in a main of `from` showing `to`,
/// preferably one of the main's own (not a layer of another instance, else its outer instance).
fn cut_point(doc: &Document, from: NodeId, to: NodeId) -> Option<NodeId> {
let found: Vec<NodeId> = doc
.nodes()
.filter(|n| matches!(n.kind, NodeKind::Instance { .. }))
.map(|n| n.id)
.filter(|i| shown(doc, *i).contains(&to) && hosts(doc, *i).contains(&from))
.collect();
found.iter().copied().find(|i| !inside_instance(doc, *i)).or_else(|| found.first().and_then(|i| root_instance(doc, *i)))
}
/// Detach instance `top` and every instance inside it: its layers stay as they are, linked to no
/// main.
fn detach_all(doc: &mut Document, top: NodeId) {
for d in doc.subtree(top) {
let modes = match doc.node(d).map(|n| &n.kind) {
Some(NodeKind::Instance { component, .. }) => doc.node(*component).map(|m| m.var_modes.clone()),
_ => None,
};
let Some(n) = doc.node_mut(d) else { continue };
if let Some(modes) = modes {
n.kind = NodeKind::Frame;
n.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
for (c, m) in modes {
n.var_modes.entry(c).or_insert(m);
}
}
n.source = None;
n.overrides.clear();
n.prop_refs.clear();
}
}
/// Break every loop of components containing each other's instances (a file from elsewhere, or
/// written before they were refused): an instance closing each loop is detached, and the instances
/// of its main rebuild without it. Returns a note for each.
pub fn break_component_cycles(doc: &mut Document) -> Vec<String> {
let mut notes = Vec::new();
// Each pass detaches an instance: never more passes than there are layers.
for _ in 0..doc.nodes().count() {
let Some(cycle) = component_cycle(doc) else { break };
let (Some(from), Some(to)) = (cycle.iter().rev().nth(1).copied(), cycle.last().copied()) else { break };
let Some(cut) = cut_point(doc, from, to) else { break };
let name = doc.node(cut).map(|n| n.name.clone()).unwrap_or_default();
detach_all(doc, cut);
rebuild_instances(doc, &BTreeSet::from([from]));
notes.push(format!("\"{name}\" was detached: components can't contain their own instances ({})", cycle_names(doc, &cycle)));
}
notes
}
/// The layers of `component` an instance `inst` of it copies, in pre-order: all of them but those
/// inside a nested instance of a component being expanded already (its own, or a main `inst` is
/// in), which would show itself again. `None` when `inst` itself is in its own main.
pub(super) fn expansion_layers(doc: &Document, inst: NodeId, component: NodeId) -> Option<Vec<NodeId>> {
let mut stops = hosts(doc, inst);
if !stops.insert(component) {
return None;
}
let repeats = |kind: &NodeKind| matches!(kind, NodeKind::Instance { component: c, .. } if stops.contains(c));
let mut out = Vec::new();
let mut stack: Vec<NodeId> = doc.node(component).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
while let Some(id) = stack.pop() {
let Some(n) = doc.node(id) else { continue };
out.push(id);
if out.len() > 5_000_000 {
break;
}
if !repeats(&n.kind) {
stack.extend(n.children.iter().rev());
}
}
Some(out)
}
@@ -1,3 +1,4 @@
use super::cycles::{contains_itself_error, would_contain_itself};
use super::diff::{binding_group, bindings_of};
use super::sync::{insert_linked, sync_instance_depth};
use super::{
@@ -16,8 +17,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
if !matches!(comp.kind, NodeKind::Component { .. }) {
return Err(DocError::Invalid(format!("{component} is not a component")));
}
if doc.is_ancestor(component, parent) {
return Err(DocError::Invalid("an instance can't be placed inside its own main component".into()));
if would_contain_itself(doc, component, parent) {
return Err(DocError::Invalid(contains_itself_error(doc, component)));
}
let id = doc.alloc_id();
let mut root = comp.clone();
+1 -1
View File
@@ -500,7 +500,7 @@ fn sized_own(n: &Node) -> bool {
/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
/// outwards), each outermost instance once, inner components first.
fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
pub(super) fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
if comps.is_empty() {
return;
}
+101 -60
View File
@@ -1,3 +1,4 @@
use super::cycles::expansion_layers;
use super::diff::diff_groups;
use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map};
use super::{
@@ -23,26 +24,76 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
// Old layers (kept alive for their overrides), by (parent, source), by source alone (when only
// one layer mirrors it) and, only when those don't match, by name path.
let (mut old, by_source, by_src) = index_old_layers(doc, inst, spare);
// Never a copy of itself inside it (components containing each other).
let Some(src) = expansion_layers(doc, inst, component) else { return };
let Matched { mut map, used, edited, fresh } = match_layers(doc, inst, component, &src, &mut old, &by_source, &by_src);
for c in root.children.clone() {
let _ = doc.remove(c);
}
for s in fresh {
let nid = doc.alloc_id();
map.insert(s, nid);
}
let Copies { nodes: new_nodes, nested, mut remeasure } = copy_layers(doc, inst, &src, &map, &old, &edited, eff_props);
for n in new_nodes {
let (id, parent) = (n.id, n.parent);
if insert_linked(doc, n).is_err()
&& let Some(p) = parent.and_then(|p| doc.node_mut(p))
{
p.children.retain(|c| *c != id);
}
}
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
if let Some(r) = doc.node_mut(inst) {
*r = fresh;
}
// A resized instance: children follow their constraints (auto layout re-flows later).
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
remeasure = map.values().copied().collect();
}
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
remeasure_text(doc, remeasure);
rebuild_nested(doc, nested, &old, &used, depth);
}
/// Which instance layer each main layer a rebuild copies becomes.
struct Matched {
/// Main layer → its copy's id (the matching old layer's, so a layer the main moved to another
/// parent keeps its id and overrides); the main itself → the instance.
map: BTreeMap<NodeId, NodeId>,
/// The old layers reused.
used: BTreeSet<NodeId>,
/// Slots holding the instance's own content (their main layers' default content isn't copied).
edited: BTreeSet<NodeId>,
/// Main layers no old layer matches: their copies get new ids.
fresh: Vec<NodeId>,
}
/// Match the main layers `src` of instance `inst`'s main `component` to its old layers: by
/// (parent, source), by source alone (when only one layer mirrors it) and, only when those don't
/// match, by name path. Old layers matched across variants drop what the two variants differ in.
fn match_layers(
doc: &Document,
inst: NodeId,
component: NodeId,
src: &[NodeId],
old: &mut BTreeMap<NodeId, std::sync::Arc<Node>>,
by_source: &BTreeMap<(NodeId, NodeId), NodeId>,
by_src: &BTreeMap<NodeId, Option<NodeId>>,
) -> Matched {
let mut by_path: Option<BTreeMap<String, NodeId>> = None;
let mut main_paths: Option<BTreeMap<NodeId, String>> = None;
// Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
// parent keeps its id and overrides).
let src: Vec<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
map.insert(component, inst);
let mut used: BTreeSet<NodeId> = BTreeSet::new();
// Slots holding the instance's own content (new ids), and main layers not copied: the
// default content of those slots.
let mut edited: BTreeSet<NodeId> = BTreeSet::new();
let mut m = Matched { map: BTreeMap::from([(component, inst)]), used: BTreeSet::new(), edited: BTreeSet::new(), fresh: Vec::new() };
let mut skipped: BTreeSet<NodeId> = BTreeSet::new();
let mut fresh_ids = Vec::new();
for s in &src {
for s in src {
let Some(sn) = doc.node(*s) else { continue };
if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) {
if sn.parent.is_some_and(|p| skipped.contains(&p) || m.map.get(&p).is_some_and(|pn| m.edited.contains(pn))) {
skipped.insert(*s);
continue;
}
let parent_new = sn.parent.and_then(|p| map.get(&p).copied());
let parent_new = sn.parent.and_then(|p| m.map.get(&p).copied());
let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten());
if found.is_none() {
let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect());
@@ -50,37 +101,47 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
found = mains.get(s).and_then(|p| paths.get(p)).copied();
}
let nid = match found {
Some(o) if used.insert(o) => o,
Some(o) if m.used.insert(o) => o,
_ => {
fresh_ids.push(*s);
m.fresh.push(*s);
continue;
}
};
if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
old.insert(nid, std::sync::Arc::new(m));
if let Some(kept) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
old.insert(nid, std::sync::Arc::new(kept));
}
if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) {
edited.insert(nid);
m.edited.insert(nid);
}
map.insert(*s, nid);
m.map.insert(*s, nid);
}
for c in root.children.clone() {
let _ = doc.remove(c);
}
for s in fresh_ids {
let nid = doc.alloc_id();
map.insert(s, nid);
}
// Build the copies (pre-order: parents first).
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
enclosing.insert(inst, vec![inst]);
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::new();
inst_props.insert(inst, eff_props);
let mut nested = Vec::new();
let mut new_nodes = Vec::new();
// Text layers showing other text or style than their main layer (to re-measure).
let mut remeasure = Vec::new();
for s in &src {
m
}
/// A rebuild's copies of the main's layers, pre-order (parents first).
struct Copies {
nodes: Vec<Node>,
/// Nested instances showing another main than their main layer (swapped): rebuilt from it.
nested: Vec<NodeId>,
/// Text layers showing other text or style than their main layer (to re-measure).
remeasure: Vec<NodeId>,
}
/// Copy main layers `src` into instance `inst` as `map` names them, with the overrides of the
/// `old` layers they replace and the component property values (`props`: the instance's).
fn copy_layers(
doc: &Document,
inst: NodeId,
src: &[NodeId],
map: &BTreeMap<NodeId, NodeId>,
old: &BTreeMap<NodeId, std::sync::Arc<Node>>,
edited: &BTreeSet<NodeId>,
props: BTreeMap<String, Value>,
) -> Copies {
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::from([(inst, vec![inst])]);
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::from([(inst, props)]);
let mut out = Copies { nodes: Vec::new(), nested: Vec::new(), remeasure: Vec::new() };
for s in src {
let Some(orig) = doc.node(*s) else { continue };
let Some(&nid) = map.get(s) else { continue };
let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue };
@@ -113,7 +174,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
e.push(nid);
enclosing.insert(nid, e);
if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) {
nested.push(nid);
out.nested.push(nid);
}
} else {
enclosing.insert(nid, enc);
@@ -121,31 +182,11 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind)
&& a != b
{
remeasure.push(nid);
out.remeasure.push(nid);
}
new_nodes.push(n);
out.nodes.push(n);
}
for n in new_nodes {
let (id, parent) = (n.id, n.parent);
if insert_linked(doc, n).is_err()
&& let Some(p) = parent.and_then(|p| doc.node_mut(p))
{
p.children.retain(|c| *c != id);
}
}
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
if let Some(r) = doc.node_mut(inst) {
*r = fresh;
}
// A resized instance: children follow their constraints (auto layout re-flows later).
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
remeasure = map.values().copied().collect();
}
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
remeasure_text(doc, remeasure);
rebuild_nested(doc, nested, &old, &used, depth);
out
}
/// Geometry overrides of children win over constraints.
+1
View File
@@ -344,6 +344,7 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
let pages: Vec<Value> =
doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect();
json!({
"id": ds.uid,
"name": doc.name,
"kind": doc.kind.id(),
"path": ds.path,
@@ -177,3 +177,76 @@ fn reattach_survives_junk() {
assert!(matches!(node(&s, k).board, Some(BoardItem::Connector(_))));
assert!(drawn(&s, k).iter().all(|p| p.x.is_finite() && p.y.is_finite()));
}
/// The connector on the page, if any (the one being drawn shows in the document).
fn the_connector(s: &Session) -> Option<figcraft_doc::Node> {
s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).cloned()
}
#[test]
fn the_connector_being_drawn_is_the_one_made() {
for style in ["straight", "elbow", "curved"] {
for to in [Point::new(350.0, 250.0), Point::new(700.0, 600.0)] {
let mut s = board();
let mut shape = |x: f64, y: f64| id(&s.execute("board.shape", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(), "id");
let (_, b) = (shape(0.0, 0.0), shape(300.0, 200.0));
s.execute("tool.connector", &json!({"style": style})).unwrap();
let undo = s.undo_depth().0;
let ev = |k, p: Point| PointerEvent::at(k, p.x, p.y, 1.0);
s.pointer(&ev(PointerKind::Down, Point::new(50.0, 50.0))).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(200.0, 150.0))).unwrap();
s.pointer(&ev(PointerKind::Move, to)).unwrap();
let drawing = the_connector(&s).unwrap_or_else(|| panic!("{style}: the connector shows while dragged"));
let ov = &s.tool.overlay;
if to.x < 400.0 {
// Over b: its four attach points, the one the end is at lit, and b outlined.
assert_eq!(ov.drop_target, Some(b));
let lit: Vec<[f64; 2]> = ov.attach.iter().filter(|a| a.1).map(|a| a.0).collect();
let end = *drawn(&s, drawing.id).last().unwrap();
assert_eq!((ov.attach.len(), lit), (4, vec![[end.x, end.y]]), "{style}");
} else {
assert!(ov.attach.is_empty() && ov.drop_target.is_none(), "a free end");
}
assert!(s.tool.overlay.rubber.is_none(), "no rubber band: the connector itself shows");
s.pointer(&ev(PointerKind::Up, to)).unwrap();
let made = the_connector(&s).unwrap();
assert_eq!(made.kind, drawing.kind, "{style} to {to:?}: the same path");
assert_eq!((made.transform, made.width, made.height), (drawing.transform, drawing.width, drawing.height));
assert_eq!((&made.strokes, &made.stroke, &made.board), (&drawing.strokes, &drawing.stroke, &drawing.board), "line, caps and ends");
assert_eq!(s.undo_depth().0, undo + 1, "one undo step");
assert!(s.tool.overlay.attach.is_empty());
}
}
// Escape mid-drag: the preview goes and nothing is made.
let mut s = board();
s.execute("tool.connector", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 200.0, 0.0, 1.0)).unwrap();
assert!(the_connector(&s).is_some());
s.escape().unwrap();
assert!(the_connector(&s).is_none());
}
#[test]
fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() {
let mut s = board();
let mut shape = |x: f64, y: f64| id(&s.execute("board.shape", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(), "id");
let (a, b) = (shape(0.0, 0.0), shape(300.0, 200.0));
s.execute("tool.connector", &json!({"style": "straight"})).unwrap();
let ev = |k, x, y| PointerEvent::at(k, x, y, 1.0);
// Hovering: the object's attach points, lit only near one.
s.pointer(&ev(PointerKind::Hover, 50.0, 50.0)).unwrap();
assert_eq!(s.tool.overlay.attach.len(), 4);
assert!(s.tool.overlay.attach.iter().all(|a| !a.1));
s.pointer(&ev(PointerKind::Hover, 50.0, 3.0)).unwrap();
assert_eq!(s.tool.overlay.attach.iter().filter(|a| a.1).map(|a| a.0).collect::<Vec<_>>(), vec![[50.0, 0.0]]);
s.pointer(&ev(PointerKind::Hover, 900.0, 900.0)).unwrap();
assert!(s.tool.overlay.attach.is_empty());
// From near a's right middle to near b's top middle: those sides, kept.
s.pointer(&ev(PointerKind::Down, 97.0, 50.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 352.0, 204.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 352.0, 204.0)).unwrap();
let k = the_connector(&s).unwrap().id;
assert_eq!((connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Right }, End::Node { node: b, side: Side::Top }));
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]);
}
+1 -1
View File
@@ -207,7 +207,7 @@ fn the_shape_stamp_highlighter_and_connector_tools_and_double_clicks() {
click(&mut s, 100.0, 100.0);
s.pointer(&PointerEvent::at(PointerKind::Down, 60.0, 120.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 140.0, 1.0)).unwrap();
assert!(s.tool.overlay.rubber.is_some(), "the rubber band shows while dragging");
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))), "nothing previewed: none would be made");
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 140.0, 1.0)).unwrap();
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))));
// From the shape out to empty canvas: a connector to a free point.
+29 -12
View File
@@ -48,11 +48,26 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => routing_of(cmd, &v)?,
None => s.tool.board.routing,
};
let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None);
let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow);
let c = Connector { from, to, routing };
let start = cap_of(cmd, p, "startCap")?.unwrap_or(CAPS.0);
let end = cap_of(cmd, p, "endCap")?.unwrap_or(CAPS.1);
let page = s.page()?;
let doc = s.doc_mut()?;
let id = make(doc, page, Connector { from, to, routing }, (start, end))?;
let mut out = json!({"id": id});
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
out["label"] = json!(set_label(doc, id, &text)?);
}
s.set_selection(vec![id]);
Ok(out)
}
/// A new connector's (start, end) caps.
pub(super) const CAPS: (StrokeCap, StrokeCap) = (StrokeCap::None, StrokeCap::TriangleArrow);
/// Connector `c` added to `page` with its line, `caps` (start, end) and route: what
/// `board.connector` makes, and the connector tool shows while it is dragged.
pub(super) fn make(doc: &mut Document, page: NodeId, c: Connector, caps: (StrokeCap, StrokeCap)) -> Result<NodeId> {
let cmd = "board.connector";
let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?;
if pts.windows(2).all(|w| w.first() == w.get(1)) {
return Err(bad(cmd, "the ends are in the same place"));
@@ -62,17 +77,11 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
n.strokes = vec![Paint::solid(LINE)];
n.stroke.weight = LINE_WEIGHT;
n.stroke.join = StrokeJoin::Round;
n.stroke.cap_start = start;
n.stroke.cap_end = end;
(n.stroke.cap_start, n.stroke.cap_end) = caps;
n.board = Some(BoardItem::Connector(c));
})?;
place_route(doc, id, &pts, routing);
let mut out = json!({"id": id});
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
out["label"] = json!(set_label(doc, id, &text)?);
}
s.set_selection(vec![id]);
Ok(out)
place_route(doc, id, &pts, c.routing);
Ok(id)
}
/// The connector `id` names: a connector, or the label on one.
@@ -121,6 +130,14 @@ pub(super) fn end_json(e: End) -> Value {
}
}
/// The side a connector end names, as the commands take it (none for a free point).
pub(super) fn side_json(e: End) -> Value {
match e {
End::Node { side, .. } => json!(side),
End::Point { .. } => Value::Null,
}
}
/// The end of connector `id` that stays while its other one (`from`: its start) is dragged.
pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option<End> {
match &doc.node(id)?.board {
+82 -32
View File
@@ -1,12 +1,18 @@
use super::connectors::{end_json, other_end};
use super::derived::ends_now;
use super::connectors::{CAPS, end_json, make, other_end, side_json};
use super::derived::{ends_now, route};
use super::edit::label_of;
use super::items::edit_text;
use super::{
BoardItem, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId, id_of,
is_board, json,
BoardItem, Connector, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId,
id_of, is_board, json,
};
/// How near (screen pixels) the pointer must be to the middle of an object's side for a connector
/// end to attach there rather than to the side facing the other end.
const ATTACH_PX: f64 = 10.0;
/// The sides an object takes a connector end at (their middles are its attach points).
const SIDES: [Side; 4] = [Side::Top, Side::Right, Side::Bottom, Side::Left];
// ---------------------------------------------------------------------------------------------
// The board tools.
@@ -51,7 +57,8 @@ impl Session {
ToolId::Connector => {
let from = self.end_at(ev);
self.tool.gesture = Gesture::Connect { from, start: ev.p() };
Ok(())
// While it is dragged the connector shows as it will be made (a preview).
self.begin_interaction("Connector")
}
ToolId::Eraser => {
self.begin_interaction("Erase")?;
@@ -69,47 +76,87 @@ impl Session {
}
}
/// What a connector end at the pointer attaches to: the object there, or the point.
/// What a connector end at the pointer attaches to: the object there (at the side whose middle
/// is within [`ATTACH_PX`], else the side facing the other end), or the point.
fn end_at(&self, ev: &PointerEvent) -> End {
let zoom = ev.zoom.max(1e-6);
let hit = (|| {
let doc = self.doc().ok()?;
let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?;
let ds = self.ds().ok()?;
// Under the connector being dragged out (previewed in the document), not on it.
let doc = ds.interaction.as_ref().map_or(&*ds.doc, |it| &*it.doc);
let leaf = self.hit(doc, ds.page, ev.p(), 2.0 / zoom)?;
let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false);
let n = doc.node(t)?;
(!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t)
if matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink)) {
return None;
}
let b = doc.world_bounds(t);
let far = |s: &Side| b.map(|b| (s.point(b) - ev.p()).hypot() * zoom).filter(|d| *d <= ATTACH_PX);
let side = SIDES.iter().filter_map(|s| Some((*s, far(s)?))).min_by(|x, y| x.1.total_cmp(&y.1)).map_or(Side::Auto, |(s, _)| s);
Some(End::Node { node: t, side })
})();
match hit {
Some(node) => End::Node { node, side: Side::Auto },
None => End::Point { x: ev.x, y: ev.y },
}
hit.unwrap_or(End::Point { x: ev.x, y: ev.y })
}
pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) {
let a = match from {
End::Point { x, y } => Point::new(x, y),
End::Node { .. } => start,
/// Where a connector dragged from `from` (pressed at `start`) to the pointer ends: none for a
/// click, or back on the object it started from (nothing is made).
fn drag_end(&self, ev: &PointerEvent, from: End, start: Point) -> Option<End> {
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return None;
}
let to = self.end_at(ev);
(to.node().is_none() || to.node() != from.node()).then_some(to)
}
/// The object `end` is on (if any) outlined, with its attach points; the one the end is at
/// (`at`, else its named side's) lit.
fn show_attach(&mut self, end: Option<End>, at: Option<Point>) {
let attach = match end {
Some(End::Node { node, side }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| {
let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b)));
SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec()
}),
_ => None,
};
self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]);
self.tool.overlay.drop_target = from.node();
self.tool.overlay.attach = attach.unwrap_or_default();
self.tool.overlay.drop_target = end.and_then(|e| e.node());
}
/// The pointer moving with the connector tool and no button down: the attach points under it.
pub(crate) fn connector_hover(&mut self, ev: &PointerEvent) {
let end = self.doc().is_ok_and(is_board).then(|| self.end_at(ev));
self.show_attach(end, None);
}
/// The connector being dragged out, drawn as it will be made (a preview) to the attach point
/// or free point under the pointer.
pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) {
self.tool.gesture = Gesture::Connect { from, start };
let c = self.drag_end(ev, from, start).map(|to| Connector { from, to, routing: self.tool.board.routing });
if let Ok(page) = self.page() {
// One that can't be placed shows nothing, as none would be made.
let _ = self.preview(|d, _| {
if let Some(c) = c {
let _ = make(d, page, c, CAPS);
}
Ok(())
});
}
let at = c.and_then(|c| route(self.doc().ok()?, &c)).and_then(|pts| pts.last().copied());
self.show_attach(c.map(|c| c.to), at);
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
}
pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> {
self.tool.overlay.rubber = None;
self.tool.overlay.drop_target = None;
// A click, not a drag: nothing to join.
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return Ok(());
}
let to = self.end_at(ev);
if to.node().is_some() && to.node() == from.node() {
return Ok(());
}
self.show_attach(None, None);
// The preview goes: the command makes the connector, one undo step.
self.cancel_interaction()?;
let Some(to) = self.drag_end(ev, from, start) else { return Ok(()) };
let style = self.tool.board.routing.id();
self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?;
let p = json!({"from": end_json(from), "fromSide": side_json(from), "to": end_json(to), "toSide": side_json(to), "style": style});
self.execute("board.connector", &p)?;
self.board_placed();
Ok(())
}
@@ -122,7 +169,8 @@ impl Session {
let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a });
let other = other_end(doc, id, from).and_then(|e| e.node());
self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]);
self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other);
let target = Some(self.end_at(ev)).filter(|e| e.node().is_none() || e.node() != other);
self.show_attach(target, None);
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
@@ -132,6 +180,7 @@ impl Session {
/// A click, or a drop on the object the other end is on, changes nothing.
pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> {
self.tool.overlay.rubber = None;
self.show_attach(None, None);
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return Ok(());
}
@@ -140,7 +189,8 @@ impl Session {
if target.node().is_some() && target.node() == other {
return Ok(());
}
self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?;
let end = if from { "from" } else { "to" };
self.execute("board.reattach", &json!({"id": id, "end": end, "target": end_json(target), "side": side_json(target)}))?;
Ok(())
}
+3
View File
@@ -336,6 +336,9 @@ pub struct Overlay {
pub rubber_handle: Option<[f64; 2]>,
/// Frame/section the pointer is drawing or dropping into (highlighted).
pub drop_target: Option<NodeId>,
/// Connector tool: the attach points (side middles, page x, y) of the object under the pointer,
/// and whether the connector end is at each.
pub attach: Vec<([f64; 2], bool)>,
/// Auto layout insertion indicator (x0, y0, x1, y1).
pub insert_line: Option<[f64; 4]>,
/// The instance an Alt-drop of a component from the Assets panel would swap (highlighted).
+1 -8
View File
@@ -546,15 +546,8 @@ impl Session {
}
let Some(data) = doc.images.get(&img.image) else { continue };
let (iw, ih) = (f64::from(data.width), f64::from(data.height));
let crop = if img.scale_mode == ImageScaleMode::Crop {
img.effective_crop(iw, ih, w, h)
} else {
let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0));
let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) };
[w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)]
};
// A degenerate (zero-scale) layer has no inverse: its image keeps its placement.
let crop = (Affine::new(crop) * to_old).as_coeffs();
let crop = (Affine::new(img.placement_crop(iw, ih, w, h)) * to_old).as_coeffs();
if !crop.iter().all(|c| c.is_finite()) {
continue;
}
@@ -257,6 +257,9 @@ impl Session {
}
}
self.tool.overlay.guides = guides;
if tool == ToolId::Connector {
self.connector_hover(ev);
}
let ds = self.ds_mut()?;
if ds.hover != hover {
ds.hover = hover;
+3 -1
View File
@@ -151,6 +151,8 @@ pub static MENU: &[MenuItem] = &[
m!("Object", "Use as mask", "object.mask"),
m!("Object", "Release mask", "object.releaseMask"),
m!("Object", "Set as thumbnail", "object.setThumbnail"),
// FigCraft's own (in `OWN`): the way into crop mode that the Design panel also offers.
m!("Object", "Crop image", "image.crop"),
m!("Object", SEP, ""),
m!("Object", "Bring to front", "arrange.front"),
m!("Object", "Bring forward", "arrange.forward"),
@@ -290,7 +292,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
}
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
const OWN: [&str; 4] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts"];
const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"];
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
+65 -18
View File
@@ -1,12 +1,13 @@
//! Comments: add, reply, edit, react, resolve, read state, delete, find and reveal comment threads.
//!
//! Messages are addressed by `reply` (an index into the replies; omitted: the thread's first
//! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only
//! a message's author edits or deletes it, and only the thread's author deletes the thread.
//! message). `author` is who is acting (default: the AI agent the command runs for, else the name
//! from Preferences, `pref.userName`): only a message's author edits or deletes it, and only the
//! thread's author deletes the thread.
use std::fmt::Write as _;
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom};
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom, mentions};
use serde_json::{Value, json};
use super::{CommandSpec, ok};
@@ -16,6 +17,12 @@ use crate::{Session, bad, spec};
/// The name comments are signed with until the user sets theirs.
pub const DEFAULT_AUTHOR: &str = "You";
/// The name of an AI agent that gave none.
pub const DEFAULT_AGENT: &str = "AI agent";
/// Most characters of an agent's own name in its comments.
const MAX_AGENT_NAME: usize = 64;
/// Links to a thread: `figcraft://comment/<id>`.
const LINK_PREFIX: &str = "figcraft://comment/";
@@ -260,9 +267,27 @@ pub fn user_name(s: &Session) -> String {
if n.is_empty() { DEFAULT_AUTHOR.into() } else { n.to_string() }
}
/// Who is acting: `author` from the params, else the user.
/// How an AI agent's comments are signed: its client's name, marked so it never reads as a
/// person's ("Claude" → "Claude (agent)"), or [`DEFAULT_AGENT`] when it gave none.
pub fn agent_author(name: &str) -> String {
let n: String = name.chars().filter(|c| !c.is_control()).collect();
let n: String = n.trim().chars().take(MAX_AGENT_NAME).collect();
if n.is_empty() { DEFAULT_AGENT.into() } else { format!("{n} (agent)") }
}
/// Who acts when no `author` is given: the AI agent the command runs for, else the user.
pub fn acting_name(s: &Session) -> String {
s.agent.clone().unwrap_or_else(|| user_name(s))
}
/// Who is acting: `author` from the params, else [`acting_name`].
fn author(s: &Session, p: &Value) -> String {
string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| user_name(s))
string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| acting_name(s))
}
/// A new message by `who`, marked as an agent's when the command runs for one.
fn new_message(s: &Session, who: String, text: String) -> Message {
Message { agent: s.agent.is_some(), ..Message::new(who, text, now_secs()) }
}
fn text(cmd: &str, p: &Value) -> crate::Result<String> {
@@ -301,25 +326,42 @@ fn local(doc: &Document, anchor: Option<NodeId>, x: f64, y: f64) -> (f64, f64) {
}
}
fn message_json(m: &Message) -> Value {
/// The names an @mention resolves to: the file's people (`Document::comment_people`) and `user`,
/// as the thread view highlights them.
fn mentionable<'a>(people: &'a [String], user: &'a str) -> Vec<&'a str> {
people.iter().map(String::as_str).chain([user]).collect()
}
/// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against
/// `people` (`mentionable`); `known: false` is a name nobody in the file has.
fn message_json(m: &Message, people: &[&str], user: &str) -> Value {
let mut named: Vec<Value> = Vec::new();
for (r, who) in mentions(&m.text, people) {
let name = who.or_else(|| m.text.get(r.start + 1..r.end)).unwrap_or_default();
let v = json!({"name": name, "known": who.is_some(), "you": who.is_some_and(|w| w.eq_ignore_ascii_case(user))});
if !named.contains(&v) {
named.push(v);
}
}
json!({
"author": m.author, "text": m.text, "created": m.created, "edited": m.edited,
"author": m.author, "agent": m.agent, "text": m.text, "created": m.created, "edited": m.edited,
"reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::<Vec<_>>(),
"mentions": named,
})
}
/// JSON view of a comment as `user` sees it: its page position, frame, read state and link, the
/// first message's fields, and the replies.
pub fn comment_json(doc: &Document, c: &Comment, user: &str) -> Value {
/// first message's fields, and the replies; mentions resolve against `people` (`mentionable`).
pub fn comment_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) -> Value {
let (x, y) = c.position(doc);
let frame = frame_of(doc, c);
let mut v = message_json(&c.first);
let mut v = message_json(&c.first, people, user);
let extra = json!({
"id": c.id, "page": c.page, "pageName": doc.node(c.page).map(|n| n.name.as_str()),
"x": x, "y": y, "anchor": c.anchor,
"frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| n.name.as_str()),
"resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id),
"replies": c.replies.iter().map(message_json).collect::<Vec<_>>(),
"replies": c.replies.iter().map(|m| message_json(m, people, user)).collect::<Vec<_>>(),
});
if let (Some(m), Value::Object(e)) = (v.as_object_mut(), extra) {
m.extend(e);
@@ -338,7 +380,7 @@ fn comment_brief_json(doc: &Document, c: &Comment, user: &str) -> Value {
"x": x, "y": y, "anchor": c.anchor,
"frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| clip_chars(&n.name, 80)),
"resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id),
"author": clip_chars(&c.first.author, 80), "preview": clip_chars(&c.first.text, 160),
"author": clip_chars(&c.first.author, 80), "agent": c.first.agent, "preview": clip_chars(&c.first.text, 160),
"replies": c.replies.len(),
})
}
@@ -385,7 +427,8 @@ fn open(s: &mut Session, id: u64, how: &str, who: &str) -> crate::Result<Value>
s.request_ui("comment.open", json!({"id": id}));
let doc = s.doc()?;
let c = doc.comment(id).ok_or_else(|| bad("comment.open", "no comment"))?;
Ok(comment_json(doc, c, who))
let people = doc.comment_people();
Ok(comment_json(doc, c, who, &mentionable(&people, who)))
}
/// A list's page (None: every page), filter, sort and search text from params.
@@ -565,7 +608,7 @@ pub fn specs() -> Vec<CommandSpec> {
x: lx,
y: ly,
anchor,
first: Message::new(who.clone(), text, now_secs()),
first: new_message(s, who.clone(), text),
resolved: false,
replies: Vec::new(),
read_by: None,
@@ -579,7 +622,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("comment.reply", "Reply to comment", "{id | link, text, author?}", true, |s, p| {
let id = req_comment("comment.reply", s, p)?;
let who = author(s, p);
let r = Message::new(who.clone(), text("comment.reply", p)?, now_secs());
let r = new_message(s, who.clone(), text("comment.reply", p)?);
let c = s.doc_mut()?.comment_mut(id).ok_or_else(|| bad("comment.reply", "no comment"))?;
c.replies.push(r);
c.resolved = false;
@@ -758,7 +801,9 @@ pub fn specs() -> Vec<CommandSpec> {
let id = req_comment("comment.link", s, p)?;
Ok(json!({"link": link(id)}))
}),
spec!("comment.people", "People in comments", "{}", false, |s, _| { Ok(json!({"people": s.doc()?.comment_people(), "you": user_name(s)})) }),
spec!("comment.people", "People in comments", "{}", false, |s, _| {
Ok(json!({"people": s.doc()?.comment_people(), "you": acting_name(s)}))
}),
spec!(
"comment.list",
"List comments",
@@ -769,7 +814,9 @@ pub fn specs() -> Vec<CommandSpec> {
let (page, filter, sort, text) = list_params("comment.list", s, p)?;
let who = author(s, p);
let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text };
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who)).collect();
let people = doc.comment_people();
let people = mentionable(&people, &who);
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who, &people)).collect();
Ok(json!({"comments": list}))
}
),
@@ -817,7 +864,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
Some(_) => return Err(bad("comment.prompt", "`ids` must be an array of comment ids")),
};
let who = user_name(s);
let who = acting_name(s);
let selected: Vec<&Comment> = if let Some(ids) = ids {
let mut out = Vec::with_capacity(ids.len());
for id in ids {
+2 -2
View File
@@ -292,8 +292,8 @@ pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc_mut()?;
let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?;
let mut slot = crate::drop::slot(doc, page, pt);
// Never inside its own main component (or a copy of it).
if doc.is_ancestor(c, slot.parent) || slot.parent == c {
// Never inside its own main component, or one it contains (directly or through others).
if crate::components::would_contain_itself(doc, c, slot.parent) {
slot = crate::drop::DropSlot { parent: page, index: None, line: None };
}
let (w, h) = doc.node(c).map(|n| (n.width, n.height)).unwrap_or((0.0, 0.0));
+2 -2
View File
@@ -376,8 +376,8 @@ pub(super) fn swap(s: &mut Session, p: &Value) -> Result<Value> {
/// Make instance `i` show main component `c`, keeping the values of properties with the same names
/// (the layers' overrides follow by name when the instance is rebuilt).
pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> {
if doc.is_ancestor(c, i) {
return Err("an instance can't show the component it is inside".into());
if crate::components::would_contain_itself(doc, c, i) {
return Err(crate::components::contains_itself_error(doc, c));
}
let defaults = doc.node(c).map(default_props).unwrap_or_default();
match doc.get_mut(i).map(|n| &mut n.kind) {
+6 -1
View File
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"edit.duplicate",
"Duplicate",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place), map? (true: also return `map`, each original layer's id, descendants included, → its copy's id)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
true,
duplicate
),
@@ -632,6 +632,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false);
s.set_selection(out.clone());
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
if boolean(p, "map") == Some(true) {
// JSON object keys are strings: each original layer's id → its copy's.
let map: Map<String, Value> = map.into_iter().map(|(o, c)| (o.to_string(), json!(c))).collect();
return Ok(json!({"ids": out, "map": map}));
}
Ok(json!({"ids": out}))
}
+10 -4
View File
@@ -318,15 +318,21 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("doc.nextTab", "Next tab", "{}", false, |s, _| cycle_doc(s, 1)),
spec!("doc.previousTab", "Previous tab", "{}", false, |s, _| cycle_doc(s, -1)),
spec!("doc.list", "List documents", "{}", false, |s, _| {
Ok(Value::Array(
spec!(
"doc.list",
"List documents",
"{} → [{index, id, name, path, dirty, active}]: `id` names the file in a control or MCP call's `document`",
false,
|s, _| {
Ok(Value::Array(
s.docs
.iter()
.enumerate()
.map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
.map(|(i, d)| json!({"index": i, "id": d.uid, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
.collect(),
))
}),
}
),
spec!("doc.home", "Home", "{}", false, |s, _| {
s.request_ui("home", json!({}));
ok()
+13
View File
@@ -70,11 +70,24 @@ pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_form
/// Load from bytes. Returns the document with its notes (repairs of a native file, or import
/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig`
/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON.
/// Components containing each other's instances are broken apart, with a note.
pub(super) fn load_with(
bytes: &[u8],
name_hint: &str,
variables: Option<&Value>,
images: figcraft_format::rest::ImageResolver,
) -> Result<(figcraft_format::Loaded, bool)> {
let (mut loaded, rest) = load_any(bytes, name_hint, variables, images)?;
let notes = crate::components::break_component_cycles(&mut loaded.doc);
loaded.issues.extend(notes);
Ok((loaded, rest))
}
fn load_any(
bytes: &[u8],
name_hint: &str,
variables: Option<&Value>,
images: figcraft_format::rest::ImageResolver,
) -> Result<(figcraft_format::Loaded, bool)> {
match ext(name_hint).as_str() {
"svg" => {
+46 -4
View File
@@ -1,12 +1,12 @@
//! Image paints: adjustment presets, built in and saved in the file.
//! Image paints: adjustment presets, built in and saved in the file, and Crop image.
use figcraft_color::{ImageFilters, PaintKind};
use figcraft_doc::{Document, ImagePreset};
use figcraft_color::{ImageFilters, ImageScaleMode, PaintKind};
use figcraft_doc::{Document, ImagePreset, Node};
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets};
use crate::{Result, Session, bad, spec};
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -34,6 +34,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!(
"image.crop",
"Crop image",
"{ids? (one layer), index? (an image fill; default its top visible one)} → {id, index}: the image fill switches to Crop, keeping the picture where it was, and the canvas enters crop mode on it",
true,
crop
),
spec!("image.presets", "Image presets", "{} → {builtIn: [{id, label, filters}], saved: [{name, filters}]}", false, |s, _| {
let built_in: Vec<Value> = ImageFilters::PRESETS.iter().map(|(id, label, f)| json!({"id": id, "label": label, "filters": f})).collect();
Ok(json!({"builtIn": built_in, "saved": s.doc()?.image_presets}))
@@ -135,6 +142,41 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"changed": changed}))
}
/// The index of a layer's top visible image fill (fills paint bottom to top).
pub fn top_image_fill(n: &Node) -> Option<usize> {
n.fills.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
}
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.crop";
let ids = req_targets(cmd, s, p)?;
// A selection with nothing to crop is refused as unavailable, not as bad input.
let refuse = |why: &str| EngineError::Disabled(cmd.into(), why.into());
let [id] = ids[..] else { return Err(refuse("select one layer to crop its image")) };
let doc = s.doc()?;
let n = doc.get(id)?;
let index = match get(p, "index") {
None | Some(Value::Null) => top_image_fill(n).ok_or_else(|| refuse("the layer has no image fill to crop"))?,
Some(_) => req_index(cmd, p, "index")?,
};
let mut paint = n.fills.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?;
let PaintKind::Image(img) = &mut paint.kind else { return Err(bad(cmd, "that fill is not an image")) };
// An image already in Crop mode only enters crop mode: no edit, no undo step.
if img.scale_mode != ImageScaleMode::Crop {
let data = doc.images.get(&img.image).ok_or_else(|| bad(cmd, "the image's data is missing"))?;
img.crop = Some(img.placement_crop(f64::from(data.width), f64::from(data.height), n.width, n.height));
img.scale_mode = ImageScaleMode::Crop;
let n = s.doc_mut()?.get_mut(id)?;
if let Some(f) = n.fills.get_mut(index) {
*f = paint;
}
// Like any paint edit, the layer leaves its paint style.
n.styles.fill = None;
}
s.request_ui(cmd, json!({"id": id, "index": index}));
Ok(json!({"id": id, "index": index}))
}
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.savePreset";
let name: String = req_string(cmd, p, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
+78
View File
@@ -0,0 +1,78 @@
//! Comments from an AI agent (#1414): a command run as an agent (`Session::execute_as`, which the
//! control channel's and MCP's `engine.execute {agent}` use) signs comments with the agent's own
//! name and marks them as an agent's; the user's own comments stay as they were.
use serde_json::{Value, json};
use crate::Session;
use crate::cmd::comment::agent_author;
use crate::live::initials;
fn as_agent(s: &mut Session, agent: &str, cmd: &str, p: Value) -> Value {
s.execute_as(Some(agent), cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn thread(s: &mut Session, agent: Option<&str>) -> Value {
s.execute_as(agent, "comment.list", &json!({"filter": "all"})).unwrap()["comments"][0].clone()
}
#[test]
fn an_agents_comments_carry_its_name_and_the_agent_marker() {
let mut s = Session::new();
let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "@Claude make this bigger"})).unwrap()["id"].as_u64().unwrap();
as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "Done"}));
let t = thread(&mut s, None);
assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("You"), Some(false)), "the user's own comment is unchanged: {t}");
let r = &t["replies"][0];
assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Claude (agent)"), Some(true)), "{r}");
let doc = s.doc().unwrap();
let m = &doc.comment(c).unwrap().replies[0];
assert!(m.agent && m.author == "Claude (agent)");
// The agent acts as itself again later: no `author` on any call.
as_agent(&mut s, "Claude", "comment.react", json!({"id": c, "emoji": "👍"}));
assert_eq!(s.doc().unwrap().comment(c).unwrap().first.reactions[0].users, ["Claude (agent)"]);
assert_eq!(as_agent(&mut s, "Claude", "comment.people", json!({}))["you"], "Claude (agent)");
assert_eq!(s.execute("comment.people", &json!({})).unwrap()["you"], "You", "the user's name never changes");
assert_eq!(s.agent, None, "the agent acts only for its own call");
}
#[test]
fn an_agents_read_state_is_its_own() {
let mut s = Session::new();
let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "Look"})).unwrap()["id"].as_u64().unwrap();
as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "On it"}));
assert_eq!(thread(&mut s, None)["unread"], true, "the agent's reply is unread for the user");
as_agent(&mut s, "Claude", "comment.markRead", json!({}));
assert_eq!(thread(&mut s, None)["unread"], true, "the agent reading it doesn't read it for the user");
assert_eq!(thread(&mut s, Some("Claude"))["unread"], false);
}
#[test]
fn an_agent_without_a_name_is_still_not_the_user() {
let mut s = Session::new();
as_agent(&mut s, "", "comment.add", json!({"x": 0, "y": 0, "text": "Hi"}));
let t = thread(&mut s, None);
assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("AI agent"), Some(true)), "{t}");
// An explicit `author` still wins, and the message is still an agent's.
as_agent(&mut s, "Claude", "comment.reply", json!({"id": t["id"], "text": "x", "author": "Helper"}));
let r = &thread(&mut s, None)["replies"][0];
assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Helper"), Some(true)), "{r}");
}
#[test]
fn agent_names_are_cleaned() {
assert_eq!(agent_author(" Claude "), "Claude (agent)");
assert_eq!(agent_author("\u{7}\n "), "AI agent");
assert_eq!(agent_author("a\u{0}b"), "ab (agent)");
let long = "x".repeat(500);
assert_eq!(agent_author(&long).chars().count(), 64 + " (agent)".len());
}
#[test]
fn an_agents_avatar_shows_its_names_initial() {
assert_eq!(initials("Claude (agent)"), "C");
assert_eq!(initials("AI agent"), "AA");
assert_eq!(initials("Ada Lovelace"), "AL");
assert_eq!(initials("(x) [y]"), "([", "no word starts with a letter: as before");
assert_eq!(initials(" "), "?");
}
@@ -0,0 +1,44 @@
//! `comment.list` reports the names each message @mentions, by the rule the thread view highlights
//! them with: resolved against the file's people where it can be, and whether the name is you.
use serde_json::{Value, json};
use crate::Session;
fn mentions(s: &mut Session, p: Value) -> Vec<Value> {
let l = s.execute("comment.list", &p).unwrap();
let c = &l["comments"][0];
[&c["mentions"], &c["replies"][0]["mentions"]].into_iter().cloned().collect()
}
#[test]
fn a_message_lists_the_names_it_mentions_and_not_an_email_address() {
let mut s = Session::new();
let id = s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Hi", "author": "Ada Lovelace"})).unwrap()["id"].as_u64().unwrap();
let text = "@ada lovelace and @You: please check, or ask @Bob (mail a@b.com). Thanks @Ada Lovelace";
s.execute("comment.reply", &json!({"id": id, "text": text, "author": "Sam"})).unwrap();
let [first, reply] = <[Value; 2]>::try_from(mentions(&mut s, json!({}))).unwrap();
assert_eq!(first, json!([]));
assert_eq!(
reply,
json!([
{"name": "Ada Lovelace", "known": true, "you": false},
{"name": "You", "known": true, "you": true},
{"name": "Bob", "known": false, "you": false},
]),
"two people the file knows (once each, as the file spells them), one name it doesn't, no email"
);
// `author` says whose list it is: then Ada is "you".
let reply = mentions(&mut s, json!({"author": "Ada Lovelace"})).remove(1);
assert_eq!(reply[0], json!({"name": "Ada Lovelace", "known": true, "you": true}));
}
#[test]
fn the_mention_rule_matches_the_highlighting_rule() {
let people = ["Sam", "Sam Lee"];
let found = figcraft_doc::mentions("@Sam Lee, @sam, x@Sam, @Zoe!", &people);
let names: Vec<_> = found.iter().map(|(r, who)| (r.clone(), *who)).collect();
assert_eq!(names, vec![(0..8, Some("Sam Lee")), (10..14, Some("Sam")), (23..27, None)]);
assert_eq!(figcraft_doc::mention_ranges("@Sam Lee, @sam, x@Sam, @Zoe!", &people), vec![0..8, 10..14]);
assert!(figcraft_doc::mentions("@ and a@b.com", &people).is_empty());
}
+234
View File
@@ -0,0 +1,234 @@
//! Components can't contain their own instances, directly, through other components or through
//! the variants of a set (#1084); files holding such a loop open with it broken.
use std::sync::Arc;
use base64::Engine;
use figcraft_doc::{Document, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
use crate::components::component_cycle;
use crate::components::tests::{copy_of, ex, idv, kids, node};
/// A 100×50 component named `name` at (x, 0), holding a rectangle; returns its id.
fn component(s: &mut Session, name: &str, x: f64) -> NodeId {
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 50, "name": name})));
ex(s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f}));
idv(&ex(s, "component.create", json!({"ids": [f]})))
}
fn instance(s: &mut Session, component: NodeId, parent: NodeId) -> NodeId {
idv(&ex(s, "instance.create", json!({"component": component, "x": 20, "y": 20, "parent": parent})))
}
/// `cmd` is refused with the loop explained, and the document is left as it was.
fn refused(s: &mut Session, cmd: &str, p: Value) {
let before = s.doc().unwrap().clone();
let undo = s.ds().unwrap().history.undo.len();
let err = s.execute(cmd, &p).expect_err(&format!("{cmd} {p} should be refused")).to_string();
assert!(err.contains("can't contain"), "{cmd}: {err}");
assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back");
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "{cmd} left no undo step");
}
/// Card holds a Badge instance; Badge holds an Icon instance. Returns (card, badge, icon, the
/// Icon instance in Badge).
fn chain(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
let icon = component(s, "Icon", 0.0);
let badge = component(s, "Badge", 200.0);
let card = component(s, "Card", 400.0);
let in_badge = instance(s, icon, badge);
instance(s, badge, card);
(card, badge, icon, in_badge)
}
#[test]
fn direct_and_indirect_loops_are_refused() {
let mut s = Session::new();
let (card, badge, _, in_badge) = chain(&mut s);
// Directly, and through Badge: Card already shows Badge.
refused(&mut s, "instance.create", json!({"component": card, "parent": card}));
refused(&mut s, "instance.create", json!({"component": card, "parent": badge}));
refused(&mut s, "instance.swap", json!({"ids": [in_badge], "component": card}));
// A Card instance moved or pasted into Badge.
let page = s.page().unwrap();
let loose = instance(&mut s, card, page);
refused(&mut s, "node.reparent", json!({"ids": [loose], "parent": badge}));
ex(&mut s, "edit.copy", json!({"ids": [loose]}));
refused(&mut s, "edit.paste", json!({"parent": badge}));
// A copied main pastes as an instance of it.
ex(&mut s, "edit.copy", json!({"ids": [card]}));
refused(&mut s, "edit.paste", json!({"parent": badge}));
assert!(component_cycle(s.doc().unwrap()).is_none());
}
#[test]
fn dropping_an_instance_where_it_would_contain_itself_places_it_on_the_page() {
let mut s = Session::new();
let (card, badge, _, _) = chain(&mut s);
let r = ex(&mut s, "instance.place", json!({"component": card, "x": 250, "y": 25}));
assert_eq!(r["parent"], json!(s.page().unwrap()), "not into Badge: {r}");
assert!(!s.doc().unwrap().is_ancestor(badge, idv(&r)));
}
#[test]
fn an_instance_swap_property_can_not_close_a_loop() {
let mut s = Session::new();
let (card, badge, icon, in_badge) = chain(&mut s);
ex(&mut s, "component.addProperty", json!({"id": badge, "name": "Icon", "type": "instanceSwap", "default": icon, "layer": in_badge}));
let in_card = kids(&s, card).into_iter().find(|k| matches!(node(&s, *k).kind, NodeKind::Instance { .. })).unwrap();
refused(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": card}}));
// Another component is fine.
let star = component(&mut s, "Star", 600.0);
ex(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": star}}));
}
/// A set of two variants, "State=On" holding an instance of "State=Off". Returns (on, off, the Off
/// instance in On).
fn nested_variants(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let on = component(s, "State=On", 0.0);
let off = component(s, "State=Off", 200.0);
ex(s, "component.combineVariants", json!({"ids": [on, off]}));
let held = instance(s, off, on);
(on, off, held)
}
#[test]
fn a_variant_holds_a_sibling_but_no_loop_back_through_it() {
let mut s = Session::new();
let (on, off, held) = nested_variants(&mut s);
assert!(component_cycle(s.doc().unwrap()).is_none(), "a sibling variant's instance is no loop");
// Off leading back to On, directly or through another component, is one.
refused(&mut s, "instance.create", json!({"component": on, "parent": off}));
let row = component(&mut s, "Row", 600.0);
instance(&mut s, on, row);
refused(&mut s, "instance.create", json!({"component": row, "parent": off}));
// So is switching the held instance to the variant holding it.
refused(&mut s, "instance.setProperties", json!({"ids": [held], "properties": {"State": "On"}}));
// Combining a component with one holding its instance is fine too.
let a = component(&mut s, "A", 0.0);
let b = component(&mut s, "B", 200.0);
instance(&mut s, a, b);
ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}));
assert!(component_cycle(s.doc().unwrap()).is_none());
}
/// `doc` saved and opened again: the session and what opening it reported.
fn reopened(doc: &Document) -> (Session, Value) {
let data = base64::engine::general_purpose::STANDARD.encode(figcraft_format::save_bytes(doc, None).unwrap());
let mut s = Session::new();
let r = ex(&mut s, "file.open", json!({"data": data, "name": "variants.figcraft"}));
(s, r)
}
#[test]
fn a_file_with_nested_sibling_variants_opens_unchanged_and_a_loop_through_them_is_repaired() {
let mut s = Session::new();
let (on, off, held) = nested_variants(&mut s);
let doc = s.doc().unwrap().clone();
let (t, r) = reopened(&doc);
assert_eq!(r["repairs"], json!([]), "{r}");
assert_eq!(t.doc().unwrap().nodes().count(), doc.nodes().count());
assert!(matches!(node(&t, held).kind, NodeKind::Instance { component, .. } if component == off));
// On → Off → On: an On instance in Off, written directly as an older file could have it.
let spare = component(&mut s, "Spare", 600.0);
let back = instance(&mut s, spare, off);
let mut doc = s.doc().unwrap().clone();
if let NodeKind::Instance { component, .. } = &mut doc.get_mut(back).unwrap().kind {
*component = on;
}
assert!(component_cycle(&doc).is_some());
let (t, r) = reopened(&doc);
assert!(r["repairs"].as_array().is_some_and(|n| n.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached")))), "{r}");
assert!(component_cycle(t.doc().unwrap()).is_none());
}
#[test]
fn components_around_instances_and_instances_in_slots_stay_allowed() {
let mut s = Session::new();
let (card, badge, ..) = chain(&mut s);
// A new component around a Card instance.
let page = s.page().unwrap();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100, "parent": page})));
instance(&mut s, card, f);
let holder = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
instance(&mut s, badge, holder);
// An instance in a slot of an instance of its own component: the slot holds that instance's
// own content, outside its main.
let slot = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": card, "name": "Slot"})));
ex(&mut s, "component.makeSlot", json!({"id": slot}));
let outer = instance(&mut s, card, page);
let outer_slot = copy_of(&s, outer, slot);
instance(&mut s, card, outer_slot);
assert!(component_cycle(s.doc().unwrap()).is_none());
}
/// A document where Card holds a Badge instance and Badge a Card instance, made by editing the
/// layers directly (as an older file or a merge could have it). Returns it with (card, badge).
fn looped() -> (Document, NodeId, NodeId) {
let mut s = Session::new();
let (card, badge, _, in_badge) = chain(&mut s);
let mut doc = s.doc().unwrap().clone();
if let NodeKind::Instance { component, .. } = &mut doc.get_mut(in_badge).unwrap().kind {
*component = card;
}
crate::components::sync_instance(&mut doc, in_badge);
assert!(component_cycle(&doc).is_some());
(doc, card, badge)
}
/// Edit mains `a` and `b` `n` times, every instance rebuilt in full each time; the layer count
/// after each round.
fn edit_mains(s: &mut Session, a: NodeId, b: NodeId, n: usize) -> Vec<usize> {
crate::components::FULL_REBUILD.with(|f| f.set(true));
let sizes = (0..n)
.map(|i| {
ex(s, "node.set", json!({"id": a, "props": {"fill": format!("#{:02x}0000", i * 20)}}));
ex(s, "node.set", json!({"id": b, "props": {"fill": format!("#00{:02x}00", i * 20)}}));
s.doc().unwrap().nodes().count()
})
.collect();
crate::components::FULL_REBUILD.with(|f| f.set(false));
sizes
}
#[test]
fn a_file_with_a_loop_opens_with_it_broken_and_a_note() {
let (doc, card, badge) = looped();
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
let mut s = Session::new();
let r = ex(&mut s, "file.open", json!({"data": data, "name": "looped.figcraft"}));
let notes = r["repairs"].as_array().cloned().unwrap_or_default();
assert!(notes.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached"))), "{r}");
let doc = s.doc().unwrap();
assert!(component_cycle(doc).is_none());
// The detached instance kept its layers; the other side is still an instance.
assert!(doc.contains(card) && doc.contains(badge));
let count = doc.nodes().count();
// Editing the mains no longer grows the file.
assert_eq!(edit_mains(&mut s, card, badge, 10), vec![count; 10]);
// Importing it into another file breaks it the same way.
let mut t = Session::new();
let r = ex(&mut t, "file.import", json!({"data": data, "name": "looped.figcraft"}));
assert!(r["warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
assert!(component_cycle(t.doc().unwrap()).is_none());
}
#[test]
fn a_loop_merged_in_stops_expanding_and_the_document_stays_editable() {
let (doc, card, badge) = looped();
let mut s = Session::new();
let uid = s.ds().unwrap().uid;
s.apply_remote(uid, Arc::new(doc));
assert!(s.message.as_deref().is_some_and(|m| m.contains("contain each other")), "{:?}", s.message);
let sizes = edit_mains(&mut s, card, badge, 12);
// A couple of rebuilds settle the copies; after that nothing grows.
assert!(sizes.windows(2).skip(2).all(|w| w[0] == w[1]), "{sizes:?}");
// Editing goes on: layers added to a main reach its instances.
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4, "parent": card}));
let page = s.page().unwrap();
let loose = instance(&mut s, badge, page);
assert!(!kids(&s, loose).is_empty());
}
+131
View File
@@ -0,0 +1,131 @@
//! `edit.duplicate` with `map: true` (#995): every original layer, descendants included, paired
//! with its copy, so a caller can edit inside a copy without inspecting it.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{Document, NodeId};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn idv(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
/// A "Screen" frame holding an instance of a "Card" component (with a "Label" sublayer), a group
/// of two rectangles and a nested frame with a text; returns (screen, card).
fn screen(s: &mut Session) -> (NodeId, NodeId) {
let card = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 80, "height": 40, "name": "Card"})));
ex(s, "text.create", json!({"text": "Hi", "x": 4, "y": 4, "parent": card, "props": {"name": "Label"}}));
let card = idv(&ex(s, "component.create", json!({"ids": [card]})));
let scr = idv(&ex(s, "frame.create", json!({"x": 200, "y": 0, "width": 300, "height": 300, "name": "Screen"})));
ex(s, "instance.create", json!({"component": card, "x": 210, "y": 10, "parent": scr}));
let r = |s: &mut Session, y: f64| idv(&ex(s, "shape.rectangle", json!({"x": 210, "y": y, "width": 20, "height": 20, "parent": scr})));
let (a, b) = (r(s, 100.0), r(s, 130.0));
ex(s, "object.group", json!({"ids": [a, b]}));
let inner = idv(&ex(s, "frame.create", json!({"x": 250, "y": 100, "width": 100, "height": 100, "parent": scr, "name": "Inner"})));
ex(s, "text.create", json!({"text": "Body", "x": 260, "y": 110, "parent": inner}));
(scr, card)
}
/// The child indexes from `root` down to `id`.
fn path(doc: &Document, root: NodeId, id: NodeId) -> Vec<usize> {
let mut out = Vec::new();
let mut at = id;
while at != root {
out.push(doc.index_in_parent(at).unwrap());
at = doc.node(at).unwrap().parent.unwrap();
}
out.reverse();
out
}
fn pairs(out: &Value) -> Vec<(NodeId, NodeId)> {
out["map"].as_object().unwrap().iter().map(|(k, v)| (NodeId(k.parse().unwrap()), id_of(v).unwrap())).collect()
}
/// The map pairs every layer of each original's subtree with the layer at the same place in its
/// copy, of the same type and name.
fn assert_complete(s: &Session, roots: &[(NodeId, NodeId)], out: &Value) {
let doc = s.doc().unwrap();
let map = pairs(out);
let all: usize = roots.iter().map(|(o, _)| doc.subtree(*o).len()).sum();
assert_eq!(map.len(), all, "every original layer, descendants included, is in the map: {out}");
for (o, c) in map {
let (root_o, root_c) = roots.iter().find(|(r, _)| doc.subtree(*r).contains(&o)).copied().unwrap();
assert!(doc.subtree(root_c).contains(&c), "{c} is inside the copy of {root_o}");
assert_eq!(path(doc, root_o, o), path(doc, root_c, c), "{c} sits where {o} does");
let (a, b) = (doc.node(o).unwrap(), doc.node(c).unwrap());
assert_eq!(a.name, b.name, "{o} → {c}");
// A main component's copy is an instance of it (the tests check the roots' types).
if o != root_o {
assert_eq!(a.kind.type_name(), b.kind.type_name(), "{o} → {c}");
}
}
}
#[test]
fn map_pairs_nested_frames_groups_and_instances_with_their_copies() {
let mut s = Session::new();
let (scr, _) = screen(&mut s);
let out = ex(&mut s, "edit.duplicate", json!({"ids": [scr], "map": true}));
let copy = id_of(&out["ids"][0]).unwrap();
assert_eq!(out["map"][scr.to_string()], json!(copy), "the root maps to the copy in `ids`");
assert_complete(&s, &[(scr, copy)], &out);
}
#[test]
fn map_covers_several_roots_and_a_main_component_copied_as_an_instance() {
let mut s = Session::new();
let (scr, card) = screen(&mut s);
let out = ex(&mut s, "edit.duplicate", json!({"ids": [card, scr], "map": true}));
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().map(|v| id_of(v).unwrap()).collect();
assert_eq!(ids.len(), 2);
let (card_copy, scr_copy) = (out["map"][card.to_string()].clone(), out["map"][scr.to_string()].clone());
assert!(ids.contains(&id_of(&card_copy).unwrap()) && ids.contains(&id_of(&scr_copy).unwrap()), "{out}");
let doc = s.doc().unwrap();
let card_copy = id_of(&card_copy).unwrap();
assert_eq!(doc.node(card_copy).unwrap().kind.type_name(), "INSTANCE", "a main component's copy is an instance");
assert_complete(&s, &[(card, card_copy), (scr, id_of(&scr_copy).unwrap())], &out);
}
#[test]
fn without_map_the_result_is_just_the_ids() {
let mut s = Session::new();
let (scr, _) = screen(&mut s);
for p in [json!({"ids": [scr]}), json!({"ids": [scr], "map": false})] {
let out = ex(&mut s, "edit.duplicate", p);
let keys: Vec<&String> = out.as_object().unwrap().keys().collect();
assert_eq!(keys, ["ids"], "{out}");
}
}
#[test]
fn a_batch_edits_inside_the_copy_through_the_map() {
let mut s = Session::new();
let (scr, _) = screen(&mut s);
let doc = s.doc().unwrap();
let inner = *doc.node(scr).unwrap().children.last().unwrap();
let body = doc.node(inner).unwrap().children[0];
let field = format!("map/{body}");
ex(
&mut s,
"command.batch",
json!({"commands": [
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [scr], "map": true}},
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": field}], "props": {"text": "Copy"}}},
]}),
);
let doc = s.doc().unwrap();
let text = |id: NodeId| match &doc.node(id).unwrap().kind {
figcraft_doc::NodeKind::Text(t) => t.characters.clone(),
_ => String::new(),
};
assert_eq!(text(body), "Body", "the original is untouched");
let copy = s.selection()[0];
let copy_body = doc.subtree(copy).into_iter().find(|i| path(doc, copy, *i) == path(doc, scr, body)).unwrap();
assert_eq!(text(copy_body), "Copy", "the edit landed in the copy");
}
+51 -6
View File
@@ -19,6 +19,8 @@ pub struct Missing {
pub family: String,
/// Text layers that use it (0: only a text style or a variable names it).
pub layers: usize,
/// The font store source it can be downloaded from (`Google Fonts`, `Noto`; desktop only).
pub source: Option<String>,
}
/// The families `doc` names that the font database doesn't have, by name. On the desktop the
@@ -61,9 +63,45 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
}
}
}
used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect()
used.into_values()
.filter(|(family, _)| !figcraft_text::has_family(family))
.map(|(family, layers)| Missing { source: source(&family), family, layers })
.collect()
}
/// A family's download ([`downloads`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Download {
/// Bytes downloaded of all its files.
Running { done: u64, total: u64 },
/// Installed and scanned: the next `font.download` of it fits the open document's text.
Installed,
/// Why it failed (nothing was installed).
Failed(String),
}
#[cfg(not(target_arch = "wasm32"))]
pub use download::{allow_downloads, downloads, downloads_allowed, source};
/// The browser keeps its built-in fonts: nothing is downloaded there.
#[cfg(target_arch = "wasm32")]
mod no_download {
pub fn source(_: &str) -> Option<String> {
None
}
pub fn downloads() -> Vec<(String, super::Download)> {
Vec::new()
}
pub fn downloads_allowed() -> bool {
false
}
pub fn allow_downloads() -> std::result::Result<(), String> {
Err("fonts aren't downloaded in the browser".into())
}
}
#[cfg(target_arch = "wasm32")]
pub use no_download::{allow_downloads, downloads, downloads_allowed, source};
/// Every string value of variable `id` in any mode, following aliases (each variable once).
fn string_values(vars: &Variables, id: &str) -> Vec<String> {
let mut out = Vec::new();
@@ -134,18 +172,19 @@ fn refit_text(s: &mut Session, p: &Value) -> Result<Value> {
}
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers, "source": m.source})).collect();
s.request_ui("dialog.missingFonts", json!({}));
Ok(json!({"families": families}))
}
/// The font commands: `text.missingFonts` and `text.refit`.
/// The font commands: `text.missingFonts`, `text.refit`, and on the desktop [`download`]'s.
pub fn specs() -> Vec<CommandSpec> {
vec![
#[allow(unused_mut)]
let mut list = vec![
spec!(
"text.missingFonts",
"Missing fonts",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers, source (the font store source font.download gets it from, or null)}]}",
false,
missing_fonts
),
@@ -156,8 +195,14 @@ pub fn specs() -> Vec<CommandSpec> {
true,
refit_text
),
]
];
#[cfg(not(target_arch = "wasm32"))]
list.extend(download::specs());
list
}
#[cfg(not(target_arch = "wasm32"))]
pub mod download;
#[cfg(test)]
mod tests;
+168
View File
@@ -0,0 +1,168 @@
//! Downloading a missing font (#754) through the suite's font store, `craft-font-store`: its
//! pinned Google Fonts and Noto indexes (compiled in) tell which families can be had, and it
//! downloads their files, checks each one's size and sha256 and installs them into the one folder
//! every Craft app's font scan reads. Desktop only.
//!
//! Downloading is off until the user turns it on, in the app's own window ([`allow_downloads`],
//! which is not a command). The commands drive the store as its agent, as the store's rules
//! have it: they install while the user has downloading on, are refused while it is off, and can
//! never turn it on.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock, PoisonError};
use craft_font_catalog::{FontEnv, font_store_dir};
use craft_font_store::{Caller, Cancel, FontStore, Index, Network};
use serde_json::{Value, json};
use super::Download;
use crate::cmd::CommandSpec;
use crate::params::req_string;
use crate::{EngineError, Result, Session, bad, spec};
/// Lower-case family → (spelling, download). Installs are the machine's, so the jobs are the
/// process's, whichever document asked.
static JOBS: Mutex<BTreeMap<String, (String, Download)>> = Mutex::new(BTreeMap::new());
/// Set by [`use_store_dir`].
static STORE_DIR: OnceLock<PathBuf> = OnceLock::new();
fn jobs() -> std::sync::MutexGuard<'static, BTreeMap<String, (String, Download)>> {
JOBS.lock().unwrap_or_else(PoisonError::into_inner)
}
fn set(family: &str, state: Download) {
if let Some(job) = jobs().get_mut(&family.to_lowercase()) {
job.1 = state;
}
}
/// Use `dir` as the font store instead of the suite's folder, for this process (tests: they never
/// read the user's setting). The first call wins.
pub fn use_store_dir(dir: PathBuf) {
let _ = STORE_DIR.set(dir);
}
fn store_dir() -> Option<PathBuf> {
if let Some(dir) = STORE_DIR.get() {
return Some(dir.clone());
}
// This crate's tests never touch the user's store.
if cfg!(test) {
return Some(std::env::temp_dir().join(format!("figcraft-font-store-tests-{}", std::process::id())));
}
font_store_dir(&FontEnv::from_process())
}
fn store(caller: Caller) -> Option<FontStore> {
Some(FontStore::new(store_dir()?, caller))
}
/// The indexes, read once: the built-in ones compiled in and the sources the user added as last
/// fetched. No request.
fn index() -> &'static Index {
static INDEX: OnceLock<Index> = OnceLock::new();
INDEX.get_or_init(|| store(Caller::Agent).map(|s| s.offline_indexes()).unwrap_or_else(|| Index::bundled().unwrap_or_default()))
}
/// The name of the source `family` can be downloaded from (`Google Fonts`, `Noto`), if any.
pub fn source(family: &str) -> Option<String> {
let index = index();
index.family(family.trim()).and_then(|f| index.source_of(f)).map(|s| s.name.clone())
}
/// Whether the user turned downloading fonts on (the setting is the store's, shared by every
/// Craft app).
pub fn downloads_allowed() -> bool {
store(Caller::Agent).is_some_and(|s| s.network() == Network::On)
}
/// Turn downloading fonts on. Only from the user's own action in the app's interface (a click in
/// the Missing fonts window), never from a command: agents can't.
pub fn allow_downloads() -> std::result::Result<(), String> {
store(Caller::User).ok_or_else(|| "this system has no font store folder".to_string())?.enable_network().map_err(|e| e.to_string())
}
/// The downloads of this process: (family, state).
pub fn downloads() -> Vec<(String, Download)> {
jobs().values().cloned().collect()
}
fn state_json(family: &str, state: &Download) -> Value {
match state {
Download::Running { done, total } => json!({"family": family, "state": "downloading", "done": done, "total": total}),
Download::Installed => json!({"family": family, "state": "installed"}),
Download::Failed(error) => json!({"family": family, "state": "failed", "error": error}),
}
}
/// `font.download`: start downloading `family`'s files on a thread of its own, or report the
/// download under way; a family that is there now fits the open document's text to it instead.
fn download(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "font.download";
let family = req_string(CMD, p, "family")?.trim().to_string();
let key = family.to_lowercase();
let state = jobs().get(&key).map(|(_, st)| st.clone());
if let Some(running @ Download::Running { .. }) = &state {
return Ok(state_json(&family, running));
}
// Installed: even when no scan sees it (system fonts opted out), it isn't downloaded again.
if state == Some(Download::Installed) || figcraft_text::has_family(&family) {
jobs().remove(&key);
let refitted = crate::fonts::refit_open(s, None, Some(std::slice::from_ref(&family)));
return Ok(json!({"family": family, "state": "installed", "refitted": refitted}));
}
let index = index();
let found = index.family(&family).ok_or_else(|| bad(CMD, format!("{family} is not in the font store's index")))?;
let store = store(Caller::Agent).ok_or_else(|| EngineError::Other("this system has no font store folder".into()))?;
if store.network() != Network::On {
return Err(EngineError::Disabled(CMD.into(), "downloading fonts is turned off; the user turns it on in Text ▸ Missing fonts".into()));
}
let total: u64 = found.files.iter().map(|f| f.size).sum();
let source = index.source_of(found).map(|src| src.name.clone());
jobs().insert(key, (found.family.clone(), Download::Running { done: 0, total }));
let name = found.family.clone();
let spawned = std::thread::Builder::new().name("figcraft-font-download".into()).spawn(move || {
let result = store.install_family(index, found, &Cancel::new(), &mut |p| set(&name, Download::Running { done: p.done, total: p.total }));
match result {
Ok(_) => {
// The new files are scanned here, off the interface's thread.
figcraft_text::rescan_system_fonts();
set(&name, Download::Installed);
}
Err(e) => set(&name, Download::Failed(e.to_string())),
}
});
if let Err(e) = spawned {
jobs().remove(&family.to_lowercase());
return Err(EngineError::Other(format!("couldn't start the download: {e}")));
}
Ok(json!({"family": found.family, "source": source, "state": "downloading", "done": 0, "total": total, "files": found.files.len()}))
}
/// The font commands that download: `font.download` and `font.downloads`.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"font.download",
"Download font",
"{family} downloads a missing font family from the font store (Google Fonts, Noto: pinned, checksum-verified files installed into the Craft apps' shared font folder) on a thread of its own; refused while the user has downloading turned off (only the user turns it on, in Text ▸ Missing fonts). Call again to see its progress; once it is installed, the call fits the open document's text to it (text.refit) → {family, state: downloading|installed, done?, total?, refitted?}",
true,
download
),
spec!(
"font.downloads",
"Font downloads",
"{} this session's font downloads and whether downloading fonts is turned on → {allowed, downloads: [{family, state: downloading|installed|failed, done?, total?, error?}]}",
false,
|_, _| {
let list: Vec<Value> = downloads().iter().map(|(f, st)| state_json(f, st)).collect();
Ok(json!({"allowed": downloads_allowed(), "downloads": list}))
}
),
]
}
#[cfg(test)]
#[path = "download_tests.rs"]
mod tests;
+61
View File
@@ -0,0 +1,61 @@
//! `font.download` without the network: this crate's tests use a store folder of their own, where
//! downloading was never turned on.
use serde_json::{Value, json};
use super::{Download, downloads, downloads_allowed, jobs, set, source};
use crate::{EngineError, Session};
#[test]
fn the_store_index_says_which_missing_families_can_be_downloaded() {
assert_eq!(source("Lato").as_deref(), Some("Google Fonts"));
assert_eq!(source(" lato ").as_deref(), Some("Google Fonts"), "as the document spells it");
assert_eq!(source("FigCraft Not A Store Font"), None);
let mut s = Session::new();
for family in ["FigCraft Not A Store Font", "Lato"] {
s.execute("text.create", &json!({"text": "Hamburgefonstiv", "props": {"fontFamily": family}})).unwrap();
}
let listed = s.execute("text.missingFonts", &json!({})).unwrap();
let ours = |name: &str| listed["families"].as_array().unwrap().iter().find(|f| f["family"] == name).cloned();
assert_eq!(ours("FigCraft Not A Store Font").unwrap()["source"], Value::Null);
if !figcraft_text::has_family("Lato") {
assert_eq!(ours("Lato").unwrap()["source"], "Google Fonts");
}
}
#[test]
fn nothing_downloads_until_the_user_turns_downloading_on() {
let mut s = Session::new();
assert!(!downloads_allowed(), "off by default");
let Some(family) = ["Lato", "Merriweather", "Bungee Shade"].into_iter().find(|f| !figcraft_text::has_family(f)) else { return };
let refused = s.execute("font.download", &json!({"family": family})).unwrap_err();
assert!(matches!(&refused, EngineError::Disabled(cmd, why) if cmd == "font.download" && why.contains("turned off")), "{refused}");
assert!(!downloads().iter().any(|(f, _)| f == family), "no download started");
assert_eq!(s.execute("font.downloads", &json!({})).unwrap()["allowed"], false);
assert!(matches!(s.execute("font.download", &json!({})), Err(EngineError::BadParams { .. })));
let unknown = s.execute("font.download", &json!({"family": "FigCraft Not A Store Font"})).unwrap_err();
assert!(unknown.to_string().contains("not in the font store's index"), "{unknown}");
}
#[test]
fn a_download_reports_its_progress_then_fits_the_text_once_installed() {
let family = "FigCraft Download Progress";
let mut s = Session::new();
jobs().insert(family.to_lowercase(), (family.into(), Download::Running { done: 50, total: 200 }));
let r = s.execute("font.download", &json!({"family": family})).unwrap();
assert_eq!(r, json!({"family": family, "state": "downloading", "done": 50, "total": 200}), "no second download");
let listed = s.execute("font.downloads", &json!({})).unwrap();
assert!(listed["downloads"].as_array().unwrap().contains(&json!({"family": family, "state": "downloading", "done": 50, "total": 200})));
set(family, Download::Failed("downloading x failed".into()));
let listed = s.execute("font.downloads", &json!({})).unwrap();
assert!(listed["downloads"].as_array().unwrap().contains(&json!({"family": family, "state": "failed", "error": "downloading x failed"})));
// Installed (and scanned by the download's thread): the next call fits the text, once.
set(family, Download::Installed);
let r = s.execute("font.download", &json!({"family": family})).unwrap();
assert_eq!(r, json!({"family": family, "state": "installed", "refitted": []}));
assert!(!downloads().iter().any(|(f, _)| f == family));
}
+110
View File
@@ -0,0 +1,110 @@
//! `image.crop` (Object ▸ Crop image, the right-click menu and the image fill row): the layer's
//! top visible image fill (or the one `index` names) switches to Crop with the picture left where
//! its old mode drew it, as one undo step, and the canvas is asked to enter crop mode. A layer
//! without an image fill, or several layers, are refused with a reason and nothing changes.
use base64::Engine as _;
use figcraft_color::{ImageScaleMode, Paint, PaintKind};
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// A 200×100 synthetic image layer (every pixel differs), resized to 200×150 so Fill and Fit
/// place the picture differently.
fn image_layer(s: &mut Session) -> NodeId {
let image = image::RgbaImage::from_fn(200, 100, |x, y| image::Rgba([x as u8, y as u8, 120, 255]));
let mut bytes = std::io::Cursor::new(Vec::new());
image.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(bytes.into_inner());
let id = id_of(&ex(s, "file.import", json!({"data": data, "name": "crop.png", "x": 0, "y": 0}))["id"]).unwrap();
ex(s, "node.set", json!({"ids": [id], "props": {"height": 150}}));
id
}
fn fills(s: &Session, id: NodeId) -> Vec<Paint> {
s.doc().unwrap().node(id).unwrap().fills.clone()
}
fn set_mode(s: &mut Session, id: NodeId, mode: ImageScaleMode) {
let mut f = fills(s, id);
let PaintKind::Image(img) = &mut f[0].kind else { panic!("image fill") };
img.scale_mode = mode;
ex(s, "node.set", json!({"ids": [id], "props": {"fills": f}}));
}
fn pixels(s: &Session, id: NodeId) -> Vec<u8> {
figcraft_render::render_node(s.doc().unwrap(), id, 1.0).unwrap().data().to_vec()
}
fn undo_steps(s: &Session) -> usize {
s.ds().unwrap().history.undo.len()
}
#[test]
fn crop_image_switches_to_crop_keeping_the_picture_as_one_undo_step() {
for mode in [ImageScaleMode::Fill, ImageScaleMode::Fit, ImageScaleMode::Crop] {
let mut s = Session::new();
let layer = image_layer(&mut s);
set_mode(&mut s, layer, mode);
let (before, doc_before, steps) = (pixels(&s, layer), s.doc().unwrap().clone(), undo_steps(&s));
s.ui_requests.clear();
let r = ex(&mut s, "image.crop", json!({}));
assert_eq!(r, json!({"id": layer, "index": 0}), "{mode:?}");
let PaintKind::Image(img) = &fills(&s, layer)[0].kind else { panic!("image fill") };
assert_eq!(img.scale_mode, ImageScaleMode::Crop, "{mode:?}");
assert_eq!(pixels(&s, layer), before, "{mode:?}: the picture stays where it was");
let asked: Vec<_> = s.ui_requests.iter().filter(|u| u.kind == "image.crop").map(|u| u.data.clone()).collect();
assert_eq!(asked, vec![json!({"id": layer, "index": 0})], "{mode:?}: the canvas enters crop mode");
if mode == ImageScaleMode::Crop {
assert_eq!(undo_steps(&s), steps, "already cropped: nothing to undo");
continue;
}
assert_eq!(undo_steps(&s), steps + 1, "{mode:?}: one undo step");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap(), &doc_before, "{mode:?}: undo restores the fill");
}
}
#[test]
fn crop_image_takes_the_top_visible_image_fill_or_the_one_named() {
let mut s = Session::new();
let layer = image_layer(&mut s);
let mut f = fills(&s, layer);
let image = f[0].clone();
let mut hidden = image.clone();
hidden.visible = false;
// Bottom to top: image, image, solid, hidden image.
f = vec![image.clone(), image, Paint::solid(figcraft_color::Color::BLACK), hidden];
ex(&mut s, "node.set", json!({"ids": [layer], "props": {"fills": f}}));
assert_eq!(ex(&mut s, "image.crop", json!({"ids": [layer]}))["index"], 1, "the top visible image fill");
let modes =
|s: &Session| fills(s, layer).iter().map(|p| if let PaintKind::Image(i) = &p.kind { Some(i.scale_mode) } else { None }).collect::<Vec<_>>();
assert_eq!(modes(&s), [Some(ImageScaleMode::Fill), Some(ImageScaleMode::Crop), None, Some(ImageScaleMode::Fill)]);
assert_eq!(ex(&mut s, "image.crop", json!({"ids": [layer], "index": 0}))["index"], 0, "an image fill by index");
assert_eq!(modes(&s)[0], Some(ImageScaleMode::Crop));
let err = s.execute("image.crop", &json!({"ids": [layer], "index": 2})).unwrap_err().to_string();
assert!(err.contains("not an image"), "{err}");
let err = s.execute("image.crop", &json!({"ids": [layer], "index": 9})).unwrap_err().to_string();
assert!(err.contains("no fill at that index"), "{err}");
}
#[test]
fn crop_image_is_refused_without_one_image_layer() {
let mut s = Session::new();
let rect = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 300, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
let layer = image_layer(&mut s);
let doc = s.doc().unwrap().clone();
s.ui_requests.clear();
for (ids, why) in [(json!([rect]), "no image fill"), (json!([rect, layer]), "select one layer")] {
let err = s.execute("image.crop", &json!({"ids": ids})).unwrap_err();
assert!(matches!(&err, crate::EngineError::Disabled(..)) && err.to_string().contains(why), "{err}");
}
assert_eq!(s.doc().unwrap(), &doc, "nothing changes");
assert!(s.ui_requests.is_empty(), "no crop mode");
}
+14
View File
@@ -108,6 +108,7 @@ pub use derived::{changed_nodes, normalize};
pub use doc_state::{DocState, History, HistoryEntry, Interaction, PREVIEW_READ_ONLY, Preedit, TextEdit, VersionPreview};
pub use error::{EngineError, Result, bad};
pub use prefs::{Clipboard, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UiRequest, clamp_ui_scale};
pub use session::Forward;
pub use view_state::ViewState;
/// The editing session: open documents, the current tool, clipboard and prefs.
@@ -148,6 +149,9 @@ pub struct Session {
pub events: Arc<events::EventLog>,
/// Events recorded inside a group: (kind, document, path, data).
pending_events: Vec<(String, String, Option<String>, Value)>,
/// The AI agent the running command is for ([`Session::execute_as`]), as comments are signed
/// (`"Claude (agent)"`); `None` for the user.
pub agent: Option<String>,
}
impl Default for Session {
@@ -219,10 +223,16 @@ mod collab_tests;
#[cfg(test)]
mod collab_undo_tests;
#[cfg(test)]
mod comment_agent_tests;
#[cfg(test)]
mod comment_mentions_tests;
#[cfg(test)]
mod comments_polish_tests;
#[cfg(test)]
mod component_codegen_tests;
#[cfg(test)]
mod component_cycle_tests;
#[cfg(test)]
mod components_behaviour_tests;
#[cfg(test)]
mod components_scale_tests;
@@ -257,6 +267,8 @@ mod double_click_tests;
#[cfg(test)]
mod draw_tools_tests;
#[cfg(test)]
mod duplicate_map_tests;
#[cfg(test)]
mod duplicate_spacing_tests;
#[cfg(test)]
mod effects_tests;
@@ -307,6 +319,8 @@ mod huge_stroke_tests;
#[cfg(test)]
mod id_params_behaviour_tests;
#[cfg(test)]
mod image_crop_tests;
#[cfg(test)]
mod images_left_in_file_tests;
#[cfg(test)]
mod import_export_tests;
+1
View File
@@ -8,6 +8,7 @@ use super::{
};
mod access;
pub use access::Forward;
mod exec;
mod new;
mod undo;
+39
View File
@@ -1,6 +1,45 @@
use super::{DocState, Document, EngineError, NodeId, Result, Session, Value, json};
/// The file [`Session::bring_forward`] put in front for one request, and the tab to bring back.
#[must_use = "pass it to `Session::put_back`"]
pub struct Forward {
target: u64,
shown: Option<u64>,
}
impl Session {
/// The uid of open file `target`: its `id` (from `doc.list` or `document.inspect`) or its path.
pub fn document_uid(&self, target: &Value) -> Result<u64> {
let found = match target {
Value::Number(n) => n.as_u64().and_then(|id| self.docs.iter().find(|d| d.uid == id)),
Value::String(p) => self.docs.iter().find(|d| d.path.as_deref() == Some(p.as_str())),
_ => None,
};
found
.map(|d| d.uid)
.ok_or_else(|| EngineError::Other(format!("no open document {target} (`document` takes an `id` from doc.list, or an open file's path)")))
}
/// Put open file `target` in front for one request (a control call naming its `document`),
/// without the user's tab changing on screen: run the request, then [`Session::put_back`].
pub fn bring_forward(&mut self, target: &Value) -> Result<Forward> {
let uid = self.document_uid(target)?;
let shown = self.ds().ok().map(|d| d.uid);
if let Some(i) = self.docs.iter().position(|d| d.uid == uid) {
self.active = i;
}
Ok(Forward { target: uid, shown })
}
/// Bring back the tab that was in front before [`Session::bring_forward`], unless the request
/// itself moved the front to another open file (`file.open`, `doc.switch`…), as it would have.
pub fn put_back(&mut self, f: Forward) {
let moved = self.docs.iter().any(|d| d.uid == f.target) && self.ds().ok().map(|d| d.uid) != Some(f.target);
if let Some(i) = f.shown.and_then(|u| self.docs.iter().position(|d| d.uid == u)).filter(|_| !moved) {
self.active = i;
}
}
pub fn ds(&self) -> Result<&DocState> {
self.docs.get(self.active).ok_or(EngineError::NoDocument)
}
+13 -1
View File
@@ -45,6 +45,16 @@ impl Session {
}
}
/// [`Session::execute`] for an AI agent named `agent` (the client's name; empty when it gave
/// none), or for the user (`None`): the comments it makes are signed with the agent's name and
/// marked as an agent's.
pub fn execute_as(&mut self, agent: Option<&str>, id: &str, params: &Value) -> Result<Value> {
let was = std::mem::replace(&mut self.agent, agent.map(cmd::comment::agent_author));
let r = self.execute(id, params);
self.agent = was;
r
}
/// Run a command by id. Mutations become one undo step; a failure or panic leaves the document
/// as it was.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
@@ -183,7 +193,8 @@ impl Session {
}
/// Instances can't gain, lose or reorder layers, nor move, resize or re-constrain the layers in
/// them, and library copies are read-only: refuse such an edit (it is rolled back).
/// them, library copies are read-only and components can't contain their own instances: refuse
/// such an edit (it is rolled back).
pub(crate) fn check_instance_structure(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
@@ -198,6 +209,7 @@ impl Session {
library::check_read_only(effective, bdoc, &ds.doc)
.and_then(|()| components::check_structure_edit(bdoc, &ds.doc))
.and_then(|()| components::check_override_edit(bdoc, &ds.doc))
.and_then(|()| components::check_component_cycles(bdoc, &ds.doc))
.map_err(|m| EngineError::Disabled(id.to_string(), m))
}
+1
View File
@@ -30,6 +30,7 @@ impl Session {
background: background::Background::default(),
events: Arc::new(events::EventLog::default()),
pending_events: Vec::new(),
agent: None,
}
}
+9 -2
View File
@@ -343,9 +343,16 @@ pub fn display_name(prefs: &crate::Prefs) -> String {
if n == crate::cmd::comment::DEFAULT_AUTHOR { String::new() } else { n.chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() }
}
/// Initials for an avatar ("Ada Lovelace" → "AL"; "?" for none).
/// Initials for an avatar ("Ada Lovelace" → "AL"; "Claude (agent)" → "C": words that start with a
/// letter or digit, when there are any; "?" for none).
pub fn initials(name: &str) -> String {
let mut out: String = name.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect();
let firsts = |alnum: bool| -> String {
name.split_whitespace().filter_map(|w| w.chars().next()).filter(|c| !alnum || c.is_alphanumeric()).take(2).collect()
};
let mut out = firsts(true);
if out.is_empty() {
out = firsts(false);
}
if out.is_empty() {
out.push('?');
}
+16 -1
View File
@@ -5,6 +5,12 @@ use super::{
ToolId, VIEW_ONLY, editing_hint, editing_of, sanitize,
};
/// What a live session's document holding components that contain each other's instances shows
/// (`names`: the loop): those instances stop expanding where they would show themselves again.
fn loop_notice(names: &str) -> String {
format!("Components contain each other's instances ({names}): those instances stop where they would show themselves")
}
impl Session {
/// The active document (`None`: no document is open).
pub(super) fn tab(&self) -> Option<u64> {
@@ -37,6 +43,12 @@ impl Session {
/// Install a document that came from the session (others' edits): the selection, page and view
/// stay where they can, and the undo history isn't touched.
pub fn apply_remote(&mut self, uid: u64, doc: Arc<Document>) {
let Some(old) = self.docs.iter().find(|d| d.uid == uid).map(|d| d.doc.clone()) else { return };
// Edits merged from others can close a loop of components containing each other.
let changed = crate::derived::changed_nodes(&doc, &old);
if let Some(names) = crate::components::new_cycle(&old, &doc, changed.iter()) {
self.message = Some(loop_notice(&names));
}
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
ds.doc = doc;
if !ds.doc.pages.contains(&ds.page) {
@@ -355,7 +367,10 @@ impl Session {
(Some(Role::Guest), None) => {
// The session's file gets a tab of its own, in front.
let ds = DocState::new(client.document().clone(), None);
self.message = Some(format!("Joined {}", ds.doc.name));
self.message = Some(match crate::components::cycle_text(&ds.doc) {
Some(names) => format!("Joined {}. {}", ds.doc.name, loop_notice(&names)),
None => format!("Joined {}", ds.doc.name),
});
let opened = (ds.uid, Some(ds.doc.clone()));
self.active = self.docs.len();
self.docs.push(ds);
+4
View File
@@ -467,6 +467,10 @@ fn every_command_survives_junk_params() {
json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [
{"$ref": "\u{0}", "field": "ids/18446744073709551616/x"}, {"$ref": {"x": 1}}, {"$step": 0, "field": "//"}, {"$ref": "\u{0}", "field": "/"}],
"props": {"name": {"$ref": "nope", "field": 5}}}}]}),
// Font downloads: family names as junk and as paths (a family is only ever looked up in
// the font index, never used as a path).
json!({"family": "../../\u{0}Lato"}),
json!({"family": ["Lato"], "style": 1e308}),
];
for spec in all_commands() {
if matches!(
@@ -15,6 +15,58 @@ fn of_own_line_height(n: &Node) -> Option<Node> {
Some(m)
}
/// The chars that start a wrapped line (not those after a hard break) in `t` laid out `w` wide.
fn soft_starts(t: &figcraft_doc::TextData, w: f64) -> Vec<usize> {
let chars: Vec<char> = t.characters.chars().collect();
let l = figcraft_text::layout(t, Some(w), None);
l.lines
.iter()
.skip(1)
.map(|ln| ln.char_start)
.filter(|s| !s.checked_sub(1).and_then(|i| chars.get(i)).is_some_and(|c| matches!(c, '\n' | '\u{2028}')))
.collect()
}
/// Each fixed-width text with the line breaks its file saved (one-line texts too) wraps there once imported, unless a
/// line would overflow its box by more than the allowed slack in the fonts at hand (then it wraps
/// as it would without them). Prints the counts (`--nocapture`).
#[test]
fn local_samples_wrap_at_their_saved_line_breaks() {
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return };
let Ok(dir) = std::fs::read_dir(dir) else { return };
figcraft_text::scan_system_fonts();
let (mut matched, mut fell_back, mut differed_before, mut wrapped) = (0, 0, 0, 0);
let mut problems = Vec::new();
for p in dir.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")) {
let Ok((doc, _)) = figcraft_format::fig::import(&std::fs::read(&p).unwrap(), "sample") else { continue };
for n in doc.nodes() {
let NodeKind::Text(t) = &n.kind else { continue };
let Some(saved) = t.saved_breaks.as_deref() else { continue };
if t.auto_resize == TextAutoResize::WidthAndHeight {
continue;
}
wrapped += usize::from(!saved.starts.is_empty());
let plain = figcraft_doc::TextData { saved_breaks: None, ..t.clone() };
let (own, hinted) = (soft_starts(&plain, n.width), soft_starts(t, n.width));
differed_before += usize::from(own != saved.starts);
if t.saved_breaks_for(n.width).is_none() {
problems.push(format!("{}: {:?}: saved breaks no longer apply after import", p.display(), t.characters));
} else if hinted == saved.starts {
matched += 1;
} else if hinted == own {
fell_back += 1;
} else {
problems.push(format!("{}: {:?}: {hinted:?} != saved {:?}", p.display(), t.characters, saved.starts));
}
}
}
eprintln!(
"saved line breaks ({wrapped} of the texts wrap): {matched} matched, {fell_back} fell back, {} other; {differed_before} wrapped differently without them",
problems.len()
);
assert!(problems.is_empty(), "{problems:#?}");
}
/// Each text box that sizes itself comes out at the size stored in the file, to the pixel.
#[test]
fn local_samples_keep_their_text_box_sizes() {
+2
View File
@@ -44,6 +44,8 @@ mod text_path_tests;
mod text_skip_ink_tests;
#[cfg(test)]
mod text_style_tests;
#[cfg(test)]
mod text_wrap_tests;
use std::collections::BTreeMap;
use std::io::Read;
+7 -4
View File
@@ -183,12 +183,15 @@ impl<'a> View<'a> {
v
}
/// This view with `style` just above the node itself: the style beats the node's own copy of
/// its fields (stale when the style changed since), and override rows beat both.
fn styled(&self, style: Option<&'a Kv<'a>>) -> Self {
/// This view with `style` just above the layer that links it in `key` (or `legacy`): the style
/// beats that layer's and lower layers' copies of its fields (stale when the style changed
/// since, or written for the style linked before), and override rows above the link beat it.
fn styled(&self, style: Option<&'a Kv<'a>>, key: &str, legacy: &str) -> Self {
let mut v = self.clone();
if let Some(s) = style {
v.layers.insert(v.layers.len().min(1), s);
let linked_in = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some());
let at = linked_in(key).or_else(|| linked_in(legacy)).map_or(1, |i| i + 1);
v.layers.insert(at.min(v.layers.len()), s);
}
v
}
+14 -2
View File
@@ -36,10 +36,18 @@ pub(super) struct OverrideRow<'a> {
/// How many mains are being expanded around the instance that wrote the row: the rows of
/// the enclosing (outer) instances win over those of the inner ones.
source_depth: usize,
/// How many levels below the writing instance the row restates (`overrideLevel`; 0 for the
/// instance's own rows).
level: usize,
/// Position among the rows of the instance that wrote it; later rows win.
ordinal: usize,
}
/// The nested level an override row restates: its `overrideLevel`, capped; 0 when it has none.
fn override_level(row: &Kv) -> usize {
row.get("overrideLevel").and_then(Kv::f64).filter(|l| l.is_finite() && *l > 0.0).map_or(0, |l| l.min(MAX_DEPTH as f64) as usize)
}
/// The layers of a main by current GUID, and the old `overrideKey` of each that has one
/// (`None` when two layers claim the same key).
pub(super) struct OverrideScope {
@@ -245,6 +253,7 @@ impl<'a> Mapper<'a> {
if own_text(v) != Some(t) {
o.remove("characterStyleOverrides");
o.remove("styleOverrideTable");
o.remove("_figLineStarts");
if let Some(tv) = prop_text_data(val) {
let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64);
char_styles(o, tv, size, &mut |w| self.warn(w));
@@ -350,6 +359,7 @@ impl<'a> Mapper<'a> {
row: ov,
entry,
source_depth,
level: override_level(ov),
ordinal: row_ordinal,
});
}
@@ -358,8 +368,10 @@ impl<'a> Mapper<'a> {
overrides.entry(path).or_default().extend(rows);
}
for rows in overrides.values_mut() {
// Deeper source rows apply first; outer rows win, with wire order preserved per source.
rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth), row.ordinal));
// Deeper rows apply first and outer rows win: a row restating a nested level counts at
// that level's depth, wherever it sits on the wire. Equal depths keep the outer source
// last, then wire order.
rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth + row.level), std::cmp::Reverse(row.source_depth), row.ordinal));
}
let mut all_props: BTreeMap<String, &'a Kv<'a>> = outer.map(|i| (*i.props).clone()).unwrap_or_default();
let mut slot_contents: BTreeMap<String, String> = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default();
+3 -2
View File
@@ -1,7 +1,7 @@
use super::component::{Entry, Inst, OverrideRow, prop_ref};
use super::connector::connector_has_saved_label;
use super::layout::{layout_grid, tracks};
use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke, style_link};
use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke};
use super::{
Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, generated_record, guid, json, matrix,
network, num, rows, size,
@@ -684,7 +684,8 @@ impl<'a> Mapper<'a> {
if own_fills.list().iter().any(paint_bound) {
return Some(own_fills);
}
let linked_style = style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID")
let linked_style = self
.style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID")
.and_then(|id| self.nodes.get(&id).copied())
.filter(|style| style.get("styleType").and_then(Kv::str) == Some("FILL"));
match linked_style {
+14 -10
View File
@@ -12,25 +12,29 @@ fn filter_number(value: &Kv) -> Option<f64> {
number.is_finite().then_some(number)
}
/// The style a layer links in `key`, or in the older `legacy` field when `key` names none. An
/// explicit NO_STYLE in `key` is a detach and wins over the legacy field.
pub(super) fn style_link(v: &View, key: &str, legacy: &str) -> Option<String> {
match v.get(key).and_then(|r| r.get("guid")).and_then(guid) {
Some(id) => Some(id).filter(|g| g != NO_STYLE),
None => v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE),
}
}
pub(super) fn color(c: &Kv) -> Value {
let ch = |k: &str| num(c, k).unwrap_or(0.0);
json!({"r": ch("r"), "g": ch("g"), "b": ch("b"), "a": num(c, "a").unwrap_or(1.0)})
}
impl<'a> Mapper<'a> {
/// The style a layer links in `key`, by guid or by library asset (the file's copy of a library
/// style), or in the older `legacy` field when `key` names none. An explicit NO_STYLE guid in
/// `key` with no asset is a detach and wins over the legacy field.
pub(super) fn style_link(&self, v: &View, key: &str, legacy: &str) -> Option<String> {
let fallback = || v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE);
let Some(link) = v.get(key) else { return fallback() };
match (self.resource_id(link), link.get("guid").and_then(guid)) {
(Some(id), _) => Some(id),
(None, Some(_)) => None,
(None, None) => fallback(),
}
}
/// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST
/// `styles` map under `slot`.
pub(super) fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> {
let id = style_link(v, key, legacy)?;
let id = self.style_link(v, key, legacy)?;
let node = *self.nodes.get(&id)?;
node.get("styleType").and_then(Kv::str)?;
out.insert(slot.into(), json!(id));
+46 -4
View File
@@ -1,4 +1,4 @@
use super::paint::{paint, style_link};
use super::paint::paint;
use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num};
/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through.
@@ -36,14 +36,56 @@ fn leading_trim<'a>(v: &View<'a>) -> Option<&'a str> {
})
}
/// The chars that start a wrapped line in the layout the file saved with the text
/// (`derivedTextData`'s `baselines`, each line's `firstCharacter` in UTF-16 units), when that layout
/// is for these characters; lines after a hard break are left out (empty: it wraps nowhere). None
/// when the saved layout is missing, for other text or inconsistent.
fn saved_line_starts(v: &View, chars: &str) -> Option<Vec<usize>> {
// Derived data from below the layer that set the characters was laid out for other text.
let top = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some());
if top("derivedTextData")? < top("textData")? {
return None;
}
let baselines = v.get("derivedTextData")?.get("baselines")?.list();
// UTF-16 offset of each char, and of the end.
let mut offsets: Vec<usize> = Vec::with_capacity(chars.len() + 1);
let mut unit = 0;
for c in chars.chars() {
offsets.push(unit);
unit += c.len_utf16();
}
offsets.push(unit);
let list: Vec<char> = chars.chars().collect();
let mut starts = Vec::new();
let mut prev = None;
for b in baselines {
let u = num(b, "firstCharacter").filter(|u| *u >= 0.0 && u.fract() == 0.0 && *u < unit as f64)? as usize;
let c = offsets.binary_search(&u).ok()?;
// Lines start at the first char, then further on each time.
if !prev.map_or(c == 0, |p| c > p) {
return None;
}
prev = Some(c);
if c > 0 && !list.get(c - 1).is_some_and(|ch| matches!(ch, '\n' | '\u{2028}')) {
starts.push(c);
}
}
prev.map(|_| starts)
}
impl<'a> Mapper<'a> {
pub(super) fn text(&mut self, o: &mut Obj, v: &View<'a>) {
let style_node = style_link(v, "styleIdForText", "inheritTextStyleID").and_then(|s| self.nodes.get(&s).copied());
// Instance overrides beat the shared style; the style beats the layer's own copy.
let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")));
let (key, legacy) = ("styleIdForText", "inheritTextStyleID");
let style_node = self.style_link(v, key, legacy).and_then(|s| self.nodes.get(&s).copied());
// Overrides above the one that links the style beat it; it beats the copies below.
let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")), key, legacy);
let td = v.get("textData");
let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or("");
o.insert("characters".into(), json!(chars));
// Where the file's own layout wrapped the lines (not a REST field).
if let Some(starts) = saved_line_starts(v, chars) {
o.insert("_figLineStarts".into(), json!(starts));
}
let mut style = text_style(&sv, None);
// The style's typography beats the layer's own copy of it (the copy is stale when the style
// changed). The file cannot tell a stale copy of these four values from a deliberate local
+106
View File
@@ -182,6 +182,112 @@ fn an_instance_override_beats_the_shared_style_and_a_detach_unlinks_it() {
assert_eq!(detached["style"]["fontSize"].as_f64(), Some(40.0));
}
/// A link naming a library style by its asset `key` and `version` instead of a guid.
fn asset_link(key: &str, version: &str) -> Kv<'static> {
object(vec![("assetRef", object(vec![("key", Kv::Str(key.into())), ("version", Kv::Str(version.into()))]))])
}
/// The file's copy of library text style "Heading" (Inter 24, asset `lib-heading` version 3).
fn library_heading() -> Kv<'static> {
node(
10,
"TEXT",
"Heading",
Some(2),
vec![
("styleType", Kv::Enum("TEXT")),
("key", Kv::Str("lib-heading".into())),
("version", Kv::Str("3".into())),
("fontName", font("Inter")),
("fontSize", Kv::Float(24.0)),
],
)
}
/// An override row for the layer at `path` (guids) with `fields`.
fn row(path: &[u64], mut fields: Fields) -> Kv<'static> {
let mut all = vec![("guidPath", object(vec![("guids", Kv::Array(path.iter().map(|g| guid(*g)).collect()))]))];
all.append(&mut fields);
object(all)
}
fn instance(id: u64, name: &str, parent: u64, main: u64, rows: Vec<Kv<'static>>) -> Kv<'static> {
node(id, "INSTANCE", name, Some(parent), vec![("symbolData", object(vec![("symbolID", guid(main)), ("symbolOverrides", Kv::Array(rows))]))])
}
#[test]
fn a_library_text_style_linked_by_asset_key_is_resolved() {
let layer = vec![
("fontName", font("Stale copy")),
("fontSize", Kv::Float(40.0)),
("styleIdForText", asset_link("lib-heading", "3")),
("textData", chars("Hi")),
];
let doc = import(&rest(vec![library_heading(), node(11, "TEXT", "Label", Some(2), layer)]));
let label = doc.nodes().find(|n| n.name == "Label").unwrap();
assert_eq!(doc.style(label.styles.text.as_deref().unwrap()).unwrap().name, "Heading");
let text = label.kind.text().unwrap();
assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 24.0), "the style beats the stale copy");
// A version the file holds no copy of names nothing: the layer keeps its own values, unlinked.
let layer = vec![
("fontName", font("Roboto")),
("fontSize", Kv::Float(40.0)),
("styleIdForText", asset_link("lib-heading", "9")),
("textData", chars("Hi")),
];
let value = rest(vec![library_heading(), node(11, "TEXT", "Label", Some(2), layer)]);
let label = find(&value["document"], "Label").unwrap();
assert!(label["styles"].get("text").is_none(), "{}", label["styles"]);
assert_eq!(label["style"]["fontSize"].as_f64(), Some(40.0));
}
#[test]
fn a_library_text_style_an_instance_links_beats_the_copies_below_it_and_outer_overrides_beat_it() {
let value = rest(vec![
library_heading(),
node(11, "TEXT", "Body", Some(2), vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(14.0))]),
node(20, "SYMBOL", "Main", Some(2), vec![]),
node(
21,
"TEXT",
"Label",
Some(20),
vec![("fontName", font("Inter")), ("fontSize", Kv::Float(14.0)), ("styleIdForText", link(11)), ("textData", chars("Hi"))],
),
// The instance relinks the label to the library style; its row keeps a stale copy (40).
instance(30, "Linked", 2, 20, vec![row(&[21], vec![("styleIdForText", asset_link("lib-heading", "3")), ("fontSize", Kv::Float(40.0))])]),
node(40, "SYMBOL", "Card", Some(2), vec![]),
instance(41, "Nested", 40, 20, vec![row(&[21], vec![("styleIdForText", asset_link("lib-heading", "3")), ("fontSize", Kv::Float(40.0))])]),
// An instance of the card sets the size on top of the nested instance's style.
instance(50, "Outer", 2, 40, vec![row(&[41, 21], vec![("fontSize", Kv::Float(30.0))])]),
]);
let linked = find(find(&value["document"], "Linked").unwrap(), "Label").unwrap();
assert_eq!(linked["styles"]["text"], "9:10");
assert_eq!(linked["style"]["fontSize"].as_f64(), Some(24.0), "the linked style beats the copies below it");
let outer = find(find(&value["document"], "Outer").unwrap(), "Label").unwrap();
assert_eq!(outer["styles"]["text"], "9:10");
assert_eq!(outer["style"]["fontSize"].as_f64(), Some(30.0), "an override above the link is kept");
}
#[test]
fn a_row_restating_a_nested_level_loses_to_the_instances_own_row() {
let label = vec![("fontName", font("Inter")), ("fontSize", Kv::Float(14.0)), ("textData", chars("Hi"))];
let value = rest(vec![
node(20, "SYMBOL", "Main", Some(2), vec![]),
node(21, "TEXT", "Label", Some(20), label),
// The own row comes first on the wire; the restated nested-level row after it.
instance(
30,
"Inst",
2,
20,
vec![row(&[21], vec![("fontSize", Kv::Float(30.0))]), row(&[21], vec![("fontSize", Kv::Float(12.0)), ("overrideLevel", Kv::Int(1))])],
),
]);
let label = find(find(&value["document"], "Inst").unwrap(), "Label").unwrap();
assert_eq!(label["style"]["fontSize"].as_f64(), Some(30.0));
}
#[test]
fn a_text_property_value_drops_the_run_styles_made_for_the_main_text() {
let ids = [0u64, 0, 0, 1, 1, 1].map(Kv::Uint).to_vec();
+102
View File
@@ -0,0 +1,102 @@
//! The line breaks a `.fig` file saved with its text (`derivedTextData`) come across as the text's
//! saved breaks, on messages built in code.
use figcraft_doc::{Document, TextData};
use super::kiwi::Kv;
type Fields = Vec<(&'static str, Kv<'static>)>;
fn object(fields: Fields) -> Kv<'static> {
Kv::Object(fields)
}
fn guid(id: u64) -> Kv<'static> {
object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(id))])
}
fn node(id: u64, ty: &'static str, name: &str, parent: u64, mut more: Fields) -> Kv<'static> {
let mut fields = vec![
("guid", guid(id)),
("type", Kv::Enum(ty)),
("name", Kv::Str(name.into())),
("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))])),
];
fields.append(&mut more);
object(fields)
}
/// The saved layout: one baseline per line, starting at these UTF-16 offsets.
fn derived(firsts: &[u64]) -> Kv<'static> {
let lines = firsts.iter().map(|f| object(vec![("firstCharacter", Kv::Uint(*f))])).collect();
object(vec![("baselines", Kv::Array(lines))])
}
/// A 100 × 40 text layer named `name` with `chars`, sizing `resize` and the saved layout `firsts`.
fn label(id: u64, name: &str, parent: u64, chars: &str, resize: &'static str, firsts: &[u64]) -> Kv<'static> {
node(
id,
"TEXT",
name,
parent,
vec![
("size", object(vec![("x", Kv::Float(100.0)), ("y", Kv::Float(40.0))])),
("fontSize", Kv::Float(14.0)),
("textAutoResize", Kv::Enum(resize)),
("textData", object(vec![("characters", Kv::Str(chars.into()))])),
("derivedTextData", derived(firsts)),
],
)
}
fn import(nodes: Vec<Kv<'static>>) -> Document {
let page = vec![
object(vec![("guid", guid(1)), ("type", Kv::Enum("DOCUMENT")), ("name", Kv::Str("Document".into()))]),
node(2, "CANVAS", "Page", 1, vec![]),
];
let message = object(vec![("nodeChanges", Kv::Array(page.into_iter().chain(nodes).collect()))]);
let value = super::map::to_rest(&message, "Synthetic wrap", crate::rest::MAX_NODES).0;
crate::rest::import_fig(&value, &|_| None).unwrap().0
}
fn texts<'d>(doc: &'d Document, name: &str) -> Vec<&'d TextData> {
doc.nodes().filter(|n| n.name == name).filter_map(|n| n.kind.text()).collect()
}
#[test]
fn saved_line_starts_come_across_as_chars_without_hard_breaks() {
// UTF-16: the emoji takes two units, so "c" is unit 6 but char 5; "e" follows a hard break.
let doc = import(vec![
label(10, "Wrapped", 2, "😀 ab cd\nef", "HEIGHT", &[0, 6, 9]),
label(11, "Unwrapped", 2, "Followers", "NONE", &[0]),
label(12, "Auto width", 2, "ab cd", "WIDTH_AND_HEIGHT", &[0, 3]),
label(13, "Inside a pair", 2, "😀 ab", "HEIGHT", &[0, 1]),
label(14, "Out of order", 2, "ab cd ef", "HEIGHT", &[0, 6, 3]),
label(15, "Not from the start", 2, "ab cd", "HEIGHT", &[3]),
]);
let wrapped = texts(&doc, "Wrapped")[0];
assert_eq!(wrapped.saved_breaks_for(100.0), Some(&[5][..]));
assert_eq!(texts(&doc, "Unwrapped")[0].saved_breaks_for(100.0), Some(&[][..]), "one line: no wrapped start");
for name in ["Auto width", "Inside a pair", "Out of order", "Not from the start"] {
assert_eq!(texts(&doc, name)[0].saved_breaks, None, "{name}");
}
}
#[test]
fn a_saved_layout_for_other_text_is_left_out() {
let row = object(vec![
("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])),
("textData", object(vec![("characters", Kv::Str("Something much longer".into()))])),
]);
let doc = import(vec![
node(20, "SYMBOL", "Main", 2, vec![]),
label(21, "Label", 20, "Hi there", "HEIGHT", &[0, 3]),
node(30, "INSTANCE", "Inst", 2, vec![("symbolData", object(vec![("symbolID", guid(20)), ("symbolOverrides", Kv::Array(vec![row]))]))]),
]);
let labels = texts(&doc, "Label");
let main = labels.iter().find(|t| t.characters == "Hi there").unwrap();
assert_eq!(main.saved_breaks_for(100.0), Some(&[3][..]));
// The instance's text comes from its override, the saved layout only from the main.
let inst = labels.iter().find(|t| t.characters == "Something much longer").unwrap();
assert_eq!(inst.saved_breaks, None);
}
+49
View File
@@ -0,0 +1,49 @@
//! A JPEG header check run before any JPEG from a file is decoded: on the canvas and in exports
//! (`figcraft-render`) and for the pictures files carry ([`crate::thumbnail`]).
/// Whether `bytes` are a progressive JPEG whose frame samples some component more finely (across
/// or down) than its first. zune-jpeg 0.5.15, the decoder behind `image`, asserts in its upsampler
/// on such a frame (etemesi254/zune-image#236; fixed there by 36007420, unreleased as of
/// 0.5.16-rc3), and in the browser a panic aborts the page. Until the fix ships, such a JPEG is
/// refused from its header: the decoder refuses the larger ones itself, so nothing that decoded is
/// lost. Baseline frames of the same shape decode and are left to it.
/// shortcut: remove this check (and its callers' early return) once `Cargo.lock` has zune-jpeg
/// 0.5.16 or later.
pub fn finer_than_first(bytes: &[u8]) -> bool {
let Some((marker, frame)) = frame(bytes) else { return false };
if !matches!(marker, 0xC2 | 0xC6 | 0xCA | 0xCE) {
return false;
}
// Length (2), precision (1), height (2), width (2), components (1), then id, sampling and
// table for each component.
let n = usize::from(frame.get(7).copied().unwrap_or(0));
let sampling = |k: usize| frame.get(9 + 3 * k).map(|b| (b >> 4, b & 0x0F));
let Some((h0, v0)) = sampling(0) else { return false };
(1..n).any(|k| sampling(k).is_some_and(|(h, v)| h > h0 || v > v0))
}
/// A JPEG's start-of-frame marker and the segment after it (from its length bytes), found as the
/// decoders find it: fill bytes are skipped and markers without a length stepped over. `None` for
/// anything but a JPEG, or one whose scan, end or bytes come before a frame.
fn frame(bytes: &[u8]) -> Option<(u8, &[u8])> {
if !bytes.starts_with(&[0xFF, 0xD8]) {
return None;
}
let mut at = 2;
loop {
if *bytes.get(at)? != 0xFF {
return None;
}
let marker = *bytes.get(at + 1)?;
match marker {
0xFF => at += 1,
0x01 | 0xD0..=0xD8 => at += 2,
0xD9 | 0xDA => return None,
0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return Some((marker, bytes.get(at + 2..)?)),
_ => {
let len = usize::from(u16::from_be_bytes([*bytes.get(at + 2)?, *bytes.get(at + 3)?]));
at = at.checked_add(2)?.checked_add(len)?;
}
}
}
}
+86
View File
@@ -0,0 +1,86 @@
// Synthetic JPEGs for the header check in `jpeg.rs`, shared by its tests here and the decoder
// tests in `figcraft-render` (`include!`d by both).
/// An 8 × 8 progressive RGB JPEG with every component sampled 1 × 1, as jpeg-encoder 0.6 writes it
/// at quality 50 (JFIF header, the standard tables, three scans). Its frame header (SOF2) starts at
/// byte 20; the second component's sampling byte is [`SECOND_SAMPLING`], the third's follows 3 on.
const TINY_PROGRESSIVE: &[u8] = &[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xC2, 0x00, 0x11,
0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x00, 0x11, 0x00, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E,
0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33,
0x3D, 0x3C, 0x39, 0x33, 0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68,
0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F,
0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00,
0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24,
0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A,
0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A,
0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99,
0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1,
0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00,
0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31,
0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15,
0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39,
0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97,
0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4,
0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA,
0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6F, 0xFF, 0xDA, 0x00, 0x08,
0x01, 0x01, 0x11, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00,
0x00, 0x01, 0x14, 0x00, 0x4D, 0xB0, 0x97, 0x8E, 0x45, 0x01, 0x08, 0x03, 0x70, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x01, 0x14, 0x00,
0xAD, 0x17, 0xA5, 0x4A, 0x6C, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x01, 0x14, 0x00, 0x8A, 0x75, 0xA1, 0xCB, 0x6D, 0x62, 0x7F, 0xFF, 0xDA,
0x00, 0x08, 0x01, 0x00, 0x00, 0x15, 0x29, 0x00, 0xE0, 0x75, 0x1C, 0x8C, 0x74, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x15, 0x29, 0x00,
0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x15, 0x29, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x2A, 0x3F, 0x00, 0xC8, 0x3C,
0x7E, 0x1F, 0x79, 0x07, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x2A, 0x3F, 0x00, 0xFE, 0x96, 0x8E, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02,
0x11, 0x2A, 0x3F, 0x00, 0xF9, 0x36, 0xFF, 0x00, 0x99, 0xFB, 0xDF, 0xFF, 0xD9,
];
const SECOND_SAMPLING: usize = 34;
/// An 8 × 8 baseline CMYK JPEG as jpeg-encoder 0.6 writes it at quality 50 with 2 × 2 sampling:
/// its black is sampled 2 × 2 against 1 × 1 for the others (SOF0 at byte 36), a well-formed file
/// of the shape the check is about, in a baseline frame.
const TINY_BASELINE_CMYK: &[u8] = &[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xEE, 0x00, 0x0E,
0x41, 0x64, 0x6F, 0x62, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xC0, 0x00, 0x14, 0x08, 0x00, 0x08, 0x00, 0x08, 0x04, 0x00, 0x11,
0x01, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0x03, 0x22, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D,
0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33, 0x38,
0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79,
0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42,
0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02,
0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51,
0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09,
0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47,
0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77,
0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5,
0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2,
0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6,
0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04,
0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07,
0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16,
0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46,
0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76,
0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3,
0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9,
0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6,
0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x0E, 0x04, 0x00, 0x11, 0x01, 0x11, 0x02, 0x11, 0x03, 0x00, 0x00, 0x3F, 0x00, 0xEE, 0xA6, 0xAA, 0x2A,
0xB3, 0x83, 0x95, 0xED, 0x6D, 0x75, 0xBB, 0xF5, 0xB4, 0x97, 0x6D, 0xB6, 0x5A, 0xD9, 0x2B, 0x8D, 0x39, 0x3A, 0xB3, 0x8F, 0x54, 0xFB, 0xCB, 0xAF,
0xFD, 0xBD, 0x7E, 0x9D, 0xDA, 0xEC, 0x96, 0xB7, 0xE2, 0x95, 0x47, 0x2A, 0x8E, 0x0B, 0x46, 0xB7, 0x77, 0x7A, 0xDD, 0xBE, 0xCD, 0x3F, 0xBD, 0xBF,
0x2B, 0x6C, 0x0E, 0xC3, 0x6E, 0xE9, 0xB6, 0xCA, 0xE0, 0x07, 0x1B, 0xC9, 0x21, 0x95, 0x4E, 0x72, 0x54, 0x0C, 0x8E, 0x00, 0x62, 0x00, 0x5C, 0x28,
0x45, 0xEF, 0x51, 0xFF, 0x00, 0x6B, 0xDA, 0xFF, 0x00, 0x76, 0xC7, 0xFF, 0x00, 0x01, 0x27, 0xA1, 0xFF, 0x00, 0xE3, 0xFB, 0xFE, 0xDD, 0x65, 0xFF,
0x00, 0xD1, 0x31, 0xD6, 0x35, 0x7F, 0xFF, 0xD9,
];
fn with_second_sampled(hv: u8) -> Vec<u8> {
let mut b = TINY_PROGRESSIVE.to_vec();
b[SECOND_SAMPLING] = hv;
b
}
+65
View File
@@ -0,0 +1,65 @@
//! A progressive JPEG whose frame samples a component more finely than its first is refused from
//! its header, not handed to the decoder (zune-jpeg 0.5.15 asserts on it, and in the browser a
//! panic aborts the page): here for the header check itself and for the pictures files carry.
use std::io::Cursor;
use crate::jpeg::finer_than_first;
use crate::thumbnail::{archive_preview, decode_thumbnail};
include!("jpeg_fixtures.rs");
#[test]
fn the_jpeg_as_written_is_not_finer_than_its_first() {
assert_eq!(TINY_PROGRESSIVE[SECOND_SAMPLING], 0x11);
assert!(!finer_than_first(TINY_PROGRESSIVE));
// Chroma sampled coarser than luma (the usual way round) isn't finer either.
assert!(!finer_than_first(&{
let mut b = TINY_PROGRESSIVE.to_vec();
b[SECOND_SAMPLING - 3] = 0x22;
b
}));
}
#[test]
fn a_progressive_jpeg_sampling_a_component_finer_than_its_first_is_caught_from_its_header() {
for hv in [0x22, 0x21, 0x12] {
assert!(finer_than_first(&with_second_sampled(hv)), "second component {hv:#x}");
}
let mut third = TINY_PROGRESSIVE.to_vec();
third[SECOND_SAMPLING + 3] = 0x22;
assert!(finer_than_first(&third));
}
#[test]
fn baseline_frames_and_other_files_are_not_caught() {
// A well-formed baseline frame with its black sampled finer than its cyan: the decoder takes it.
assert!(!finer_than_first(TINY_BASELINE_CMYK));
// Not JPEGs, or JPEGs that end before their frame.
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert!(!finer_than_first(&png));
assert!(!finer_than_first(&TINY_PROGRESSIVE[..19]));
assert!(!finer_than_first(&[0xFF, 0xD8]));
assert!(!finer_than_first(&[]));
}
/// Without the check in `decode_thumbnail` this test panics in the decoder (an assertion in
/// zune-jpeg's upsampler): a crafted file's thumbnail, on the home screen and in recent files.
#[test]
fn a_thumbnail_sampling_a_component_finer_than_its_first_gives_no_picture() {
for hv in [0x22, 0x21, 0x12] {
assert_eq!(decode_thumbnail(&with_second_sampled(hv), 64), None, "second component {hv:#x}");
}
let mut zip = Cursor::new(Vec::new());
let mut z = zip::ZipWriter::new(&mut zip);
z.start_file("thumbnail.png", zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut z, &with_second_sampled(0x22)).unwrap();
z.finish().unwrap();
assert_eq!(archive_preview(Cursor::new(zip.into_inner()), 64).picture, None);
// The well-formed ones still give their picture.
for good in [TINY_PROGRESSIVE, TINY_BASELINE_CMYK] {
let t = decode_thumbnail(good, 64).expect("decoded");
assert_eq!((t.width, t.height), (8, 8));
}
}
+3
View File
@@ -18,6 +18,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod fig;
pub mod jpeg;
pub mod library;
pub mod penpot;
pub mod rest;
@@ -987,6 +988,8 @@ mod image_budget_tests;
#[cfg(test)]
mod image_tests;
#[cfg(test)]
mod jpeg_tests;
#[cfg(test)]
mod large_tests;
#[cfg(test)]
mod staging_tests;
+11
View File
@@ -46,6 +46,17 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData {
_ => cx.warn("invalid internal .fig text path data was ignored"),
}
}
// The line breaks the .fig file saved, for the box width the layer imports with.
let width = v.get("size").and_then(super::props::vec2).map(|(w, _)| w.clamp(0.0, super::props::MAX_SIZE));
if cx.fig_sources.is_some()
&& td.path.is_none()
&& td.auto_resize != TextAutoResize::WidthAndHeight
&& let (Some(starts), Some(width)) = (v.get("_figLineStarts").and_then(Value::as_array), width)
{
let n = td.char_count();
let starts = starts.iter().filter_map(Value::as_u64).filter_map(|s| usize::try_from(s).ok()).filter(|s| *s < n).collect();
td.save_breaks(starts, width);
}
td
}
+3
View File
@@ -42,6 +42,9 @@ pub fn sketch_preview_png<R: Read + Seek>(archive: R) -> Option<Vec<u8>> {
/// Decode a picture (PNG, JPEG, WebP, GIF, BMP or TIFF) no larger than [`MAX_THUMBNAIL_SIDE`] on
/// either side, scaled down to at most `fit` pixels on its longer side (never up).
pub fn decode_thumbnail(bytes: &[u8], fit: u32) -> Option<Thumbnail> {
if crate::jpeg::finer_than_first(bytes) {
return None;
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_THUMBNAIL_SIDE);
+21 -4
View File
@@ -55,9 +55,14 @@ impl Headless {
}
fn execute(&mut self, cmd: &str, params: &Value) -> Result<Value, String> {
self.execute_as(None, cmd, params)
}
/// A command for `agent` (an AI agent's name, `engine.execute {agent}`) or for the user.
fn execute_as(&mut self, agent: Option<&str>, cmd: &str, params: &Value) -> Result<Value, String> {
// What happened in a live session since the last call (latest presence only).
self.session.live_poll();
let r = self.session.execute(cmd, params).map_err(|e| e.to_string());
let r = self.session.execute_as(agent, cmd, params).map_err(|e| e.to_string());
self.session.live_sync();
// No UI: dialogs and panels requested by commands are dropped.
self.session.ui_requests.clear();
@@ -234,8 +239,8 @@ impl Headless {
}
}
impl Backend for Headless {
fn call(&mut self, method: &str, p: Value) -> Result<Value, String> {
impl Headless {
fn dispatch(&mut self, method: &str, p: Value) -> Result<Value, String> {
match method {
"engine.execute" => {
let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?.to_string();
@@ -243,7 +248,7 @@ impl Backend for Headless {
None | Some(Value::Null) => json!({}),
Some(v) => v.clone(),
};
self.execute(&cmd, &params)
self.execute_as(p.get("agent").and_then(Value::as_str), &cmd, &params)
}
"engine.commands" => Ok(Value::Array(
figcraft_engine::all_commands().iter().map(|c| json!({"id": c.id, "label": c.label, "params": c.params, "menu": c.menu(), "shortcut": c.shortcut(), "undoable": c.history})).collect(),
@@ -296,6 +301,18 @@ impl Backend for Headless {
other => Err(format!("unknown method `{other}`")),
}
}
}
impl Backend for Headless {
/// With `document` (an `id` from `doc.list`, or an open file's path) the call acts on that
/// file, and the file in front stays in front.
fn call(&mut self, method: &str, p: Value) -> Result<Value, String> {
let Some(target) = p.get("document").filter(|d| !d.is_null()) else { return self.dispatch(method, p) };
let forward = self.session.bring_forward(target).map_err(|e| e.to_string())?;
let r = self.dispatch(method, p);
self.session.put_back(forward);
r
}
fn has_ui(&self) -> bool {
false
+2 -2
View File
@@ -86,7 +86,7 @@ pub const PROMPTS: &[PromptDef] = &[
],
template: r#"Fix the open comment threads in this FigCraft document.
1. Read them: run_command {"command": "comment.list", "params": {"page": "{page}", "filter": "open"}}. Each thread has an id, text, pageName, frameName, anchor (a node id; null if the comment is only pinned to a page point), x/y, author, replies and link.
1. Read them: run_command {"command": "comment.list", "params": {"page": "{page}", "filter": "open"}}. Each thread has an id, text, pageName, frameName, anchor (a node id; null if the comment is only pinned to a page point), x/y, author, mentions (the names a message @mentions: {name, known, you}), replies and link.
2. For each thread, in list order:
a. Inspect the anchor with get_node {"id": <anchor>, "depth": 2} (or get_node on frame if anchor is null), and look at the frame: screenshot {"ids": [<frame>]}.
b. Make the smallest change that answers the comment with set_properties, create_node or run_command. Put the edits for one thread in one batch so the user can undo them in one step. Do not edit layers the comment does not mention.
@@ -104,7 +104,7 @@ pub const PROMPTS: &[PromptDef] = &[
template: r#"Watch for new feedback in this FigCraft document for {minutes} minutes.
1. Start the watcher: wait_for_changes {"after": 0, "timeoutMs": 0, "topics": ["comments"]}. Keep its `cursor` and note `reset`; if the returned cursor is 0, remember that the initial cursor was zero.
2. Handle what is already open: run_command {"command": "comment.list", "params": {"page": "all", "filter": "open"}}. For each thread: inspect the anchor and frame, make the smallest change (one batch per thread), comment.reply with what changed (author "AI"), verify, then comment.resolve. Leave ambiguous threads open with a question.
2. Handle what is already open: run_command {"command": "comment.list", "params": {"page": "all", "filter": "open"}}. For each thread: inspect the anchor and frame, make the smallest change (one batch per thread), comment.reply with what changed (it is signed with your own agent name), verify, then comment.resolve. Leave ambiguous threads open with a question.
3. Then loop until the budget ends: wait_for_changes {"after": <cursor>, "timeoutMs": 20000, "topics": ["comments"]}. Set cursor to the result's cursor. For each comment.added or comment.replied event, read the thread with comment.list. If its latest message is your own reply, skip it (comment.reply emits comment.replied too); otherwise fix it the same way. If reset is true, dropped is greater than 0, or the cursor was 0 before this poll and the returned cursor is greater than 0, re-read every open thread and continue with the returned cursor.
4. Stop after {minutes} minutes; summarize the threads you fixed and the ones still open (and why)."#,
defaults: &[("minutes", "5")],
+56 -3
View File
@@ -52,6 +52,46 @@ pub struct Server {
/// The automation roots every request's file access is confined to (`--automation-read-root`,
/// `--automation-write-root`), if any.
roots: Option<Arc<Roots>>,
/// The client's name from `initialize` (`clientInfo.title`, else `.name`; empty until then):
/// every command runs as this AI agent, so its comments are signed with it, not the user's name.
agent: String,
}
/// The backend as the MCP client's AI agent: `engine.execute` carries its name (`agent`).
struct AsAgent<'a> {
inner: &'a mut dyn Backend,
agent: &'a str,
}
impl Backend for AsAgent<'_> {
fn call(&mut self, method: &str, mut params: Value) -> Result<Value, String> {
if method == "engine.execute"
&& let Some(p) = params.as_object_mut()
{
p.insert("agent".into(), json!(self.agent));
}
self.inner.call(method, params)
}
fn has_ui(&self) -> bool {
self.inner.has_ui()
}
fn describe(&self) -> String {
self.inner.describe()
}
fn render_png(&mut self, ids: &[u64], scale: f64) -> Result<Vec<u8>, String> {
self.inner.render_png(ids, scale)
}
fn screenshot_png(&mut self) -> Result<Vec<u8>, String> {
self.inner.screenshot_png()
}
fn window_png(&mut self, window: u64) -> Result<Vec<u8>, String> {
self.inner.window_png(window)
}
}
fn response(id: Value, result: Value) -> Value {
@@ -62,13 +102,20 @@ fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
}
/// The name an `initialize` request gives its client (`clientInfo.title`, else `.name`; empty
/// without one). The engine keeps 64 characters of it; this only bounds what each call carries.
fn client_name(params: &Value) -> String {
let name = |k: &str| params.get("clientInfo").and_then(|i| i.get(k)).and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty());
name("title").or_else(|| name("name")).unwrap_or_default().chars().take(256).collect()
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload.downcast_ref::<&str>().map(ToString::to_string).or_else(|| payload.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self { backend, initialized: false, roots: None }
Self { backend, initialized: false, roots: None, agent: String::new() }
}
/// Confine the files every request reads and writes to `roots` (see
@@ -192,9 +239,15 @@ impl Server {
}
}
/// The backend, acting as the client's agent.
fn as_agent(&mut self) -> AsAgent<'_> {
AsAgent { inner: self.backend.as_mut(), agent: &self.agent }
}
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => {
self.agent = client_name(params);
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
Ok(json!({
@@ -219,13 +272,13 @@ impl Server {
if !crate::tools::TOOL_NAMES.contains(&name) {
return Err((INVALID_PARAMS, format!("unknown tool `{name}`")));
}
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
Ok(call_tool(&mut self.as_agent(), name, &args).to_value())
}
"resources/list" => Ok(resources::list()),
"resources/templates/list" => Ok(resources::templates()),
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri` (a string)".to_string()))?;
let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e {
let v = resources::read(&mut self.as_agent(), uri).map_err(|e| match e {
resources::ReadError::NotFound(uri) => (RESOURCE_NOT_FOUND, format!("resource not found: {uri}")),
resources::ReadError::Backend(why) => (INTERNAL_ERROR, why),
})?;
+64 -2
View File
@@ -105,8 +105,39 @@ pub const TOOL_NAMES: &[&str] = &[
"wait_for_changes",
];
/// The tools that take `document`: they act on the file it names instead of the one in front.
const DOCUMENT_TOOLS: &[&str] = &[
"run_command",
"batch",
"create_node",
"set_properties",
"inspect_document",
"get_node",
"select",
"export",
"screenshot",
"save_file",
"undo",
"redo",
];
/// All tools, in `tools/list` order.
pub fn tool_definitions() -> Vec<Value> {
let mut tools = tools();
let document = id_schema(
"The open file to act on: its `id` (from run_command doc.list, or the `document` open_file and inspect_document return) or its path. Default: the file in front. The tab in front stays in front; with an attached app, a screenshot of the window needs the file in front",
);
for t in &mut tools {
if t.get("name").and_then(Value::as_str).is_some_and(|n| DOCUMENT_TOOLS.contains(&n))
&& let Some(props) = t.pointer_mut("/inputSchema/properties").and_then(Value::as_object_mut)
{
props.insert("document".into(), document.clone());
}
}
tools
}
fn tools() -> Vec<Value> {
let empty = || obj(json!({}), &[]);
vec![
tool(
@@ -129,7 +160,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"batch",
"Run commands as one step",
"Run several commands in order as ONE undo step (engine `command.batch`). By default (onError: \"abort\"), if any command fails, everything is rolled back and the error names the failing step; success returns an array of the results in order. With onError: \"continue\", a failed step is rolled back on its own, the later steps still run, the call is not an error, and the array instead holds one {step, command, ok, result} or {step, command, ok, error} object per step; a reference to a failed step fails that step too. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned. Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.",
"Run several commands in order as ONE undo step (engine `command.batch`). By default (onError: \"abort\"), if any command fails, everything is rolled back and the error names the failing step; success returns an array of the results in order. With onError: \"continue\", a failed step is rolled back on its own, the later steps still run, the call is not an error, and the array instead holds one {step, command, ok, result} or {step, command, ok, error} object per step; a reference to a failed step fails that step too. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned (with \"map\": true it also returns `map`, each original id → its copy, so \"field\": \"map/3\" is the copy of layer 3). Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.",
obj(
json!({
"commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}, "as": string("Name for this step, unique in the batch; later steps use its result with {\"$ref\": name}")}), &["command"])},
@@ -346,6 +377,29 @@ pub fn tool_definitions() -> Vec<Value> {
// ---------- helpers ----------
/// A backend whose every call names `document` (a tool's `document` argument).
struct InDocument<'a> {
inner: &'a mut dyn Backend,
document: Value,
}
impl Backend for InDocument<'_> {
fn call(&mut self, method: &str, mut params: Value) -> Result<Value, String> {
if let Some(o) = params.as_object_mut() {
o.insert("document".into(), self.document.clone());
}
self.inner.call(method, params)
}
fn has_ui(&self) -> bool {
self.inner.has_ui()
}
fn describe(&self) -> String {
self.inner.describe()
}
}
fn exec(b: &mut dyn Backend, cmd: &str, params: Value) -> Result<Value, String> {
b.call("engine.execute", json!({"command": cmd, "params": params}))
}
@@ -468,7 +522,7 @@ fn refuse_unknown_args(schema: &Value, args: &Value, path: &str) -> Result<(), S
/// Run one tool. Every failure becomes an `isError` result (never a panic).
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
let a = match args_obj(args) {
let mut a = match args_obj(args) {
Ok(a) => a,
Err(e) => return ToolResult::error(e),
};
@@ -479,6 +533,14 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
{
return ToolResult::error(e);
}
let mut targeted;
let b: &mut dyn Backend = match a.remove("document").filter(|d| !d.is_null()) {
Some(document) => {
targeted = InDocument { inner: b, document };
&mut targeted
}
None => b,
};
let r = match name {
"list_commands" => list_commands(b, &a),
"run_command" => run_command(b, &a),
+50
View File
@@ -0,0 +1,50 @@
//! An agent attached over MCP comments under its own name (#1414): the client's name from
//! `initialize` (`clientInfo.title`, else `.name`), marked as an agent's, or "AI agent".
use figcraft_mcp::Server;
use serde_json::{Value, json};
use crate::common::Client;
fn rpc(s: &mut Server, method: &str, params: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).to_string();
serde_json::from_str(&s.handle_line(&line).unwrap()).unwrap()
}
fn run(s: &mut Server, command: &str, params: Value) -> Value {
let r = rpc(s, "tools/call", json!({"name": "run_command", "arguments": {"command": command, "params": params}}));
assert_eq!(r["result"]["isError"], false, "{r}");
serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()
}
/// The first message's (author, agent marker) after the server comments once.
fn signed(s: &mut Server) -> (String, Value) {
run(s, "comment.add", json!({"x": 10, "y": 10, "text": "Made it bigger"}));
let t = run(s, "comment.list", json!({"filter": "all"}))["comments"][0].clone();
(t["author"].as_str().unwrap().to_string(), t["agent"].clone())
}
#[test]
fn an_agent_comments_under_its_clients_name() {
let mut s = Server::headless();
rpc(
&mut s,
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "claude-code", "title": "Claude", "version": "1"}}),
);
assert_eq!(signed(&mut s), ("Claude (agent)".into(), json!(true)), "the title wins over the name");
let mut c = Client::new();
c.run("comment.add", json!({"x": 0, "y": 0, "text": "Hi"}));
let t = c.run("comment.list", json!({}))["comments"][0].clone();
assert_eq!((t["author"].clone(), t["agent"].clone()), (json!("test (agent)"), json!(true)), "the name without a title");
assert_eq!(c.run("comment.people", json!({}))["you"], "test (agent)", "the agent is itself on every call");
}
#[test]
fn an_agent_without_a_name_is_still_an_agent() {
let mut s = Server::headless();
assert_eq!(signed(&mut s), ("AI agent".into(), json!(true)), "no initialize");
let mut s = Server::headless();
rpc(&mut s, "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": " ", "version": "1"}}));
assert_eq!(signed(&mut s), ("AI agent".into(), json!(true)), "a blank name");
}
+35
View File
@@ -64,6 +64,21 @@ fn attached_backend_forwards_everything() {
assert_eq!(err["isError"], true);
}
#[test]
fn an_attached_agent_comments_under_its_own_name() {
let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap()));
let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "Claude", "version": "1"}}});
assert!(s.handle_line(&init.to_string()).is_some());
let run = |s: &mut Server, command: &str, params: Value| {
let r = call(s, "run_command", json!({"command": command, "params": params}));
assert_eq!(r["isError"], false, "{r}");
serde_json::from_str::<Value>(r["content"][0]["text"].as_str().unwrap()).unwrap()
};
run(&mut s, "comment.add", json!({"x": 10, "y": 10, "text": "Done"}));
let t = run(&mut s, "comment.list", json!({}))["comments"][0].clone();
assert_eq!((t["author"].clone(), t["agent"].clone()), (json!("Claude (agent)"), json!(true)), "{t}");
}
#[test]
fn attached_to_nothing_fails_cleanly() {
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
@@ -146,3 +161,23 @@ fn reconnect_failure_reports_request_not_sent() {
let error = backend.call("document.inspect", json!({})).unwrap_err();
assert!(error.contains("request was not sent"), "{error}");
}
#[test]
fn a_tool_call_naming_its_document_edits_that_file_and_not_the_one_in_front() {
let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap()));
let text = |r: &Value| -> Value { serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap() };
let back = text(&call(&mut s, "inspect_document", json!({"depth": 0})))["id"].clone();
assert!(back.is_u64(), "inspect_document names the file: {back}");
assert_eq!(call(&mut s, "run_command", json!({"command": "file.new", "params": {"name": "Front"}}))["isError"], false);
let r = call(&mut s, "create_node", json!({"type": "rectangle", "document": back}));
assert_eq!(r["isError"], false, "{r}");
let list = text(&call(&mut s, "run_command", json!({"command": "doc.list"})));
assert_eq!((list[0]["id"].clone(), list[0]["active"].clone(), list[1]["active"].clone()), (back.clone(), json!(false), json!(true)));
let front = text(&call(&mut s, "inspect_document", json!({"depth": 1})));
assert_eq!((front["name"].as_str(), front["pages"][0]["children"].as_array().map_or(0, Vec::len)), (Some("Front"), 0));
let behind = text(&call(&mut s, "inspect_document", json!({"depth": 1, "document": back})));
assert_eq!(behind["pages"][0]["children"].as_array().map_or(0, Vec::len), 1, "{behind}");
let e = call(&mut s, "inspect_document", json!({"document": 999_999}));
assert_eq!(e["isError"], true);
assert_eq!(call(&mut s, "pointer", json!({"x": 0, "y": 0, "document": back}))["isError"], true, "pointer takes no `document`");
}
+2
View File
@@ -1,5 +1,7 @@
//! The crate's integration tests, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod agent_comments;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod agent_tasks;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+1 -1
View File
@@ -135,7 +135,7 @@ fn tools_call_happy_and_error_paths() {
assert!(c.err("open_file", json!({"path": "/definitely/not/here.figcraft"})).contains("here.figcraft"));
// Names a tool or a command doesn't take are refused, not ignored (#1413).
let e = c.err("run_command", json!({"command": "inspect.code", "args": {"id": id, "language": "html"}}));
assert!(e.contains("unknown argument `args` (it takes command, params)"), "{e}");
assert!(e.contains("unknown argument `args` (it takes command, document, params)"), "{e}");
let e = c.err("batch", json!({"commands": [{"command": "comment.list", "args": {"filter": "open"}}]}));
assert!(e.contains("unknown argument `commands[0].args`"), "{e}");
let e = c.err("run_command", json!({"command": "comment.list", "params": {"filterr": "open"}}));
+7 -6
View File
@@ -11,7 +11,7 @@
//! node on the page, and placed under its content.
use figcraft_color::BlendMode;
use figcraft_doc::{EffectKind, NodeId, NodeKind};
use figcraft_doc::{EffectKind, NodeId};
use figcraft_geom::{Affine, Rect};
use figcraft_render::{Alone, Pixmap, View, backdrop, shadow};
use krilla::geom::Size;
@@ -164,11 +164,12 @@ impl Exporter<'_> {
}
}
EffectKind::InnerShadow(sh) => {
// A group is no outline: its layers' pixels, together, are what is shaded.
let inner = match &shape {
Some(o) => shadow::inner_shadow(o, w, h, xf, view.scale, sh),
None if matches!(n.kind, NodeKind::Group) => shadow::inner_shadow_of(&layer, view.scale, sh),
None => None,
// A group is no outline, and image fills paint their own alpha: the layer's
// pixels are what is shaded.
let inner = if shadow::inner_from_pixels(doc, n) {
shadow::inner_shadow_of(&layer, view.scale, sh)
} else {
shape.as_ref().and_then(|o| shadow::inner_shadow(o, w, h, xf, view.scale, sh))
};
if let Some(p) = inner {
out.inner.push(Raster::new(&p, rect, self.ink()));
+1
View File
@@ -12,6 +12,7 @@ workspace = true
figcraft-geom = { workspace = true }
figcraft-color = { workspace = true }
figcraft-doc = { workspace = true }
figcraft-format = { workspace = true }
figcraft-text = { workspace = true }
figcraft-pathops = { workspace = true }
tiny-skia = { workspace = true }
+98 -39
View File
@@ -30,6 +30,9 @@ pub struct Ctx<'a, 'd> {
pub skip: &'a [NodeId],
/// Approximate what's too small to see in detail (canvas previews; see [`crate::lod`]).
pub lod: bool,
/// A canvas preview at any zoom ([`crate::Options::lod`]): large images shown smaller than
/// their pixels draw from smaller copies (see [`crate::images::shown`]).
pub preview: bool,
pub magnified_image_pixels: bool,
/// The surfaces under the one being drawn into, when it is an isolated layer (for backdrops).
pub under: Option<&'a Under<'a>>,
@@ -130,7 +133,7 @@ fn pixels_when_magnified(prep: Prepared, device_scale: f64) -> Prepared {
fn fill_with(pix: &mut Pixmap, path: &tiny_skia::Path, paints: &[Paint], w: f64, h: f64, xf: Affine, rule: FillRule, ctx: &Ctx, mask: Option<&Mask>) {
let scale = xf.determinant().abs().sqrt();
for p in paints {
let Some(prep) = prepare_at(p, w, h, scale, ctx.doc, ctx.time) else { continue };
let Some(prep) = prepare_at(p, w, h, scale, ctx.doc, ctx.time, ctx.preview) else { continue };
let prep = if ctx.magnified_image_pixels && matches!(p.kind, PaintKind::Image(_)) { pixels_when_magnified(prep, scale) } else { prep };
let paint = tiny_skia::Paint { shader: prep.shader(), anti_alias: true, blend_mode: ts_blend(p.blend), ..Default::default() };
if p.blend == BlendMode::PlusDarker {
@@ -480,9 +483,13 @@ pub fn effect_margin(n: &Node) -> f64 {
EffectKind::BackgroundBlur { .. } | EffectKind::Glass(_) => e.backdrop_radius() * 2.0,
EffectKind::DropShadow(s) => s.radius * 2.0 + s.spread.abs() * figcraft_doc::SPREAD_MITER_LIMIT + s.offset.0.abs() + s.offset.1.abs(),
EffectKind::Texture(t) => t.radius.abs(),
// A group's inner shadows are cast from around its pixels: a layer cut off at the
// view would cast one from the cut.
EffectKind::InnerShadow(s) if matches!(n.kind, NodeKind::Group) => s.radius * 2.0 + s.spread.abs() + s.offset.0.abs() + s.offset.1.abs(),
// Inner shadows cast from around a layer's pixels (groups, image fills with alpha; any
// image counts here): a layer cut off at the view would cast one from the cut.
EffectKind::InnerShadow(s)
if matches!(n.kind, NodeKind::Group) || n.fills.iter().any(|f| matches!(f.kind, PaintKind::Image(_) | PaintKind::Pattern(_))) =>
{
s.radius * 2.0 + s.spread.abs() + s.offset.0.abs() + s.offset.1.abs()
}
_ => 0.0,
})
.filter(|m| m.is_finite())
@@ -587,6 +594,8 @@ fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: u
let (lx, ly) = (x0 as i32, y0 as i32);
let lw = w.min(16_384.0) as u32;
let lh = h.min(16_384.0) as u32;
// Nested layers hold their pixmaps while the ones inside them draw: within one budget.
let Some(_held) = crate::layer_budget::Held::take(lw, lh, 1) else { return };
let Some(mut layer) = Pixmap::new(lw, lh) else { return };
let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf;
let shape = node_outline(ctx.doc, n);
@@ -607,12 +616,16 @@ fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: u
}
let op = n.opacity.clamp(0.0, 1.0);
{
// Inner shadows paint over the fills, under the children and strokes.
// Inner shadows paint over the fills, under the children and strokes: cast from around the
// outline, or around the fills' pixels where images paint their own alpha.
let inner = |l: &mut Pixmap| {
let fills = shadow::inner_from_pixels(ctx.doc, n).then(|| l.clone());
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::InnerShadow(s) = &e.kind
&& let Some(sh) = &shape
&& let Some(inner) = shadow::inner_shadow(sh, lw, lh, lxf, ctx.scale, s)
&& let Some(inner) = match &fills {
Some(f) => shadow::inner_shadow_of(f, ctx.scale, s),
None => shape.as_ref().and_then(|sh| shadow::inner_shadow(sh, lw, lh, lxf, ctx.scale, s)),
}
{
l.draw_pixmap(0, 0, inner.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
}
@@ -883,52 +896,98 @@ fn draw_children(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize
}
}
fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine, depth: usize) {
let (w, h) = (pix.width(), pix.height());
let Some(mut mask_px) = Pixmap::new(w, h) else { return };
let mask_type = ctx.doc.node(mask_id).map(|m| m.mask_type).unwrap_or_default();
// Draw the mask node as if it weren't a mask (a vector mask: its outline, fully opaque).
if let Some(m) = ctx.doc.node(mask_id) {
let mut m2 = m.clone();
m2.is_mask = false;
let mxf = xf * m2.transform;
if mask_type == figcraft_doc::MaskType::Vector {
if let Some((outline, rule)) = subtree_outline(ctx.doc, &m2, depth + 1)
&& let Some(path) = ts_path(&outline)
{
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(255, 255, 255, 255);
paint.anti_alias = true;
mask_px.fill_path(&path, &paint, rule, ts_transform(mxf), None);
}
} else if needs_layer(&m2) {
let paint = PixmapPaint { opacity: m2.opacity, ..Default::default() };
let mut tmp = Pixmap::new(w, h);
if let Some(tmp) = tmp.as_mut() {
draw_content(ctx, tmp, &m2, mxf, depth + 1, None);
mask_px.draw_pixmap(0, 0, tmp.as_ref(), &paint, Transform::identity(), None);
}
} else {
draw_content(ctx, &mut mask_px, &m2, mxf, depth + 1, None);
}
#[cfg(test)]
thread_local! {
/// Tests: mask groups draw over the whole pixmap, as they did before [`masked_area`].
pub(crate) static WHOLE_MASK_AREA: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The pixels of `pix` a mask group (mask `mask_id`, then `content`, drawn at `xf`) can paint,
/// rounded out to whole pixels: where the mask or the content reach, effects included (#1061).
fn masked_area(ctx: &Ctx, pix: &Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine) -> Option<IntRect> {
let size = (pix.width(), pix.height());
let whole = Rect::new(0.0, 0.0, f64::from(size.0), f64::from(size.1));
#[cfg(test)]
if WHOLE_MASK_AREA.with(std::cell::Cell::get) {
return int_rect(whole, size);
}
let reach = |id: &NodeId| {
let n = ctx.bounds.node(ctx.doc, *id)?;
let (local, levels) = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds));
// Bounds the nesting cap cut short could miss a layer: anywhere then.
if levels.is_none() {
return Some(whole);
}
// A pixel more for antialiasing.
let m = (effect_margin(n) * ctx.scale).min(MAX_EFFECT_MARGIN_PX) + 1.0;
Some(device_rect(xf * n.transform, local).inflate(m, m))
};
let area = std::iter::once(&mask_id).chain(content).filter_map(reach).reduce(|a, b| a.union(b))?;
int_rect(area, size)
}
/// 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> {
let mut mask_px = Pixmap::new(w, h)?;
let mut m2 = m.clone();
m2.is_mask = false;
let mxf = xf * m2.transform;
if m.mask_type == figcraft_doc::MaskType::Vector {
if let Some((outline, rule)) = subtree_outline(ctx.doc, &m2, depth + 1)
&& let Some(path) = ts_path(&outline)
{
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(255, 255, 255, 255);
paint.anti_alias = true;
mask_px.fill_path(&path, &paint, rule, ts_transform(mxf), None);
}
} else if needs_layer(&m2) {
let paint = PixmapPaint { opacity: m2.opacity, ..Default::default() };
if let Some(mut tmp) = Pixmap::new(w, h) {
draw_content(ctx, &mut tmp, &m2, mxf, depth + 1, None);
mask_px.draw_pixmap(0, 0, tmp.as_ref(), &paint, Transform::identity(), None);
}
} else {
draw_content(ctx, &mut mask_px, &m2, mxf, depth + 1, None);
}
Some(mask_px)
}
fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine, depth: usize) {
// Only over the pixels the group can paint, not the whole pixmap.
let Some(area) = masked_area(ctx, pix, mask_id, content, xf) else { return };
let (ax, ay, w, h) = (area.x(), area.y(), area.width(), area.height());
// The mask (and its own layer), the content, and the pixels it goes onto.
let Some(_held) = crate::layer_budget::Held::take(w, h, 4) else { return };
let axf = Affine::translate((-f64::from(ax), -f64::from(ay))) * xf;
let m = ctx.doc.node(mask_id);
let mask_px = match m {
Some(m) => mask_pixels(ctx, m, (w, h), axf, depth),
None => Pixmap::new(w, h),
};
let Some(mask_px) = mask_px else { return };
let Some(mut layer) = Pixmap::new(w, h) else { return };
let kind = if mask_type == figcraft_doc::MaskType::Luminance { MaskType::Luminance } else { MaskType::Alpha };
let kind = if m.is_some_and(|m| m.mask_type == figcraft_doc::MaskType::Luminance) { MaskType::Luminance } else { MaskType::Alpha };
let mask = Mask::from_pixmap(mask_px.as_ref(), kind);
{
let under = Under { clip: Some(&mask), ..Under::new(pix, (0, 0), ctx.under) };
let under = Under { clip: Some(&mask), ..Under::new(pix, (ax, ay), ctx.under) };
let sub = Ctx { under: Some(&under), ..*ctx };
for c in content {
draw_node(&sub, &mut layer, *c, xf, depth + 1);
draw_node(&sub, &mut layer, *c, axf, depth + 1);
}
}
pix.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), Some(&mask));
// A mask applies to a pixmap of its own size: through it onto a copy of the area, put back.
let Some(mut onto) = pix.clone_rect(area) else { return };
onto.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), Some(&mask));
backdrop::replace(pix, (ax, ay), &onto, None);
}
fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, clip: Option<&BezPath>) {
let (pw, ph) = (pix.width(), pix.height());
let dev = device_rect(xf, n.local_rect());
let Some(ir) = int_rect(dev, (pw, ph)) else { return };
let Some(_held) = crate::layer_budget::Held::take(ir.width(), ir.height(), 1) else { return };
let (lx, ly) = (ir.x(), ir.y());
// The layer starts as what lies under it and goes back in its place through the clip:
// clipping cuts the children off without isolating them, so their blend modes reach the
+2 -2
View File
@@ -82,7 +82,7 @@ fn an_image_past_the_side_limit_is_drawn_downscaled() {
}
fn key(name: &str) -> images::Key {
(name.to_string(), [0; 7], ColorProfile::Srgb)
(name.to_string(), [0; 7], ColorProfile::Srgb, 0)
}
fn pixmap(side: u32) -> Arc<Pixmap> {
@@ -132,7 +132,7 @@ fn filter_and_profile_variants_count_against_one_budget() {
let mut c = images::PixmapCache::default();
for (i, profile) in [ColorProfile::Srgb, ColorProfile::DisplayP3].into_iter().enumerate() {
for f in 0..4 {
c.insert(("img".to_string(), [f, 0, 0, 0, 0, 0, i as i32], profile), pixmap(10), 1000);
c.insert(("img".to_string(), [f, 0, 0, 0, 0, 0, i as i32], profile, 0), pixmap(10), 1000);
assert!(c.bytes() <= 1000, "{} bytes held", c.bytes());
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Shadows of image fills follow what the image paints (#1289): a picture with transparent pixels
//! casts its drop shadow from its alpha and hides it only behind its pixels, and its inner shadow
//! shades the inside of its alpha; an opaque picture keeps its outline.
use std::sync::Arc;
use figcraft_color::{Color, ImageScaleMode, Paint};
use figcraft_doc::{Document, Effect, EffectKind, ImageData, NodeId, NodeKind, Shadow};
use super::{Pixmap, View, render_nodes_into, shadow};
/// A 200 × 200 PNG, red: a circle of radius 50 in its middle on transparent pixels (`circle`), or
/// all over.
fn png(circle: bool) -> Vec<u8> {
let img = image::RgbaImage::from_fn(200, 200, |x, y| {
let r = (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 100.0);
image::Rgba(if !circle || r <= 50.0 { [255, 0, 0, 255] } else { [0; 4] })
});
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// A 200 × 200 rectangle at (100, 100) filled with the image, with `effect`, at `opacity`.
fn scene(circle: bool, effect: EffectKind, opacity: f32) -> (Document, NodeId) {
let mut d = Document::new("image shadow");
let p = d.first_page().unwrap();
// Image ids key the shared decode cache: fixtures running in parallel need ids of their own.
let id = if circle { "image-shadow-tests-circle" } else { "image-shadow-tests-opaque" };
d.images.insert(id.into(), ImageData { bytes: Arc::new(png(circle)), width: 200, height: 200 });
let n = d.create(p, NodeKind::Rectangle, "R", 100.0, 100.0, 200.0, 200.0).unwrap();
let node = d.get_mut(n).unwrap();
node.fills = vec![Paint::image(id, ImageScaleMode::Fill)];
node.effects = vec![Effect { kind: effect, visible: true }];
node.opacity = opacity;
(d, n)
}
fn shadow(offset: (f64, f64), show_behind: bool) -> Shadow {
Shadow { color: Color::BLACK, offset, radius: 0.0, show_behind, ..Default::default() }
}
/// Node `id` drawn on a transparent 400 × 400 view of the page.
fn render(d: &Document, id: NodeId) -> Pixmap {
let mut pix = Pixmap::new(400, 400).unwrap();
render_nodes_into(d, &[id], &View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 }, &mut pix);
pix
}
/// The premultiplied RGBA at the page point `(x, y)`.
fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] {
let i = ((y * p.width() + x) * 4) as usize;
p.data()[i..i + 4].try_into().unwrap()
}
fn near(v: u8, want: u8) -> bool {
v.abs_diff(want) <= 3
}
#[test]
fn a_transparent_images_drop_shadow_follows_its_alpha() {
for show_behind in [false, true] {
// At half opacity, a shadow under the circle shows through it.
let (d, n) = scene(true, EffectKind::DropShadow(shadow((20.0, 20.0), show_behind)), 0.5);
assert!(shadow::cover(&d, d.node(n).unwrap()).is_none(), "the image's pixels hide its shadow, not its box");
let p = render(&d, n);
// Inside the box, outside the circle, inside the circle's shadow: the shadow.
let s = px(&p, 265, 220);
assert!(s[..3] == [0, 0, 0] && near(s[3], 128), "show behind {show_behind}: {s:?}");
// Inside the box, away from the circle and its shadow: nothing.
assert_eq!(px(&p, 150, 150), [0; 4]);
// Where the circle covers the shadow, the option decides.
let c = px(&p, 225, 225);
assert!(near(c[3], if show_behind { 191 } else { 128 }), "show behind {show_behind}: {c:?}");
}
}
#[test]
fn an_opaque_images_drop_shadow_still_hides_behind_its_box() {
let (d, n) = scene(false, EffectKind::DropShadow(shadow((20.0, 20.0), false)), 0.5);
let node = d.node(n).unwrap();
assert!(shadow::cover(&d, node).is_some() && shadow::silhouette(&d, node).is_some(), "an opaque fill keeps its outline");
let p = render(&d, n);
assert!(near(px(&p, 250, 250)[3], 128), "behind the picture: hidden");
let s = px(&p, 310, 250);
assert!(s[..3] == [0, 0, 0] && near(s[3], 128), "past it: the shadow {s:?}");
// Fit leaves part of a wider box empty: the pixels decide there too.
let (mut d, n) = scene(false, EffectKind::DropShadow(shadow((20.0, 20.0), false)), 0.5);
d.get_mut(n).unwrap().width = 300.0;
d.get_mut(n).unwrap().fills = vec![Paint::image("image-shadow-tests-opaque", ImageScaleMode::Fit)];
assert!(shadow::cover(&d, d.node(n).unwrap()).is_none());
}
#[test]
fn a_transparent_images_inner_shadow_shades_its_alpha() {
let (d, n) = scene(true, EffectKind::InnerShadow(shadow((0.0, 10.0), false)), 1.0);
let p = render(&d, n);
// The top 10 of the circle (it starts at y 150) is in shadow; its middle stays red.
assert_eq!(px(&p, 200, 155), [0, 0, 0, 255]);
assert_eq!(px(&p, 200, 200), [255, 0, 0, 255]);
// The box's top edge is transparent: no shadow there.
assert_eq!(px(&p, 200, 105), [0; 4]);
// An opaque picture is shaded from its box's edge, as before.
let (d, n) = scene(false, EffectKind::InnerShadow(shadow((0.0, 10.0), false)), 1.0);
let p = render(&d, n);
assert_eq!(px(&p, 200, 105), [0, 0, 0, 255]);
assert_eq!(px(&p, 200, 200), [255, 0, 0, 255]);
}
+64 -55
View File
@@ -1,6 +1,10 @@
//! Decoded image cache (content hash + filters + colour profile → premultiplied pixmap). Images
//! are converted from their own colour profile (see [`crate::image_profile`]; sRGB when they have
//! none) into the document's space, once per image and profile.
//! Decoded image cache (content hash + filters + colour profile + largest side → premultiplied
//! pixmap). Images are converted from their own colour profile (see [`crate::image_profile`]; sRGB
//! when they have none) into the document's space, once per image and profile.
//!
//! The canvas draws a large image shown smaller than its pixels from a copy scaled down to one of
//! [`DISPLAY_SIDES`] (see [`shown`]), so several of them stay decoded within the cache's budget
//! (#1483); exports and every other render draw from the image itself ([`get`]).
//!
//! Animated GIFs keep their original bytes like any image; their first frame is the image
//! everywhere (canvas, exports). The presenter plays them: [`frame_at`] decodes every frame on
@@ -13,7 +17,8 @@ use figcraft_color::{ColorProfile, ImageFilters, PaintKind};
use figcraft_doc::{Document, ImageData, NodeId};
use tiny_skia::Pixmap;
pub(crate) type Key = (String, [i32; 7], ColorProfile);
/// Image, filters, colour profile and the largest side it was decoded at.
pub(crate) type Key = (String, [i32; 7], ColorProfile, u32);
/// The largest side of a decoded image: larger images are scaled down to fit.
const MAX_SIDE: u32 = 16_384;
@@ -22,9 +27,13 @@ const MAX_SIDE: u32 = 16_384;
const MAX_DECODED_BYTES: u64 = 512 << 20;
/// The most bytes of pixels each decoded-image cache holds (one image larger than this is held
/// alone).
const MAX_CACHED_BYTES: u64 = 512 << 20;
pub(crate) const MAX_CACHED_BYTES: u64 = 512 << 20;
/// The most entries each decoded-image cache holds.
const MAX_CACHED_IMAGES: usize = 256;
/// The largest sides of the copies the canvas draws larger images from while they show smaller
/// (device pixels): two 16 000 × 10 000 images take 640 MB each in full, past the cache's budget
/// together, and 42 MB each at 4096 (168 MB at 8192).
pub const DISPLAY_SIDES: [u32; 2] = [4096, 8192];
/// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding
/// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so
@@ -73,6 +82,15 @@ impl PixmapCache {
}
}
/// The sides image `hash` is decoded at in the cache (any filters and profile), smallest first.
#[cfg(test)]
pub(crate) fn cached_sides(hash: &str) -> Vec<u32> {
let c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut sides: Vec<u32> = c.map.keys().filter(|k| k.0 == hash).map(|k| k.3).collect();
sides.sort_unstable();
sides
}
fn cache() -> &'static Mutex<PixmapCache> {
static C: OnceLock<Mutex<PixmapCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(PixmapCache::default()))
@@ -100,7 +118,7 @@ fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> {
/// tall.
pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> {
use image::ImageDecoder;
if jpeg_finer_than_first(bytes) {
if figcraft_format::jpeg::finer_than_first(bytes) {
return None;
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
@@ -123,6 +141,23 @@ pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, m
}
}
/// Whether image `hash`, decoded as `pm`, has no transparent pixels (filters and colour profiles
/// keep alpha). Scanned once per image.
pub(crate) fn opaque(hash: &str, pm: &Pixmap) -> bool {
static C: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
let c = C.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(o) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(hash) {
return *o;
}
let o = pm.pixels().iter().all(|p| p.alpha() == u8::MAX);
let mut c = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 4096 {
c.clear();
}
c.insert(hash.to_string(), o);
o
}
fn profile_cache() -> &'static Mutex<HashMap<String, Option<String>>> {
static C: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
@@ -165,11 +200,30 @@ fn premultiplied(rgba: &image::RgbaImage) -> Option<Pixmap> {
/// The decoded (and filtered) image for `hash` in the colour space `profile`, cached.
pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters), profile);
get_within(hash, data, filters, profile, MAX_SIDE)
}
/// [`get`] for the canvas, where the image shows `side` device pixels on its longer side: an image
/// larger than one of [`DISPLAY_SIDES`] that holds `side` comes scaled down to that side (cached
/// apart), else as [`get`] gives it. The image's size is the one `data` records (none: the image).
pub fn shown(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile, side: f64) -> Option<Arc<Pixmap>> {
get_within(hash, data, filters, profile, display_side(data.map_or(0, |d| d.width.max(d.height)), side))
}
/// The largest side to decode an image `long` pixels on its longer side at, to show `side` device
/// pixels long: the smallest of [`DISPLAY_SIDES`] that holds `side` and is smaller than the image,
/// else the image's own ([`MAX_SIDE`]).
pub(crate) fn display_side(long: u32, side: f64) -> u32 {
DISPLAY_SIDES.into_iter().find(|s| f64::from(*s) >= side && *s < long).unwrap_or(MAX_SIDE)
}
/// [`get`] scaled down to at most `max_side` wide and tall.
fn get_within(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile, max_side: u32) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters), profile, max_side);
if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(p);
}
let (mut p, srgb) = decode_in(&data?.bytes, profile)?;
let (mut p, srgb) = decode_within(&data?.bytes, profile, max_side, MAX_DECODED_BYTES)?;
// Images with their own profile are in `profile` already (sRGB → sRGB is the identity).
finish(&mut p, filters, if srgb { profile } else { ColorProfile::Srgb });
let p = Arc::new(p);
@@ -367,51 +421,6 @@ pub fn apply_filters(p: &mut Pixmap, f: &ImageFilters) {
}
}
/// Whether `bytes` are a progressive JPEG whose frame samples some component more finely (across
/// or down) than its first. zune-jpeg 0.5.15, the decoder behind `image`, asserts in its upsampler
/// on such a frame (etemesi254/zune-image#236; fixed there by 36007420, unreleased as of
/// 0.5.16-rc3), and in the browser a panic aborts the page. Until the fix ships, such a JPEG is
/// refused from its header: the decoder refuses the larger ones itself, so nothing that decoded is
/// lost. Baseline frames of the same shape decode and are left to it.
pub(crate) fn jpeg_finer_than_first(bytes: &[u8]) -> bool {
let Some((marker, frame)) = jpeg_frame(bytes) else { return false };
if !matches!(marker, 0xC2 | 0xC6 | 0xCA | 0xCE) {
return false;
}
// Length (2), precision (1), height (2), width (2), components (1), then id, sampling and
// table for each component.
let n = usize::from(frame.get(7).copied().unwrap_or(0));
let sampling = |k: usize| frame.get(9 + 3 * k).map(|b| (b >> 4, b & 0x0F));
let Some((h0, v0)) = sampling(0) else { return false };
(1..n).any(|k| sampling(k).is_some_and(|(h, v)| h > h0 || v > v0))
}
/// A JPEG's start-of-frame marker and the segment after it (from its length bytes), found as the
/// decoders find it: fill bytes are skipped and markers without a length stepped over. `None` for
/// anything but a JPEG, or one whose scan, end or bytes come before a frame.
fn jpeg_frame(bytes: &[u8]) -> Option<(u8, &[u8])> {
if !bytes.starts_with(&[0xFF, 0xD8]) {
return None;
}
let mut at = 2;
loop {
if *bytes.get(at)? != 0xFF {
return None;
}
let marker = *bytes.get(at + 1)?;
match marker {
0xFF => at += 1,
0x01 | 0xD0..=0xD8 => at += 2,
0xD9 | 0xDA => return None,
0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return Some((marker, bytes.get(at + 2..)?)),
_ => {
let len = usize::from(u16::from_be_bytes([*bytes.get(at + 2)?, *bytes.get(at + 3)?]));
at = at.checked_add(2)?.checked_add(len)?;
}
}
}
}
/// Read an image's pixel size without keeping the decode.
pub fn dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
@@ -430,9 +439,9 @@ fn padded_cache() -> &'static Mutex<PixmapCache> {
C.get_or_init(|| Mutex::new(PixmapCache::default()))
}
/// `pm` with a transparent 1-pixel border (cached).
/// `pm` with a transparent 1-pixel border (cached; a smaller copy of the image apart).
pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pixmap) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters), profile);
let key = (hash.to_string(), fkey(filters), profile, pm.width().max(pm.height()));
if let Some(p) = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(p);
}
+6 -109
View File
@@ -3,135 +3,32 @@
//! in its upsampler on such a frame (etemesi254/zune-image#236), and in the browser a panic aborts
//! the page. Baseline frames of the same shape are decoded by the decoder itself and left to it.
use std::io::Cursor;
use crate::images;
use super::*;
/// An 8 × 8 progressive RGB JPEG with every component sampled 1 × 1, as jpeg-encoder 0.6 writes it
/// at quality 50 (JFIF header, the standard tables, three scans). Its frame header (SOF2) starts at
/// byte 20; the second component's sampling byte is [`SECOND_SAMPLING`], the third's follows 3 on.
const TINY_PROGRESSIVE: &[u8] = &[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xC2, 0x00, 0x11,
0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x00, 0x11, 0x00, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E,
0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33,
0x3D, 0x3C, 0x39, 0x33, 0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68,
0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F,
0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00,
0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24,
0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A,
0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A,
0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99,
0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1,
0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00,
0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31,
0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15,
0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39,
0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97,
0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4,
0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA,
0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6F, 0xFF, 0xDA, 0x00, 0x08,
0x01, 0x01, 0x11, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00,
0x00, 0x01, 0x14, 0x00, 0x4D, 0xB0, 0x97, 0x8E, 0x45, 0x01, 0x08, 0x03, 0x70, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x01, 0x14, 0x00,
0xAD, 0x17, 0xA5, 0x4A, 0x6C, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x01, 0x14, 0x00, 0x8A, 0x75, 0xA1, 0xCB, 0x6D, 0x62, 0x7F, 0xFF, 0xDA,
0x00, 0x08, 0x01, 0x00, 0x00, 0x15, 0x29, 0x00, 0xE0, 0x75, 0x1C, 0x8C, 0x74, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x15, 0x29, 0x00,
0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x15, 0x29, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x2A, 0x3F, 0x00, 0xC8, 0x3C,
0x7E, 0x1F, 0x79, 0x07, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x2A, 0x3F, 0x00, 0xFE, 0x96, 0x8E, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02,
0x11, 0x2A, 0x3F, 0x00, 0xF9, 0x36, 0xFF, 0x00, 0x99, 0xFB, 0xDF, 0xFF, 0xD9,
];
const SECOND_SAMPLING: usize = 34;
/// An 8 × 8 baseline CMYK JPEG as jpeg-encoder 0.6 writes it at quality 50 with 2 × 2 sampling:
/// its black is sampled 2 × 2 against 1 × 1 for the others (SOF0 at byte 36), a well-formed file
/// of the shape the check is about, in a baseline frame.
const TINY_BASELINE_CMYK: &[u8] = &[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xEE, 0x00, 0x0E,
0x41, 0x64, 0x6F, 0x62, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xC0, 0x00, 0x14, 0x08, 0x00, 0x08, 0x00, 0x08, 0x04, 0x00, 0x11,
0x01, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0x03, 0x22, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D,
0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33, 0x38,
0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79,
0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42,
0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02,
0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51,
0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09,
0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47,
0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77,
0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5,
0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2,
0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6,
0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04,
0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07,
0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16,
0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46,
0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76,
0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3,
0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9,
0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6,
0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x0E, 0x04, 0x00, 0x11, 0x01, 0x11, 0x02, 0x11, 0x03, 0x00, 0x00, 0x3F, 0x00, 0xEE, 0xA6, 0xAA, 0x2A,
0xB3, 0x83, 0x95, 0xED, 0x6D, 0x75, 0xBB, 0xF5, 0xB4, 0x97, 0x6D, 0xB6, 0x5A, 0xD9, 0x2B, 0x8D, 0x39, 0x3A, 0xB3, 0x8F, 0x54, 0xFB, 0xCB, 0xAF,
0xFD, 0xBD, 0x7E, 0x9D, 0xDA, 0xEC, 0x96, 0xB7, 0xE2, 0x95, 0x47, 0x2A, 0x8E, 0x0B, 0x46, 0xB7, 0x77, 0x7A, 0xDD, 0xBE, 0xCD, 0x3F, 0xBD, 0xBF,
0x2B, 0x6C, 0x0E, 0xC3, 0x6E, 0xE9, 0xB6, 0xCA, 0xE0, 0x07, 0x1B, 0xC9, 0x21, 0x95, 0x4E, 0x72, 0x54, 0x0C, 0x8E, 0x00, 0x62, 0x00, 0x5C, 0x28,
0x45, 0xEF, 0x51, 0xFF, 0x00, 0x6B, 0xDA, 0xFF, 0x00, 0x76, 0xC7, 0xFF, 0x00, 0x01, 0x27, 0xA1, 0xFF, 0x00, 0xE3, 0xFB, 0xFE, 0xDD, 0x65, 0xFF,
0x00, 0xD1, 0x31, 0xD6, 0x35, 0x7F, 0xFF, 0xD9,
];
fn with_second_sampled(hv: u8) -> Vec<u8> {
let mut b = TINY_PROGRESSIVE.to_vec();
b[SECOND_SAMPLING] = hv;
b
}
// The fixtures live with the header check in `figcraft-format` (`jpeg.rs`), which tests it there.
include!("../../format/src/jpeg_fixtures.rs");
#[test]
fn the_jpeg_as_written_is_not_finer_than_its_first_and_decodes() {
assert_eq!(TINY_PROGRESSIVE[SECOND_SAMPLING], 0x11);
assert!(!images::jpeg_finer_than_first(TINY_PROGRESSIVE));
fn the_jpeg_as_written_decodes() {
let p = images::decode(TINY_PROGRESSIVE).expect("decoded");
assert_eq!((p.width(), p.height()), (8, 8));
// Chroma sampled coarser than luma (the usual way round) isn't finer either.
assert!(!images::jpeg_finer_than_first(&{
let mut b = TINY_PROGRESSIVE.to_vec();
b[SECOND_SAMPLING - 3] = 0x22;
b
}));
}
/// Without the check this test panics in the decoder (an assertion in zune-jpeg's upsampler).
#[test]
fn a_progressive_jpeg_sampling_a_component_finer_than_its_first_is_refused_from_its_header() {
for hv in [0x22, 0x21, 0x12] {
let b = with_second_sampled(hv);
assert!(images::jpeg_finer_than_first(&b), "second component {hv:#x}");
assert!(images::decode(&b).is_none(), "second component {hv:#x}");
assert!(images::decode(&with_second_sampled(hv)).is_none(), "second component {hv:#x}");
}
let mut third = TINY_PROGRESSIVE.to_vec();
third[SECOND_SAMPLING + 3] = 0x22;
assert!(images::jpeg_finer_than_first(&third));
assert!(images::decode(&third).is_none());
}
#[test]
fn baseline_frames_and_other_files_are_left_to_the_decoder() {
// A well-formed baseline frame with its black sampled finer than its cyan: the decoder takes
// it, so it isn't refused from its header.
assert!(!images::jpeg_finer_than_first(TINY_BASELINE_CMYK));
// A well-formed baseline frame with its black sampled finer than its cyan: the decoder takes it.
let p = images::decode(TINY_BASELINE_CMYK).expect("decoded");
assert_eq!((p.width(), p.height()), (8, 8));
// Not JPEGs, or JPEGs that end before their frame.
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert!(!images::jpeg_finer_than_first(&png));
assert!(!images::jpeg_finer_than_first(&TINY_PROGRESSIVE[..19]));
assert!(!images::jpeg_finer_than_first(&[0xFF, 0xD8]));
assert!(!images::jpeg_finer_than_first(&[]));
assert!(images::decode(&TINY_PROGRESSIVE[..19]).is_none());
}
+115
View File
@@ -0,0 +1,115 @@
//! Large images on the canvas (#1483): a large image shown smaller than its pixels is drawn from a
//! copy no larger than it shows, so two of them stay decoded together, while exports and close-up
//! views draw from the image itself.
use std::sync::Arc;
use figcraft_color::{ImageScaleMode, Paint};
use figcraft_doc::{Document, ImageData, NodeId, NodeKind};
use tiny_skia::Pixmap;
use crate::images::{self, DISPLAY_SIDES, MAX_CACHED_BYTES};
use crate::{Options, View, render_view};
const W: u32 = 6000;
const H: u32 = 8;
/// A `W` × `H` PNG of one-pixel black and white columns, black first unless `phase` is odd.
fn stripes(phase: u32) -> Vec<u8> {
let img = image::RgbaImage::from_fn(W, H, |x, _| image::Rgba(if (x + phase).is_multiple_of(2) { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// Two `W` × `H` images at their own size, one under the other (keys starting with `tag`: the
/// cache is shared by every test).
fn doc(tag: &str) -> (Document, NodeId, [String; 2]) {
let mut d = Document::new("large images");
let page = d.first_page().unwrap();
let keys = [format!("{tag}-a"), format!("{tag}-b")];
for (i, key) in (0u32..).zip(&keys) {
d.images.insert(key.clone(), ImageData { bytes: Arc::new(stripes(i)), width: W, height: H });
let r = d.create(page, NodeKind::Rectangle, "Image", 0.0, f64::from(i * 2 * H), f64::from(W), f64::from(H)).unwrap();
d.get_mut(r).unwrap().fills = vec![Paint::image(key, ImageScaleMode::Fill)];
}
(d, page, keys)
}
fn canvas() -> Options {
Options { lod: true, magnified_image_pixels: true, ..Default::default() }
}
/// The first `W` units of both rows at `scale`.
fn view(scale: f64) -> View {
View { origin: (0.0, 0.0), scale, width: (f64::from(W) * scale).ceil() as u32, height: (f64::from(4 * H) * scale).ceil() as u32 }
}
/// The canvas's copy of image `key` of `d` shown `side` device pixels long.
fn shown(d: &Document, key: &str, side: f64) -> Arc<Pixmap> {
images::shown(key, d.images.get(key), &Default::default(), d.color_profile, side).unwrap()
}
/// Whether row `y` of `p` shows one-pixel columns, black at even `x` when `black_first`, `each`
/// device pixels wide.
fn sharp_stripes(p: &Pixmap, y: u32, black_first: bool, each: u32) -> bool {
(0..200).chain(4000..4200).all(|x| {
let red = p.pixel(x, y).unwrap().red();
let black = (x / each) % 2 == u32::from(!black_first);
if black { red < 8 } else { red > 247 }
})
}
#[test]
fn zoomed_out_the_canvas_draws_both_images_from_copies_that_stay_decoded() {
let (d, page, keys) = doc("zoomed-out");
// At 25% each shows 1500 px long: drawn from 4096 px copies, not decoded in full.
render_view(&d, page, &view(0.25), &canvas()).unwrap();
let first = keys.each_ref().map(|k| shown(&d, k, 1500.0));
for (k, p) in keys.iter().zip(&first) {
assert_eq!((p.width(), p.height()), (DISPLAY_SIDES[0], 5), "{k}");
assert_eq!(images::cached_sides(k), vec![DISPLAY_SIDES[0]], "{k}: only its copy is decoded");
}
// Drawn again (a drag, a pan), both are still the copies decoded the first time.
render_view(&d, page, &view(0.25), &canvas()).unwrap();
for (k, p) in keys.iter().zip(&first) {
assert!(Arc::ptr_eq(p, &shown(&d, k, 1500.0)), "{k} was decoded again");
}
}
#[test]
fn exports_and_close_ups_draw_from_the_image_itself() {
let (d, page, keys) = doc("close-up");
// An export at 1x, and the canvas at 100% and 200%, show every pixel column.
let export = render_view(&d, page, &view(1.0), &Options::default()).unwrap();
assert!(sharp_stripes(&export, 4, true, 1) && sharp_stripes(&export, 2 * H + 4, false, 1));
let at_100 = render_view(&d, page, &view(1.0), &canvas()).unwrap();
assert!(sharp_stripes(&at_100, 4, true, 1) && sharp_stripes(&at_100, 2 * H + 4, false, 1));
let at_200 = render_view(&d, page, &view(2.0), &canvas()).unwrap();
assert!(sharp_stripes(&at_200, 8, true, 2));
assert_eq!(images::cached_sides(&keys[0]), vec![16_384], "the image itself, as exports draw it");
}
#[test]
fn the_copy_is_the_smallest_that_holds_what_shows() {
let side = |long, shown| images::display_side(long, shown);
assert_eq!(side(6000, 1500.0), 4096);
assert_eq!(side(16_000, 4096.0), 4096);
assert_eq!(side(16_000, 4097.0), 8192);
// Shown at more than the largest copy, or an image no larger than the copy: the image itself.
assert_eq!(side(16_000, 9000.0), 16_384);
assert_eq!(side(6000, 5000.0), 16_384);
assert_eq!(side(4096, 100.0), 16_384);
// An unknown size or a meaningless scale: the image itself.
assert_eq!(side(0, 100.0), 16_384);
assert_eq!(side(16_000, f64::NAN), 16_384);
}
#[test]
fn two_16k_images_fit_the_cache_as_copies_but_not_in_full() {
// 16 000 × 10 000 (#1483's size class): 640 MB each in full, 42 MB at 4096 and 168 MB at 8192.
let bytes = |w: u64, h: u64| w * h * 4;
assert!(2 * bytes(16_000, 10_000) > MAX_CACHED_BYTES);
assert!(2 * bytes(8192, 5120) <= MAX_CACHED_BYTES);
assert!(2 * bytes(4096, 2560) <= MAX_CACHED_BYTES);
}
+59
View File
@@ -0,0 +1,59 @@
//! One budget for the isolated layers a render holds at once (#1061): each nested layer with
//! effects, opacity or a mask keeps its pixmaps while the layers inside it draw, so a deep nest of
//! large ones could ask for more memory than there is. A layer past the budget is left out (with
//! a warning, once) instead.
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
/// Bytes the isolated layers being drawn on one thread may hold at once: two of the largest layer
/// there is (16 384 px square).
pub const BUDGET: u64 = 2 << 30;
thread_local! {
/// Bytes held now on this thread, the most held in this thread's life, and the budget
/// (tests lower it).
static HELD: Cell<u64> = const { Cell::new(0) };
static PEAK: Cell<u64> = const { Cell::new(0) };
static LIMIT: Cell<u64> = const { Cell::new(BUDGET) };
}
static WARNED: AtomicBool = AtomicBool::new(false);
/// A share of the budget, given back when dropped (on unwinding too).
pub(crate) struct Held(u64);
impl Held {
/// Room for `count` RGBA pixmaps of `w` × `h` pixels, if the budget has it.
pub(crate) fn take(w: u32, h: u32, count: u64) -> Option<Held> {
let bytes = u64::from(w) * u64::from(h) * 4 * count;
let held = HELD.with(Cell::get);
let total = held.saturating_add(bytes);
if total > LIMIT.with(Cell::get) {
if !WARNED.swap(true, Ordering::Relaxed) {
log::warn!("render: nested layers need more than {} MB at once; the innermost are left out", LIMIT.with(Cell::get) >> 20);
}
return None;
}
HELD.with(|c| c.set(total));
PEAK.with(|c| c.set(c.get().max(total)));
Some(Held(bytes))
}
}
impl Drop for Held {
fn drop(&mut self) {
HELD.with(|c| c.set(c.get().saturating_sub(self.0)));
}
}
/// Tests: run `f` with a budget of `limit` bytes on this thread; returns what it gave and the most
/// it held at once.
#[cfg(test)]
pub(crate) fn within<T>(limit: u64, f: impl FnOnce() -> T) -> (T, u64) {
let was = LIMIT.with(|c| c.replace(limit));
PEAK.with(|c| c.set(HELD.with(Cell::get)));
let out = f();
LIMIT.with(|c| c.set(was));
(out, PEAK.with(Cell::get))
}
+12 -2
View File
@@ -16,6 +16,7 @@ pub mod glass;
pub mod grain;
pub mod image_profile;
pub mod images;
mod layer_budget;
pub mod lod;
pub mod paint;
pub mod pattern;
@@ -78,8 +79,9 @@ pub struct Options {
/// Draw only this layer of the page, where it is (Dev Mode's focus view).
pub solo: Option<NodeId>,
/// A preview (the canvas): when zoomed out, approximate what's too small on screen to see in
/// detail, see [`lod`]. Off for everything that must be exact (exports, thumbnails); no
/// effect in outline mode.
/// detail, see [`lod`]; at any zoom, draw a large image shown smaller than its pixels from a
/// smaller copy (see [`images::shown`]). Off for everything that must be exact (exports,
/// thumbnails); no effect in outline mode.
pub lod: bool,
pub magnified_image_pixels: bool,
/// A dot grid over the background, under the layers (a board's canvas; drawn only where the
@@ -209,6 +211,7 @@ fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Opti
outlined: &opts.outlined,
skip: &opts.skip,
lod,
preview: opts.lod,
magnified_image_pixels: opts.magnified_image_pixels,
under: None,
time: 0.0,
@@ -243,6 +246,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim
outlined: &[],
skip: &[],
lod: false,
preview: false,
magnified_image_pixels: false,
under: None,
time,
@@ -492,12 +496,18 @@ mod image_budget_tests;
#[cfg(test)]
mod image_profile_tests;
#[cfg(test)]
mod image_shadow_tests;
#[cfg(test)]
mod jpeg_sampling_tests;
#[cfg(test)]
mod large_image_tests;
#[cfg(test)]
mod lod_tests;
#[cfg(test)]
mod magnified_image_tests;
#[cfg(test)]
mod mask_bounds_tests;
#[cfg(test)]
mod paint_behaviour_tests;
#[cfg(test)]
mod progressive_tests;
+2 -1
View File
@@ -12,7 +12,8 @@
//!
//! Only views at [`MAX_SCALE`] or below are approximated: 1:1 and closer previews are exact, and
//! so are exports, thumbnails and every other render. Whatever LOD can't approximate faithfully
//! (image paints, masks) takes the exact path at any scale.
//! (image paints, masks) takes the exact path at any scale, except that previews draw a large image
//! shown smaller than its pixels from a smaller copy ([`crate::images::shown`]).
use figcraft_color::{BlendMode, Paint, PaintKind};
use figcraft_doc::{Document, ListStyle, Node, NodeKind, StrokeAlign, TextAutoResize, TextData};
+1
View File
@@ -176,6 +176,7 @@ fn exports_never_use_lod() {
outlined: &[],
skip: &[],
lod: true,
preview: false,
magnified_image_pixels: false,
under: None,
time: 0.0,
+141
View File
@@ -0,0 +1,141 @@
//! Mask groups draw only over the pixels they can paint, and nested isolated layers share one
//! memory budget (#1061). Each scene renders the same with a mask's pixmaps cut to its area as with
//! the whole view (how masks drew before).
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, EffectKind, MaskType, NodeId, NodeKind, Shadow};
use figcraft_geom::Affine;
use tiny_skia::Pixmap;
use crate::draw::WHOLE_MASK_AREA;
use crate::layer_budget::{BUDGET, within};
use crate::{Options, View, render_view_single};
const ELLIPSE: NodeKind = NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 };
fn shape(d: &mut Document, parent: NodeId, kind: NodeKind, (x, y, w, h): (f64, f64, f64, f64), hex: &str) -> NodeId {
let id = d.create(parent, kind, "Shape", x, y, w, h).unwrap();
d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::from_hex(hex).unwrap())];
id
}
/// A group under `parent` whose first layer, `mask`, masks the rest.
fn mask_group(d: &mut Document, parent: NodeId, kind: NodeKind, rect: (f64, f64, f64, f64), mask_type: MaskType) -> (NodeId, NodeId) {
let g = d.create(parent, NodeKind::Group, "Mask group", 0.0, 0.0, 1.0, 1.0).unwrap();
let m = shape(d, g, kind, rect, "808080");
let n = d.get_mut(m).unwrap();
n.is_mask = true;
n.mask_type = mask_type;
(g, m)
}
fn turn(d: &mut Document, id: NodeId, degrees: f64) {
let n = d.get_mut(id).unwrap();
let (cx, cy) = (n.width / 2.0, n.height / 2.0);
n.transform = n.transform * Affine::translate((cx, cy)) * Affine::rotate(degrees.to_radians()) * Affine::translate((-cx, -cy));
}
/// Nested masks (alpha, luminance, vector, a mask with opacity), rotated masks and content,
/// content with opacity, a drop shadow and a background blur, and a mask group running off the
/// right and bottom of an 800 × 600 view.
fn scene() -> (Document, NodeId) {
let mut d = Document::new("masks");
let p = d.first_page().unwrap();
shape(&mut d, p, NodeKind::Rectangle, (0.0, 0.0, 800.0, 600.0), "EEEEEE");
let (g1, _) = mask_group(&mut d, p, ELLIPSE, (100.0, 80.0, 320.0, 220.0), MaskType::Alpha);
let c1 = shape(&mut d, g1, NodeKind::Rectangle, (60.0, 50.0, 400.0, 300.0), "E03030");
let n = d.get_mut(c1).unwrap();
n.opacity = 0.6;
n.effects = vec![Effect {
kind: EffectKind::DropShadow(Shadow { color: Color::BLACK, offset: (8.0, 12.0), radius: 10.0, ..Shadow::default() }),
visible: true,
}];
let c2 = shape(&mut d, g1, NodeKind::Rectangle, (150.0, 120.0, 160.0, 90.0), "2040E0");
turn(&mut d, c2, 30.0);
let pane = shape(&mut d, g1, NodeKind::Rectangle, (120.0, 100.0, 140.0, 80.0), "FFFFFF33");
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(6.0)), visible: true }];
// Nested: a turned luminance mask inside the first group.
let (g2, m2) = mask_group(&mut d, g1, NodeKind::Rectangle, (200.0, 140.0, 150.0, 110.0), MaskType::Luminance);
turn(&mut d, m2, 20.0);
let c3 = shape(&mut d, g2, ELLIPSE, (180.0, 120.0, 200.0, 160.0), "20C060");
d.get_mut(c3).unwrap().opacity = 0.8;
// A vector mask with opacity running off the view.
let (g3, m3) = mask_group(&mut d, p, NodeKind::Star { count: 5, inner_ratio: 0.5 }, (640.0, 420.0, 300.0, 300.0), MaskType::Vector);
d.get_mut(m3).unwrap().opacity = 0.5;
let c4 = shape(&mut d, g3, NodeKind::Rectangle, (560.0, 380.0, 500.0, 400.0), "F0A020");
turn(&mut d, c4, -15.0);
// An alpha mask with opacity: its own layer.
let (g4, m4) = mask_group(&mut d, p, NodeKind::Rectangle, (20.0, 420.0, 200.0, 150.0), MaskType::Alpha);
d.get_mut(m4).unwrap().opacity = 0.4;
shape(&mut d, g4, ELLIPSE, (0.0, 400.0, 260.0, 190.0), "8020C0");
(d, p)
}
fn render(d: &Document, page: NodeId, view: &View, whole: bool) -> Pixmap {
WHOLE_MASK_AREA.with(|c| c.set(whole));
let pix = render_view_single(d, page, view, &Options { background: Some(Color::WHITE), ..Default::default() });
WHOLE_MASK_AREA.with(|c| c.set(false));
pix.unwrap()
}
#[test]
fn masks_cut_to_their_area_draw_the_same() {
let (d, page) = scene();
let views = [
View { origin: (0.0, 0.0), scale: 1.0, width: 800, height: 600 },
// Fractional origin and zoom: the area's edges fall between pixels.
View { origin: (-37.3, 12.6), scale: 1.7, width: 900, height: 700 },
View { origin: (300.0, 200.0), scale: 0.45, width: 320, height: 240 },
// Only part of each group in view.
View { origin: (250.0, 250.0), scale: 2.0, width: 500, height: 400 },
];
for v in &views {
let (before, after) = (render(&d, page, v, true), render(&d, page, v, false));
let differ = before.data().chunks(4).zip(after.data().chunks(4)).filter(|(a, b)| a != b).count();
assert_eq!(differ, 0, "{v:?}: {differ} pixels differ");
let colours: std::collections::BTreeSet<&[u8]> = after.data().chunks(4).collect();
assert!(colours.len() > 3, "{v:?}: the masks show their content");
}
}
#[test]
fn a_small_mask_holds_a_small_area() {
let mut d = Document::new("small mask");
let p = d.first_page().unwrap();
let (g, _) = mask_group(&mut d, p, ELLIPSE, (100.0, 100.0, 40.0, 40.0), MaskType::Alpha);
shape(&mut d, g, NodeKind::Rectangle, (90.0, 90.0, 60.0, 60.0), "E03030");
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 3000, height: 2000 };
let (before, whole) = within(BUDGET, || render(&d, p, &v, true));
let (after, cut) = within(BUDGET, || render(&d, p, &v, false));
assert_eq!(before.data(), after.data());
// Four 3000 × 2000 pixmaps before (96 MB), four 62 × 62 now (61.5 kB).
assert_eq!(whole, 4 * 4 * 3000 * 2000);
assert_eq!(cut, 4 * 4 * 62 * 62, "the mask's 40 × 40 plus its content, rounded out and a pixel round");
}
#[test]
fn a_deep_nest_of_isolated_layers_stays_within_the_budget() {
let mut d = Document::new("deep");
let p = d.first_page().unwrap();
let mut parent = p;
// 64 levels, each a full-view group at half opacity or a mask group, every one isolating.
for i in 0..64 {
parent = if i % 2 == 0 {
let g = d.create(parent, NodeKind::Group, "Group", 0.0, 0.0, 1.0, 1.0).unwrap();
d.get_mut(g).unwrap().opacity = 0.5;
g
} else {
mask_group(&mut d, parent, NodeKind::Rectangle, (0.0, 0.0, 1000.0, 1000.0), MaskType::Alpha).0
};
shape(&mut d, parent, NodeKind::Rectangle, (0.0, 0.0, 1000.0, 1000.0), "2040E0");
}
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 500, height: 500 };
// Unbounded, the nest holds all its levels at once.
let (_, all) = within(u64::MAX, || render(&d, p, &v, false));
assert_eq!(all, 32 * 4 * 500 * 500 * 5, "32 groups of one layer, 32 mask groups of four");
let limit = 16 << 20;
let (pix, peak) = within(limit, || render(&d, p, &v, false));
assert!(peak <= limit, "held {peak} bytes at once");
// The outer levels still draw (the innermost, past the budget, are left out).
assert_ne!(&pix.data()[..4], &[255, 255, 255, 255]);
}
+29 -5
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImagePaint, ImageScaleMode, Paint, PaintKind};
use figcraft_doc::Document;
use figcraft_doc::{Document, ImageData};
use tiny_skia::{FilterQuality, GradientStop, Pixmap, Point, Shader, SpreadMode, Transform};
pub fn ts_color(c: Color) -> tiny_skia::Color {
@@ -95,11 +95,12 @@ fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option<Arc<Pixmap>> {
/// (the resolution pattern tiles are drawn at). Returns `None` when the paint draws nothing
/// (invisible, fully transparent, missing image or pattern source).
pub fn prepare(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document) -> Option<Prepared> {
prepare_at(paint, w, h, scale, doc, 0.0)
prepare_at(paint, w, h, scale, doc, 0.0, false)
}
/// [`prepare`] `time` seconds into animated GIFs (0: their first frame).
pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, time: f64) -> Option<Prepared> {
/// [`prepare`] `time` seconds into animated GIFs (0: their first frame); on the canvas (`preview`)
/// a large image shown smaller than its pixels draws from a smaller copy (see [`still`]).
pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, time: f64, preview: bool) -> Option<Prepared> {
if !paint.visible || paint.opacity <= 0.0 {
return None;
}
@@ -140,7 +141,7 @@ pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, tim
let moving = crate::images::frame_at(&img.image, data, &img.filters, doc.color_profile, time);
let pm = match &moving {
Some(f) => f.clone(),
None => crate::images::get(&img.image, data, &img.filters, doc.color_profile)?,
None => still(img, data, w, h, scale, doc, preview)?,
};
let transform = image_transform(img, f64::from(pm.width()), f64::from(pm.height()), w, h);
if img.scale_mode == ImageScaleMode::Tile {
@@ -164,6 +165,29 @@ pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, tim
}
}
/// The pixmap image paint `img` draws in a `w` × `h` box at `scale` device pixels per unit: on the
/// canvas (`preview`) a copy no larger than the image shows ([`crate::images::shown`]), else the
/// image itself. Tiles keep the image: their size is in its pixels.
fn still(img: &ImagePaint, data: Option<&ImageData>, w: f64, h: f64, scale: f64, doc: &Document, preview: bool) -> Option<Arc<Pixmap>> {
let (iw, ih) = data.map_or((0, 0), |d| (d.width, d.height));
if preview && img.scale_mode != ImageScaleMode::Tile && iw > 0 && ih > 0 {
let t = image_transform(img, f64::from(iw), f64::from(ih), w, h);
let side = scale * f64::from(t.sx * t.sy - t.kx * t.ky).abs().sqrt() * f64::from(iw.max(ih));
return crate::images::shown(&img.image, data, &img.filters, doc.color_profile, side);
}
crate::images::get(&img.image, data, &img.filters, doc.color_profile)
}
/// Whether image paint `img` paints its whole `w` × `h` box opaque: an image without transparent
/// pixels that covers the box (or tiles it).
/// shortcut: always looks at the image itself (exact, also on the canvas), so a large image with
/// shadows still decodes in full there; pass the canvas scale here if that turns up (#1483).
pub(crate) fn fills_box_opaque(doc: &Document, img: &ImagePaint, w: f64, h: f64) -> bool {
let Some(pm) = crate::images::get(&img.image, doc.images.get(&img.image), &img.filters, doc.color_profile) else { return false };
(img.scale_mode == ImageScaleMode::Tile || covers_box(img, f64::from(pm.width()), f64::from(pm.height()), w, h))
&& crate::images::opaque(&img.image, &pm)
}
/// Whether an image paint covers its whole `w` × `h` box: Fill always, Crop when its frame does.
fn covers_box(img: &ImagePaint, iw: f64, ih: f64, w: f64, h: f64) -> bool {
match img.scale_mode {
+1
View File
@@ -55,6 +55,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64
outlined: &[],
skip: &[],
lod: false,
preview: false,
magnified_image_pixels: false,
under: None,
time,
+24 -2
View File
@@ -12,6 +12,7 @@ use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use figcraft_color::{Paint, PaintKind};
use figcraft_doc::{Document, EffectKind, Node, NodeKind, SPREAD_MITER_LIMIT, Shadow};
use figcraft_geom::{Affine, BezPath};
use tiny_skia::{FillRule, Pixmap, PixmapPaint, Transform};
@@ -27,7 +28,8 @@ pub type Outline = (BezPath, FillRule);
/// The outlines a node's shadows are cast by when they spread, in local space: its fill outline
/// (when a fill shows) and its stroke area. `None` when the layer's pixels aren't those outlines:
/// groups, frames that don't clip their children or show no fill, text with a stroke.
/// groups, frames that don't clip their children or show no fill, text with a stroke, layers
/// filled only with images or patterns (whose transparent pixels cast no shadow).
pub fn silhouette(doc: &Document, n: &Node) -> Option<Vec<Outline>> {
outlines(doc, n, |f| f.visible && f.opacity > 0.0)
}
@@ -39,7 +41,26 @@ pub fn cover(doc: &Document, n: &Node) -> Option<Vec<Outline>> {
outlines(doc, n, |f| f.visible)
}
fn outlines(doc: &Document, n: &Node, fill_counts: fn(&figcraft_color::Paint) -> bool) -> Option<Vec<Outline>> {
/// Whether the fills `counts` picks (one at least) all paint their own alpha within `n`'s outline,
/// not the whole outline: patterns, and images with transparent pixels or that leave part of the
/// box empty (Fit, a smaller crop). The layer's pixels then cast and hide its shadows.
fn alpha_fills(doc: &Document, n: &Node, counts: impl Fn(&Paint) -> bool) -> bool {
let mut fills = n.fills.iter().filter(|f| counts(f)).peekable();
fills.peek().is_some()
&& fills.all(|f| match &f.kind {
PaintKind::Pattern(_) => true,
PaintKind::Image(img) => !crate::paint::fills_box_opaque(doc, img, n.width, n.height),
PaintKind::Solid { .. } | PaintKind::Gradient(_) => false,
})
}
/// Whether `n`'s inner shadows are cast from around its pixels, not its outline: groups (no
/// outline of their own) and layers whose fills paint their own alpha (see [`alpha_fills`]).
pub fn inner_from_pixels(doc: &Document, n: &Node) -> bool {
matches!(n.kind, NodeKind::Group) || alpha_fills(doc, n, |f| f.visible)
}
fn outlines(doc: &Document, n: &Node, fill_counts: fn(&Paint) -> bool) -> Option<Vec<Outline>> {
let has_fill = n.fills.iter().any(fill_counts);
let is_bool = matches!(n.kind, NodeKind::BooleanOp { .. });
let is_text = matches!(n.kind, NodeKind::Text(_));
@@ -47,6 +68,7 @@ fn outlines(doc: &Document, n: &Node, fill_counts: fn(&figcraft_color::Paint) ->
if matches!(n.kind, NodeKind::Group | NodeKind::Page { .. })
|| (kids && !(n.kind.is_frame_like() && n.clip && has_fill))
|| (is_text && n.has_visible_stroke())
|| alpha_fills(doc, n, fill_counts)
{
return None;
}
+59
View File
@@ -0,0 +1,59 @@
//! Shadows of image fills in SVG follow what the image paints, like the renderer (#1289): a
//! picture with transparent pixels hides its drop shadows behind its alpha (not an image of its
//! box) and is an embedded render when it has inner shadows; an opaque one keeps its outline.
use figcraft_color::{Color, ImageScaleMode, Paint};
use figcraft_doc::{Document, Effect, EffectKind, ImageData, NodeId, NodeKind, Shadow};
use super::{SvgOptions, export_svg};
/// A 100 × 100 rectangle filled with a 100 × 100 red PNG: a circle on transparent pixels
/// (`circle`) or red all over, with `effect`.
fn scene(circle: bool, effect: EffectKind) -> (Document, NodeId) {
let mut pm = figcraft_render::Pixmap::new(100, 100).unwrap();
for (i, px) in pm.data_mut().chunks_exact_mut(4).enumerate() {
let (x, y) = ((i % 100) as f64 + 0.5 - 50.0, (i / 100) as f64 + 0.5 - 50.0);
if !circle || x.hypot(y) <= 25.0 {
px.copy_from_slice(&[255, 0, 0, 255]);
}
}
let mut d = Document::new("svg image shadow");
let p = d.first_page().unwrap();
// Image ids key the renderer's shared caches: fixtures running in parallel need ids of their own.
let id = if circle { "svg-image-shadow-circle" } else { "svg-image-shadow-opaque" };
let bytes = figcraft_render::encode_png(&pm).unwrap();
d.images.insert(id.into(), ImageData { bytes: std::sync::Arc::new(bytes), width: 100, height: 100 });
let n = d.create(p, NodeKind::Rectangle, "R", 10.0, 10.0, 100.0, 100.0).unwrap();
let node = d.get_mut(n).unwrap();
node.fills = vec![Paint::image(id, ImageScaleMode::Fill)];
node.effects = vec![Effect { kind: effect, visible: true }];
(d, n)
}
fn export(d: &Document, id: NodeId) -> String {
export_svg(d, &[id], &SvgOptions::default()).unwrap()
}
fn shadow() -> Shadow {
Shadow { color: Color::BLACK, offset: (6.0, 6.0), radius: 4.0, show_behind: false, ..Shadow::default() }
}
#[test]
fn a_transparent_images_drop_shadow_hides_behind_its_alpha() {
let (d, n) = scene(true, EffectKind::DropShadow(shadow()));
let svg = export(&d, n);
assert!(svg.contains("in2=\"SourceAlpha\" operator=\"out\"") && !svg.contains("result=\"cover\""), "{svg}");
let (d, n) = scene(false, EffectKind::DropShadow(shadow()));
let svg = export(&d, n);
assert!(svg.contains("in2=\"cover\" operator=\"out\""), "an opaque picture hides it behind its box: {svg}");
}
#[test]
fn a_transparent_images_inner_shadow_is_an_embedded_render() {
let (d, n) = scene(true, EffectKind::InnerShadow(shadow()));
let svg = export(&d, n);
assert!(!svg.contains("<pattern") && !svg.contains("<mask"), "the render holds the picture and its shade: {svg}");
let (d, n) = scene(false, EffectKind::InnerShadow(shadow()));
let svg = export(&d, n);
assert!(svg.contains("<pattern") && svg.contains("<mask"), "an opaque picture stays vector, shaded from its box: {svg}");
}
+5 -2
View File
@@ -847,8 +847,9 @@ impl W<'_> {
let whole = has(&|k| matches!(k, EffectKind::Texture(_)))
|| has(&|k| matches!(k, EffectKind::LayerBlur(b) if b.is_progressive()))
|| (has(&|k| matches!(k, EffectKind::Noise(_))) && has(&|k| matches!(k, EffectKind::LayerBlur(_))))
// A group's inner shadows are cast by its layers' pixels, over them.
|| (matches!(n.kind, NodeKind::Group) && has(&|k| matches!(k, EffectKind::InnerShadow(_))));
// A group's inner shadows are cast by its layers' pixels, over them; an image fill's by
// its alpha.
|| (has(&|k| matches!(k, EffectKind::InnerShadow(_))) && figcraft_render::shadow::inner_from_pixels(self.doc, n));
if !whole {
return false;
}
@@ -1162,6 +1163,8 @@ mod effects_tests;
#[cfg(test)]
mod group_effects_tests;
#[cfg(test)]
mod image_shadow_tests;
#[cfg(test)]
mod mask_export_tests;
#[cfg(test)]
mod raster_fx_tests;
+4
View File
@@ -25,6 +25,10 @@ log = { workspace = true }
# `get_descriptors` takes ownership of the array correctly (20.x and 21.x leak it on every scan);
# it brings core-foundation 0.10, core-graphics 0.25 and core-graphics-types 0.2 beside the older
# versions other crates use. Pinned exactly: review the diff before bumping.
# The suite's font store folder (fonts installed by any Craft app) is scanned with the system's.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-font-catalog = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-text = { version = "=22.0.0", default-features = false, features = ["link"] }
objc2 = "0.6"
+4
View File
@@ -874,6 +874,10 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
v.push("/usr/local/share/fonts".into());
v.extend(unix_user_font_dirs(std::env::var_os("HOME"), std::env::var_os("XDG_DATA_HOME")));
}
// Fonts any Craft app downloaded into the suite's font store (a download being installed is a
// `.part` file, which the walk skips).
#[cfg(not(target_arch = "wasm32"))]
v.extend(craft_font_catalog::font_store_dir(&craft_font_catalog::FontEnv::from_process()));
v
}
+7
View File
@@ -261,6 +261,13 @@ fn the_scan_reads_font_files_without_an_extension() {
assert!(system_font_dirs().iter().any(|d| d.ends_with("Adobe/CoreSync/plugins/livetype")), "Adobe Fonts' folder is scanned");
}
/// Fonts downloaded into the suite's font store (by any Craft app) are scanned with the system's.
#[test]
fn the_suite_font_store_is_scanned() {
let store = craft_font_catalog::font_store_dir(&craft_font_catalog::FontEnv::from_process());
assert!(store.is_none_or(|dir| system_font_dirs().contains(&dir)));
}
/// A rescan offers new families and the styles installed since, and never a file twice.
#[test]
fn a_rescan_offers_fonts_installed_since() {
+96 -29
View File
@@ -383,33 +383,24 @@ fn shape_text(text: &TextData) -> ShapedText {
let Some(Some(primary)) = faces.get(st_idx) else { continue };
let size = sanitize_size(style.font_size);
let spacing = style.letter_spacing.resolve(size);
let span_fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks);
// The face for the grapheme cluster starting at `i` (clipped to the span).
let face_at = |i: usize| {
let to = (i + 1..*end).find(|&j| cluster_start.get(j).copied().unwrap_or(true)).unwrap_or(*end);
cluster_face(chars.get(i..to).unwrap_or(&[]), primary, &emoji, span_fallbacks)
};
// Segment by face.
let mut seg_start = *start;
while seg_start < *end {
let face_for = |c: char| -> Arc<Face> {
// Emoji take a colour face before a face without colour glyphs (the style's own or a
// symbol font), which would draw them in one colour.
if is_emoji(c)
&& !primary.has_color()
&& let Some(f) = emoji.iter().find(|f| f.has_char(c))
{
return f.clone();
}
if is_line_break(c) || primary.has_char(c) {
return primary.clone();
}
let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks);
fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone())
};
let first = chars.get(seg_start).copied().unwrap_or(' ');
let face = face_for(first);
let face = face_at(seg_start);
let level = level_at(seg_start);
let mut seg_end = seg_start + 1;
while seg_end < *end
&& chars.get(seg_end).is_some_and(|c| {
!is_line_break(*c)
&& !is_line_break(first)
&& (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id)
&& (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_at(seg_end).id == face.id)
})
&& level_at(seg_end) == level
{
@@ -442,6 +433,44 @@ fn shape_text(text: &TextData) -> ShapedText {
ShapedText { chars, styles, faces, fallbacks, items, bidi, cluster_start }
}
/// The face a grapheme cluster `cl` is shaped with. A letter and its marks come from one face that
/// has them all: a mark the letter's face lacks would draw as a missing glyph beside it, and a mark
/// from another face would sit apart from it (#1478).
fn cluster_face(cl: &[char], primary: &Arc<Face>, emoji: &[Arc<Face>], fallbacks: &[Arc<Face>]) -> Arc<Face> {
pick_cluster_face(cl, primary, emoji, fallbacks, |f, c| f.has_char(c), |f| f.has_color()).clone()
}
/// [`cluster_face`]'s rule over any kind of face (`has`: whether a face has a char's glyph,
/// `color`: whether it has colour glyphs), so it can be checked without fonts.
pub(crate) fn pick_cluster_face<'a, F>(
cl: &[char],
primary: &'a F,
emoji: &'a [F],
fallbacks: &'a [F],
has: impl Fn(&F, char) -> bool,
color: impl Fn(&F) -> bool,
) -> &'a F {
let c = cl.first().copied().unwrap_or(' ');
// Emoji take a colour face before a face without colour glyphs (the style's own or a symbol
// font), which would draw them in one colour.
if is_emoji(c)
&& !color(primary)
&& let Some(f) = emoji.iter().find(|f| has(f, c))
{
return f;
}
// Joiners and variation selectors need no glyph: shaping hides them.
let covers = |f: &F| {
cl.iter().all(|&m| has(f, m) || matches!(m, '\u{034F}' | '\u{200C}' | '\u{200D}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}'))
};
if is_line_break(c) || covers(primary) {
return primary;
}
// No face has the whole cluster: the letter's own face, as for a lone letter.
let own = || if has(primary, c) { Some(primary) } else { fallbacks.iter().find(|f| has(f, c)) };
fallbacks.iter().find(|f| covers(f)).or_else(own).unwrap_or(primary)
}
fn line_break_after(items: &[Item], end: usize) -> bool {
end.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|item| is_line_break(item.ch))
}
@@ -470,6 +499,30 @@ fn safe_break_index(items: &[Item], chars: &[char], cluster_start: &[bool], line
(line_start + 1..brk).rev().find(|&k| safe(k)).or(carry).unwrap_or(brk)
}
/// How much wider than its box a saved line may come out in the fonts at hand before the saved
/// breaks are given up: past that the font isn't the one the file was laid out with (a stand-in),
/// and the text wraps by its own measure.
const SAVED_LINE_SLACK: f64 = 1.03;
/// Saved line starts being followed ([`TextData::saved_breaks_for`]).
struct SavedStarts<'a> {
starts: &'a [usize],
next: usize,
}
impl SavedStarts<'_> {
/// Whether a line starting at char `from` breaks before char `at`: a saved start lies in
/// `(from, at]` (one inside a cluster or a run of spaces breaks at the next glyph after it).
fn due(&mut self, from: usize, at: usize) -> bool {
while self.starts.get(self.next).is_some_and(|s| *s <= from) {
self.next += 1;
}
self.starts.get(self.next).is_some_and(|s| *s <= at)
}
}
/// The lines (item range, paragraph end, paragraph), wrapped by measure or at `saved` line starts;
/// None when a saved line would overflow its box by more than [`SAVED_LINE_SLACK`].
fn break_lines(
items: &[Item],
chars: &[char],
@@ -479,7 +532,8 @@ fn break_lines(
rtl_para_at: &impl Fn(usize) -> bool,
hang_start: bool,
hang_end: bool,
) -> Vec<(usize, usize, bool, usize)> {
mut saved: Option<SavedStarts>,
) -> Option<Vec<(usize, usize, bool, usize)>> {
let mut lines: Vec<(usize, usize, bool, usize)> = Vec::new(); // item range, paragraph end, paragraph
let mut line_start = 0;
let mut i = 0;
@@ -517,18 +571,28 @@ fn break_lines(
let hung = items.get(line_start).filter(|f| hang_start && ltr && opens(f.ch)).map_or(0.0, |f| f.adv)
+ if hang_end && ltr && closes(it.ch) { w } else { 0.0 };
if let Some(maxw) = wrap
&& x + w - it.spacing > maxw - line_indent + hung + 1e-6
&& i > line_start
&& !it.is_space()
{
let brk = safe_break_index(items, chars, cluster_start, line_start, last_break.filter(|b| *b >= line_start).map(|b| b + 1).unwrap_or(i));
lines.push((line_start, brk, false, para));
line_start = brk;
i = brk;
x = 0.0;
last_break = None;
para_first_line = false;
continue;
let (width, room) = (x + w - it.spacing, maxw - line_indent + hung);
let line_char = items.get(line_start).map_or(0, |f| f.char_start);
let brk = match saved.as_mut().map(|s| s.due(line_char, it.char_start)) {
Some(true) => Some(i),
Some(false) if width > room * SAVED_LINE_SLACK + 1.0 => return None,
Some(false) => None,
None => (width > room + 1e-6).then(|| {
safe_break_index(items, chars, cluster_start, line_start, last_break.filter(|b| *b >= line_start).map(|b| b + 1).unwrap_or(i))
}),
};
if let Some(brk) = brk {
lines.push((line_start, brk, false, para));
line_start = brk;
i = brk;
x = 0.0;
last_break = None;
para_first_line = false;
continue;
}
}
x += w;
if items.get(i + 1).is_some_and(|next| crate::breaks::break_between(it.ch, next.ch)) {
@@ -540,7 +604,7 @@ fn break_lines(
lines.push((line_start, items.len(), true, para));
}
lines
Some(lines)
}
#[derive(Clone, Copy)]
@@ -788,7 +852,10 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
TextAutoResize::WidthAndHeight => None,
_ => box_width.filter(|w| w.is_finite() && *w > 0.0),
};
let lines = break_lines(&items, &chars, &cluster_start, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end);
let breaks = |saved| break_lines(&items, &chars, &cluster_start, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end, saved);
// Where the file saved its line breaks, they win while the text and box are as saved.
let saved = wrap.and_then(|w| text.saved_breaks_for(w)).map(|starts| SavedStarts { starts, next: 0 });
let lines = saved.and_then(|s| breaks(Some(s))).or_else(|| breaks(None)).unwrap_or_default();
// Line metrics and positions.
let default_style = styles.first().cloned().unwrap_or_default();
let default_face = faces.first().cloned().flatten();

Some files were not shown because too many files have changed in this diff Show More