mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 06:31:22 +00:00
Merge main into feat/board-templates-tables
# Conflicts: # ROADMAP.md # crates/engine/src/cmd/mod.rs # crates/engine/src/lib.rs # crates/ui-egui/locales/bn.toml # crates/ui-egui/locales/cs.toml # crates/ui-egui/locales/de.toml # crates/ui-egui/locales/en.toml # crates/ui-egui/locales/es.toml # crates/ui-egui/locales/fi.toml # crates/ui-egui/locales/fr.toml # crates/ui-egui/locales/id.toml # crates/ui-egui/locales/ja.toml # crates/ui-egui/locales/ka.toml # crates/ui-egui/locales/ko.toml # crates/ui-egui/locales/nl.toml # crates/ui-egui/locales/pl.toml # crates/ui-egui/locales/pt-BR.toml # crates/ui-egui/locales/ru.toml # crates/ui-egui/locales/sv.toml # crates/ui-egui/locales/tr.toml # crates/ui-egui/locales/zh-Hans.toml # crates/ui-egui/src/lib.rs # docs/boards-parity.md # docs/gaps.md # docs/mcp.md # docs/target-app-parity.md
This commit is contained in:
Generated
+2
@@ -1625,11 +1625,13 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"regex",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"wat",
|
||||
"web-time",
|
||||
"zip 2.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -49,6 +49,7 @@ serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
regex = "1.13"
|
||||
log = "0.4"
|
||||
same-file = "1.0.6"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
proptest = "1"
|
||||
|
||||
@@ -234,8 +234,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
||||
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
|
||||
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
|
||||
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
|
||||
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
|
||||
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
|
||||
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
|
||||
|
||||
+13
-4
@@ -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-25 · **Change:** minor (boards: templates and tables; 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-25 · **Change:** minor (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
|
||||
@@ -93,7 +93,7 @@ native speakers' reviews of the nine language drafts need people.
|
||||
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
|
||||
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
| Boards (whiteboarding; not in the weighted numbers) | ~40% | ~24% | 26–54 | [boards-parity](docs/boards-parity.md) |
|
||||
| Boards (whiteboarding; not in the weighted numbers) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) |
|
||||
|
||||
## Languages
|
||||
|
||||
@@ -110,11 +110,11 @@ native speakers' reviews of the nine language drafts need people.
|
||||
| Japanese | ja | 99% | draft (needs native review) |
|
||||
| German | de | 96% | draft (needs native review) |
|
||||
| Korean | ko | 95% | draft (needs native review) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
| Vietnamese | vi | 100% | draft (needs native review) |
|
||||
|
||||
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
|
||||
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸
|
||||
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; CJK UI
|
||||
text uses an installed CJK font, loaded only when needed; adding a language:
|
||||
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
|
||||
@@ -134,9 +134,13 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
|
||||
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
|
||||
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
|
||||
- **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged.
|
||||
- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%.
|
||||
- **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
|
||||
- **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged.
|
||||
- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
|
||||
- **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged.
|
||||
- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged.
|
||||
- **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged.
|
||||
- **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged.
|
||||
@@ -339,9 +343,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
|
||||
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
|
||||
| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
|
||||
| 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
|
||||
| 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
|
||||
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
|
||||
| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged |
|
||||
| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged |
|
||||
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
|
||||
| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
|
||||
|
||||
@@ -292,8 +292,29 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn refuse_dropping_images(input: &str, out: &str, images_left_out: usize) -> CliResult<()> {
|
||||
if images_left_out > 0 && figcraft_engine::file_access::same_file(input, out) {
|
||||
return Err(CliError::Failed(format!(
|
||||
"{out} wasn't written: {images_left_out} of its images weren't loaded (together they pass what FigCraft reads from one file), and writing over it would delete them. Write to another path; {input} keeps them."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &figcraft_engine::DocState, out: &str) -> CliResult<()> {
|
||||
if let Some((input, images_left_out)) = input {
|
||||
refuse_dropping_images(input, out, images_left_out)?;
|
||||
}
|
||||
if let Some(images_left_out) = active.images_lost_saving_to(out) {
|
||||
refuse_dropping_images(out, out, images_left_out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn convert(input: &str, out: &str) -> CliResult<ExitCode> {
|
||||
let doc = load(input)?;
|
||||
let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?;
|
||||
refuse_dropping_images(input, out, loaded.images_past_budget)?;
|
||||
let doc = loaded.doc;
|
||||
let page = doc
|
||||
.current_page
|
||||
.filter(|p| doc.pages.contains(p))
|
||||
@@ -353,13 +374,14 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
|
||||
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
|
||||
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let mut s = match input {
|
||||
let (mut s, input_images_left_out) = match input {
|
||||
Some(path) => {
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?;
|
||||
s
|
||||
let left_out = s.ds().map_err(|e| CliError::Failed(e.to_string()))?.images_lost_saving_to(path).unwrap_or(0);
|
||||
(s, left_out)
|
||||
}
|
||||
None => Session::new(),
|
||||
None => (Session::new(), 0),
|
||||
};
|
||||
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
|
||||
// (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the
|
||||
@@ -389,6 +411,7 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
|
||||
}
|
||||
if let Some(out) = out {
|
||||
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?;
|
||||
if ext(out) == "figcraft" {
|
||||
// Through the engine, so the version history is kept.
|
||||
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
|
||||
@@ -567,4 +590,24 @@ mod tests {
|
||||
assert!(!out.exists(), "a script that failed writes nothing");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_output_over_its_input_is_refused_whichever_tab_the_script_left_active() {
|
||||
let dir = temp_dir("script-images-left-out");
|
||||
let input = dir.join("big.figcraft");
|
||||
std::fs::write(&input, b"kept").unwrap();
|
||||
let link = dir.join("link.figcraft");
|
||||
std::fs::hard_link(&input, &link).unwrap();
|
||||
let (input, link, other) = (input.to_str().unwrap(), link.to_str().unwrap(), dir.join("other.figcraft"));
|
||||
let duplicate = Session::new();
|
||||
let active = duplicate.ds().unwrap();
|
||||
|
||||
for out in [input, link] {
|
||||
let refused = refuse_dropping_images_from_script(Some((input, 3)), active, out);
|
||||
assert!(matches!(&refused, Err(CliError::Failed(m)) if m.contains("3 of its images")), "{out}");
|
||||
}
|
||||
assert!(refuse_dropping_images_from_script(Some((input, 3)), active, other.to_str().unwrap()).is_ok());
|
||||
assert!(refuse_dropping_images_from_script(Some((input, 0)), active, input).is_ok());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ fonts built into FigCraft, for renders that match on every machine. Otherwise in
|
||||
indexed the first time text is laid out, as in the app.
|
||||
|
||||
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
||||
wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file
|
||||
per top-level frame into the <out> directory, or one file when <out> has an image extension or --node
|
||||
is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
|
||||
wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an
|
||||
image layer). `export` writes one file per top-level frame into the <out> directory, or one file when
|
||||
<out> has an image extension or --node is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
|
||||
not open every file); PDFs take print options (a bleed like 3mm or 0.125in,
|
||||
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
|
||||
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
|
||||
@@ -115,6 +115,9 @@ fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Arg
|
||||
// Text lays out with the installed fonts, like the desktop app, so headless renders and
|
||||
// auto layout sizes match what users see.
|
||||
figcraft_text::scan_system_fonts_on_demand();
|
||||
} else {
|
||||
// Nothing scans them then, not even a `.sketch` import matching its fonts.
|
||||
figcraft_text::opt_out_of_system_fonts();
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
@@ -66,3 +66,29 @@ fn info_lists_the_fonts_a_file_uses_that_are_missing() {
|
||||
let shown = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
|
||||
}
|
||||
|
||||
/// A synthetic `.sketch` file: one page with one Helvetica text.
|
||||
const SKETCH: &str = "UEsDBBQAAAAIAOFFVl2B8d55RwAAAFcAAAANAAAAZG9jdW1lbnQuanNvbqtWik/OSSwuVrJSUErJTy7NTc0rUdJRUCpITE8FCUZXIynwDfYK9vdzy8xJDUpNSy1KzUtOBamNL0pNA0mD9egHKNXG1gIAUEsDBBQAAAAIAOFFVl0B3thgPQAAAEkAAAAJAAAAbWV0YS5qc29uq1ZKLChQslJQSs7P1UvKz0jNzUzMS85PycxL1yvOTi1JzjBW0lEAKQpLLSrOzM8DqTUzAYmVwQUMjUxrAVBLAwQUAAAACADhRVZdo5nc2bkAAABnAQAADAAAAHBhZ2VzL1AuanNvbm1PQQrCMBD8StmzB1voxZsgUg+CUG8iJU3XNhITaVZtLf27SWylFEPIzgyzs5sOMi6ZMbAK4M5KhEUAhc50fkVOu42TD05T7IaeDB7JWqxd16mbJBA29CfhOE3w5FJ/WQeNfZdWaYf6EgVVFseOVCjKiiwL495SRlSL/EFYpBao0geYEUIiYGoatpOaMxJaDfkSVekHRHNvB/t0PZuw1Yp+mvfMWsZfJSifSIIzt4IRbyeGUW/P2d0PUEsBAhQDFAAAAAgA4UVWXYHx3nlHAAAAVwAAAA0AAAAAAAAAAAAAAIABAAAAAGRvY3VtZW50Lmpzb25QSwECFAMUAAAACADhRVZdAd7YYD0AAABJAAAACQAAAAAAAAAAAAAAgAFyAAAAbWV0YS5qc29uUEsBAhQDFAAAAAgA4UVWXaOZ3Nm5AAAAZwEAAAwAAAAAAAAAAAAAAIAB1gAAAHBhZ2VzL1AuanNvblBLBQYAAAAAAwADAKwAAAC5AQAAAAA=";
|
||||
|
||||
/// Opening a `.sketch` file (whose import matches fonts against the installed ones) keeps to
|
||||
/// `--no-system-fonts` too: the run still knows only the built-in families.
|
||||
#[test]
|
||||
fn sketch_imports_keep_to_no_system_fonts() {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-sketch-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let script = d.join("open.json");
|
||||
let steps = json!([{"command": "file.open", "params": {"data": SKETCH, "name": "x.sketch"}}, {"command": "text.fonts"}]);
|
||||
std::fs::write(&script, steps.to_string()).unwrap();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
|
||||
.arg("run")
|
||||
.arg(&script)
|
||||
.arg("--no-system-fonts")
|
||||
.env_remove("FIGCRAFT_NO_SYSTEM_FONTS")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let lines: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines[0]["ok"], json!(true), "{lines:?}");
|
||||
let fonts: Vec<String> = serde_json::from_value(lines[1]["result"].clone()).unwrap();
|
||||
assert_eq!(fonts, families(&["--no-system-fonts"], false), "the import didn't scan the system fonts");
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ sync_github_name = true
|
||||
[people.AganzeFelicite]
|
||||
sync_github_name = true
|
||||
|
||||
[people.mojtabajafarzadeh]
|
||||
real_name = "Mojtaba Jafarzadeh"
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
|
||||
@@ -24,6 +24,12 @@ pub struct NetLimits {
|
||||
pub hello_timeout: Duration,
|
||||
/// A connection with no traffic for this long is dead.
|
||||
pub idle_timeout: Duration,
|
||||
/// How long a one-shot request connection (hosting, a library, a plugin directory — and, most
|
||||
/// importantly, publishing a file) waits for the server's reply before giving up. Publishing
|
||||
/// uploads a whole file and a large one on a slow link takes the server a while to receive and
|
||||
/// answer, so this is longer than `idle_timeout` (a large project on a slow connection can
|
||||
/// easily need several minutes).
|
||||
pub request_reply_timeout: Duration,
|
||||
/// A quiet client pings this often (keeps it under `idle_timeout`).
|
||||
pub keepalive: Duration,
|
||||
/// Messages queued for one client before it counts as stuck and is disconnected.
|
||||
@@ -68,6 +74,7 @@ impl Default for NetLimits {
|
||||
burst: 400.0,
|
||||
hello_timeout: Duration::from_secs(10),
|
||||
idle_timeout: Duration::from_secs(45),
|
||||
request_reply_timeout: Duration::from_secs(300),
|
||||
keepalive: Duration::from_secs(15),
|
||||
queue: 1024,
|
||||
write_timeout: Duration::from_secs(10),
|
||||
@@ -85,3 +92,16 @@ impl Default for NetLimits {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_reply_wait_is_longer_than_idle() {
|
||||
let d = NetLimits::default();
|
||||
// Publishing uploads a whole file; a slow link must not cut it off at the session idle
|
||||
// timeout. Keep the reply wait comfortably above it.
|
||||
assert!(d.request_reply_timeout > d.idle_timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,13 +196,15 @@ pub fn plugins(server: &Endpoint, key: &str, req: PluginReq, limits: &NetLimits,
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the
|
||||
/// server a while to keep).
|
||||
/// A WebSocket to `server` that waits for answers up to the request-reply timeout (a large upload
|
||||
/// takes the server a while to keep).
|
||||
fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<Ws, String> {
|
||||
let (stream, _, _) = open(server, timeout, limits)?;
|
||||
let (ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message)))
|
||||
.map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?;
|
||||
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;
|
||||
// A publish uploads a whole file; a large project on a slow link can take several minutes to
|
||||
// receive and answer. The wait uses `request_reply_timeout`, not the session `idle_timeout`.
|
||||
ws.get_ref().set_read_timeout(Some(limits.request_reply_timeout)).map_err(|e| e.to_string())?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +223,10 @@ pub enum DocProp {
|
||||
VariableOrder,
|
||||
/// A design file or a board.
|
||||
Kind,
|
||||
/// A board's countdown (`null`: none).
|
||||
Timer,
|
||||
/// A board's vote (`null`: none); the ballots are [`Entry::Ballot`]s.
|
||||
Vote,
|
||||
}
|
||||
|
||||
/// A document-level entry, by id.
|
||||
@@ -242,6 +246,8 @@ pub enum Entry {
|
||||
DevSnapshot(NodeId),
|
||||
/// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64).
|
||||
Image(String),
|
||||
/// A voter's ballot in a board's vote (value: the layer ids voted on).
|
||||
Ballot(String),
|
||||
}
|
||||
|
||||
/// One synced value a change writes: a node property, a document-level property or a
|
||||
@@ -315,7 +321,7 @@ impl Entry {
|
||||
Entry::Variable(id) => Some(id_space(id, VARIABLE_ID_PREFIX)),
|
||||
Entry::Collection(id) => Some(id_space(id, COLLECTION_ID_PREFIX)),
|
||||
Entry::Comment(id) => Some(space_of(*id)),
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) => None,
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +334,7 @@ impl Entry {
|
||||
Entry::Variable(id) => format!("variable {id}"),
|
||||
Entry::DevSnapshot(id) => format!("ready-for-dev snapshot of {id}"),
|
||||
Entry::Image(hash) => format!("image {hash}"),
|
||||
Entry::Ballot(voter) => format!("ballot of {voter}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +345,7 @@ impl Entry {
|
||||
Entry::Comment(_) => Some(Family::Comments),
|
||||
Entry::Collection(_) => Some(Family::Collections),
|
||||
Entry::Variable(_) => Some(Family::Variables),
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) => None,
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,8 @@ doc_scalars! {
|
||||
AnnotationCategories => annotation_categories,
|
||||
Libraries => libraries,
|
||||
Kind => kind,
|
||||
Timer => timer,
|
||||
Vote => vote,
|
||||
}
|
||||
|
||||
/// Keyed maps (no order): changed and added entries are put, missing ones removed.
|
||||
@@ -250,7 +252,8 @@ pub(crate) fn diff(a: &Document, b: &Document, out: &mut Vec<Change>) -> Result<
|
||||
|k| Entry::Image(k.clone()),
|
||||
image_json,
|
||||
out,
|
||||
)
|
||||
)?;
|
||||
diff_map(&a.votes, &b.votes, |x, y| x == y, |k| Entry::Ballot(k.clone()), Json::of, out)
|
||||
}
|
||||
|
||||
/// Apply a document-level change. Returns whether it can change which pages are listed (the
|
||||
@@ -286,6 +289,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
|
||||
let img = image_of(value)?;
|
||||
doc.images.insert(k.clone(), img);
|
||||
}
|
||||
Entry::Ballot(k) => {
|
||||
doc.votes.insert(k.clone(), value.read("ballot")?);
|
||||
}
|
||||
},
|
||||
Change::Remove { entry } => match entry {
|
||||
Entry::Style(k) => STYLES.remove(doc, k),
|
||||
@@ -298,6 +304,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
|
||||
Entry::Image(k) => {
|
||||
doc.images.remove(k);
|
||||
}
|
||||
Entry::Ballot(k) => {
|
||||
doc.votes.remove(k);
|
||||
}
|
||||
},
|
||||
_ => return Err(Error::Invalid("not a document-level change".into())),
|
||||
}
|
||||
@@ -334,6 +343,7 @@ pub(crate) fn entry_value(doc: &Document, entry: &Entry) -> Result<Option<Json>,
|
||||
Entry::Variable(k) => doc.variables.variables.iter().find(|v| v.id == *k).map(Json::of),
|
||||
Entry::DevSnapshot(k) => doc.dev_snapshots.get(k).map(|s| Json::of(s.as_ref())),
|
||||
Entry::Image(k) => doc.images.get(k).map(image_json),
|
||||
Entry::Ballot(k) => doc.votes.get(k).map(Json::of),
|
||||
};
|
||||
value.transpose()
|
||||
}
|
||||
@@ -347,6 +357,7 @@ pub(crate) fn has(doc: &Document, entry: &Entry) -> bool {
|
||||
Entry::Variable(k) => doc.variables.variables.iter().any(|v| v.id == *k),
|
||||
Entry::DevSnapshot(k) => doc.dev_snapshots.contains_key(k),
|
||||
Entry::Image(k) => doc.images.contains_key(k),
|
||||
Entry::Ballot(k) => doc.votes.contains_key(k),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ impl fmt::Display for Key {
|
||||
Entry::Variable(k) => write!(f, "variable/{k}"),
|
||||
Entry::DevSnapshot(k) => write!(f, "devSnapshot/{k}"),
|
||||
Entry::Image(k) => write!(f, "image/{k}"),
|
||||
Entry::Ballot(k) => write!(f, "ballot/{k}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -7,7 +7,7 @@
|
||||
|
||||
use figcraft_color::Color;
|
||||
use figcraft_geom::kurbo::{Ellipse, RoundedRect};
|
||||
use figcraft_geom::{BezPath, Point, Rect, Shape};
|
||||
use figcraft_geom::{BezPath, Point, Rect, Shape, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::NodeId;
|
||||
@@ -345,8 +345,11 @@ impl Stamp {
|
||||
pub enum Routing {
|
||||
#[default]
|
||||
Straight,
|
||||
/// Right angles only: out of each end along its side's normal, turning once or twice.
|
||||
/// Right angles only: out of each end along its side's normal, turning once or twice, around
|
||||
/// the objects it joins when the short way would cross them.
|
||||
Elbow,
|
||||
/// One smooth curve, leaving and arriving along the sides' normals.
|
||||
Curved,
|
||||
}
|
||||
|
||||
impl Routing {
|
||||
@@ -354,10 +357,11 @@ impl Routing {
|
||||
match self {
|
||||
Routing::Straight => "straight",
|
||||
Routing::Elbow => "elbow",
|
||||
Routing::Curved => "curved",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<Routing> {
|
||||
[Routing::Straight, Routing::Elbow].into_iter().find(|r| r.id().eq_ignore_ascii_case(s))
|
||||
[Routing::Straight, Routing::Elbow, Routing::Curved].into_iter().find(|r| r.id().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +405,17 @@ impl Side {
|
||||
pub fn horizontal(self) -> bool {
|
||||
matches!(self, Side::Left | Side::Right)
|
||||
}
|
||||
|
||||
/// The way out of the side (y down; none for [`Side::Auto`]).
|
||||
pub fn normal(self) -> Vec2 {
|
||||
match self {
|
||||
Side::Top => Vec2::new(0.0, -1.0),
|
||||
Side::Right => Vec2::new(1.0, 0.0),
|
||||
Side::Bottom => Vec2::new(0.0, 1.0),
|
||||
Side::Left => Vec2::new(-1.0, 0.0),
|
||||
Side::Auto => Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One end of a connector: attached to a layer, or a free point on the page.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! A board's facilitation state, shared by everyone on it: the countdown timer and the vote.
|
||||
//!
|
||||
//! The timer keeps when it ends (or, paused, how long is left), never the ticks: each client works
|
||||
//! out the time left from its own clock. A vote's ballots are kept per voter, so two people voting
|
||||
//! at once change different entries.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{BoardItem, Document, NodeId};
|
||||
|
||||
/// The longest a timer runs (24 hours, in milliseconds).
|
||||
pub const TIMER_MAX_MS: u64 = 24 * 60 * 60 * 1000;
|
||||
/// The most votes one person gets in a vote.
|
||||
pub const VOTES_MAX: u32 = 99;
|
||||
|
||||
/// A board's countdown.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", rename_all = "camelCase")]
|
||||
pub enum BoardTimer {
|
||||
/// Counting down to `ends` (milliseconds since the Unix epoch).
|
||||
Running { ends: u64 },
|
||||
/// Stopped with `left` milliseconds to go.
|
||||
Paused { left: u64 },
|
||||
}
|
||||
|
||||
impl BoardTimer {
|
||||
/// Milliseconds left at `now` (ms since the Unix epoch); 0 once it has run out.
|
||||
pub fn left(&self, now: u64) -> u64 {
|
||||
match *self {
|
||||
BoardTimer::Running { ends } => ends.saturating_sub(now).min(TIMER_MAX_MS),
|
||||
BoardTimer::Paused { left } => left.min(TIMER_MAX_MS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a vote is on.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum VoteScope {
|
||||
/// Any object on the board.
|
||||
#[default]
|
||||
All,
|
||||
/// Sticky notes only.
|
||||
Stickies,
|
||||
/// The objects that were selected when it started.
|
||||
Only { ids: Vec<NodeId> },
|
||||
}
|
||||
|
||||
impl VoteScope {
|
||||
/// The object a vote at layer `leaf` goes to: the nearest layer from it up that the vote is
|
||||
/// on (`All`: `whole`, the object a click there picks).
|
||||
pub fn target(&self, doc: &Document, leaf: NodeId, whole: NodeId) -> Option<NodeId> {
|
||||
if matches!(self, VoteScope::All) {
|
||||
return self.takes(doc, whole).then_some(whole);
|
||||
}
|
||||
std::iter::once(leaf).chain(doc.ancestors(leaf)).find(|id| self.takes(doc, *id))
|
||||
}
|
||||
|
||||
/// Whether object `id` takes votes.
|
||||
pub fn takes(&self, doc: &Document, id: NodeId) -> bool {
|
||||
let Some(n) = doc.node(id) else { return false };
|
||||
if doc.pages.contains(&id) || n.parent.is_none() {
|
||||
return false;
|
||||
}
|
||||
match self {
|
||||
VoteScope::All => true,
|
||||
VoteScope::Stickies => matches!(n.board, Some(BoardItem::Sticky { .. })),
|
||||
VoteScope::Only { ids } => ids.contains(&id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A vote: how many votes each person has, what they go on, and whether it has ended (then
|
||||
/// everyone's votes show). The ballots are [`Document::votes`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Vote {
|
||||
pub per_person: u32,
|
||||
#[serde(default)]
|
||||
pub scope: VoteScope,
|
||||
#[serde(default)]
|
||||
pub ended: bool,
|
||||
/// Who started it.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub by: String,
|
||||
}
|
||||
|
||||
/// Every voter's ballot: the objects they put their votes on (once per vote).
|
||||
pub type Ballots = BTreeMap<String, Vec<NodeId>>;
|
||||
|
||||
impl Vote {
|
||||
/// The votes of `ballot` that count: on objects that are still there, up to the cap.
|
||||
pub fn counted<'a>(&self, doc: &'a Document, ballot: &'a [NodeId]) -> impl Iterator<Item = NodeId> + 'a {
|
||||
ballot.iter().copied().filter(|id| doc.node(*id).is_some()).take(self.per_person.min(VOTES_MAX) as usize)
|
||||
}
|
||||
|
||||
/// The votes each object got, most first (ties in layer id order).
|
||||
pub fn tally(&self, doc: &Document, ballots: &Ballots) -> Vec<(NodeId, u32)> {
|
||||
let mut counts: BTreeMap<NodeId, u32> = BTreeMap::new();
|
||||
for ballot in ballots.values() {
|
||||
for id in self.counted(doc, ballot) {
|
||||
*counts.entry(id).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut out: Vec<(NodeId, u32)> = counts.into_iter().collect();
|
||||
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
pub mod board;
|
||||
pub mod colors;
|
||||
pub mod comments;
|
||||
pub mod facilitation;
|
||||
pub mod geometry;
|
||||
pub mod handoff;
|
||||
pub mod ids;
|
||||
@@ -27,6 +28,7 @@ pub mod vector_regions;
|
||||
pub use board::*;
|
||||
pub use colors::*;
|
||||
pub use comments::*;
|
||||
pub use facilitation::*;
|
||||
pub use figcraft_color as color;
|
||||
pub use figcraft_geom as geom;
|
||||
pub use handoff::*;
|
||||
@@ -208,6 +210,15 @@ pub struct Document {
|
||||
/// [`handoff`]).
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub dev_snapshots: BTreeMap<NodeId, Arc<DevSnapshot>>,
|
||||
/// A board's countdown (see [`facilitation`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timer: Option<BoardTimer>,
|
||||
/// A board's vote, running or ended.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vote: Option<Vote>,
|
||||
/// The vote's ballots, by voter.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub votes: Ballots,
|
||||
}
|
||||
|
||||
impl Default for Document {
|
||||
@@ -247,6 +258,9 @@ impl Document {
|
||||
image_presets: Vec::new(),
|
||||
annotation_categories: None,
|
||||
dev_snapshots: BTreeMap::new(),
|
||||
timer: None,
|
||||
vote: None,
|
||||
votes: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,10 @@ thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
same-file = { workspace = true }
|
||||
web-time = "1.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
zip = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
+239
-52
@@ -12,7 +12,8 @@ use figcraft_doc::{
|
||||
HConstraint, LayoutMode, MainAlign, Node, NodeId, NodeKind, Routing, STAMP_SIZE, Shadow, Side, Sizing, Stamp, StrokeAlign, StrokeCap, StrokeJoin,
|
||||
TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color,
|
||||
};
|
||||
use figcraft_geom::{Affine, Point, Rect};
|
||||
use figcraft_geom::kurbo::PathEl;
|
||||
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
@@ -98,10 +99,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"board.connector",
|
||||
"Connector",
|
||||
"{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins",
|
||||
"{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins",
|
||||
true,
|
||||
connector_cmd
|
||||
),
|
||||
spec!(
|
||||
"board.reattach",
|
||||
"Reattach connector",
|
||||
"{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left)} → {id} moves one end of a connector",
|
||||
true,
|
||||
reattach
|
||||
),
|
||||
spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd),
|
||||
spec!(
|
||||
"board.stamp",
|
||||
@@ -127,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"board.setStyle",
|
||||
"Board style",
|
||||
"{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow, startCap?, endCap? (connectors)}",
|
||||
"{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow|curved, startCap?, endCap? (connectors)}",
|
||||
true,
|
||||
set_style
|
||||
),
|
||||
@@ -143,7 +151,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
board_tool(s, ToolId::BoardShape)
|
||||
}),
|
||||
spec!("tool.connector", "Connector", "{style?: straight|elbow} (drag from one object to another)", false, |s, p| {
|
||||
spec!("tool.connector", "Connector", "{style?: straight|elbow|curved} (drag from one object to another)", false, |s, p| {
|
||||
if let Some(v) = string(p, "style") {
|
||||
s.tool.board.routing = routing_of("tool.connector", &v)?;
|
||||
}
|
||||
@@ -205,7 +213,7 @@ fn stamp_of(cmd: &str, v: &str) -> Result<Stamp> {
|
||||
}
|
||||
|
||||
fn routing_of(cmd: &str, v: &str) -> Result<Routing> {
|
||||
Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight or elbow, not `{v}`")))
|
||||
Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight, elbow or curved, not `{v}`")))
|
||||
}
|
||||
|
||||
fn cap_of(cmd: &str, p: &Value, key: &str) -> Result<Option<StrokeCap>> {
|
||||
@@ -232,7 +240,7 @@ fn line_shade(c: Color) -> Color {
|
||||
|
||||
/// Your name on what you make: the name from Preferences, else the one a live session gave you
|
||||
/// (none for the default name outside a session).
|
||||
fn author(s: &Session) -> String {
|
||||
pub(crate) fn author(s: &Session) -> String {
|
||||
let name = crate::live::display_name(&s.prefs);
|
||||
if name.is_empty() { s.live().info.as_ref().map(|i| i.name.clone()).unwrap_or_default() } else { name }
|
||||
}
|
||||
@@ -606,7 +614,7 @@ fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.stroke.cap_end = end;
|
||||
n.board = Some(BoardItem::Connector(c));
|
||||
})?;
|
||||
place_route(doc, id, &pts);
|
||||
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)?);
|
||||
@@ -615,6 +623,80 @@ fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The connector `id` names: a connector, or the label on one.
|
||||
fn connector_of(cmd: &str, doc: &Document, id: NodeId) -> Result<(NodeId, Connector)> {
|
||||
match doc.get(id)?.board.clone() {
|
||||
Some(BoardItem::Connector(c)) => Ok((id, c)),
|
||||
Some(BoardItem::Label { connector }) => match doc.get(connector)?.board.clone() {
|
||||
Some(BoardItem::Connector(c)) => Ok((connector, c)),
|
||||
_ => Err(bad(cmd, "that isn't a connector")),
|
||||
},
|
||||
_ => Err(bad(cmd, "that isn't a connector")),
|
||||
}
|
||||
}
|
||||
|
||||
fn reattach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.reattach";
|
||||
require(s, cmd)?;
|
||||
let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?;
|
||||
let (id, mut c) = connector_of(cmd, s.doc()?, id)?;
|
||||
let from = match string(p, "end").as_deref() {
|
||||
Some("from") => true,
|
||||
Some("to") => false,
|
||||
_ => return Err(bad(cmd, "`end` is from or to")),
|
||||
};
|
||||
let end = end_param(cmd, s, p, "target", "side")?;
|
||||
let other = if from { c.to } else { c.from };
|
||||
if end.node().is_some() && end.node() == other.node() {
|
||||
return Err(bad(cmd, "connect two different layers"));
|
||||
}
|
||||
*(if from { &mut c.from } else { &mut c.to }) = end;
|
||||
let doc = s.doc_mut()?;
|
||||
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"));
|
||||
}
|
||||
doc.get_mut(id)?.board = Some(BoardItem::Connector(c));
|
||||
place_route(doc, id, &pts, c.routing);
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
/// A connector end as `board.connector` and `board.reattach` take it.
|
||||
fn end_json(e: End) -> Value {
|
||||
match e {
|
||||
End::Node { node, .. } => json!(node),
|
||||
End::Point { x, y } => json!({"x": x, "y": y}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of connector `id` that stays while its other one (`from`: its start) is dragged.
|
||||
fn other_end(doc: &Document, id: NodeId, from: bool) -> Option<End> {
|
||||
match &doc.node(id)?.board {
|
||||
Some(BoardItem::Connector(c)) => Some(if from { c.to } else { c.from }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected connector (one, alone and unlocked) and its two ends on the page as drawn.
|
||||
pub fn selected_connector_ends(doc: &Document, sel: &[NodeId]) -> Option<(NodeId, [Point; 2])> {
|
||||
let [id] = sel else { return None };
|
||||
if !matches!(doc.node(*id)?.board, Some(BoardItem::Connector(_))) || crate::tools::locked_in(doc, *id) {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = ends_now(doc, *id)?;
|
||||
Some((*id, [a, b]))
|
||||
}
|
||||
|
||||
/// How near (screen pixels) a press must be to a selected connector's end to drag it.
|
||||
pub const END_HANDLE_PX: f64 = 8.0;
|
||||
|
||||
/// The selected connector's end under `p` at `zoom`, nearest first (`true`: its start).
|
||||
pub(crate) fn end_handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<(NodeId, bool)> {
|
||||
let (id, [a, b]) = selected_connector_ends(doc, sel)?;
|
||||
let (da, db) = ((a - p).hypot(), (b - p).hypot());
|
||||
(da.min(db) * zoom.max(1e-6) <= END_HANDLE_PX).then_some((id, da <= db))
|
||||
}
|
||||
|
||||
/// The label of connector `id`, if it has one.
|
||||
pub fn label_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let parent = doc.node(id)?.parent?;
|
||||
@@ -664,12 +746,7 @@ fn label_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.label";
|
||||
require(s, cmd)?;
|
||||
let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?;
|
||||
let doc = s.doc()?;
|
||||
let id = match doc.node(id).and_then(|n| n.board.clone()) {
|
||||
Some(BoardItem::Connector(_)) => id,
|
||||
Some(BoardItem::Label { connector }) => connector,
|
||||
_ => return Err(bad(cmd, "that isn't a connector")),
|
||||
};
|
||||
let (id, _) = connector_of(cmd, s.doc()?, id)?;
|
||||
let text = req_string(cmd, p, "text")?;
|
||||
let label = set_label(s.doc_mut()?, id, &text)?;
|
||||
Ok(json!({"id": id, "label": label}))
|
||||
@@ -941,7 +1018,7 @@ pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTr
|
||||
n.board = Some(BoardItem::Connector(c));
|
||||
}
|
||||
if let Some(pts) = route(doc, &c) {
|
||||
place_route(doc, id, &pts);
|
||||
place_route(doc, id, &pts, c.routing);
|
||||
}
|
||||
}
|
||||
for (label, connector) in labels {
|
||||
@@ -974,7 +1051,7 @@ fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet<NodeId>
|
||||
if !touched.contains(&id) || b.transform == n.transform || b.board != n.board {
|
||||
return false;
|
||||
}
|
||||
let Some(expected) = route(doc, c).and_then(|pts| figcraft_geom::points_bounds(pts.iter().copied())).map(|b| b.origin()) else { return false };
|
||||
let Some(expected) = route(doc, c).and_then(|pts| finite_box(&route_path(&pts, c.routing))).map(|b| b.origin()) else { return false };
|
||||
let d = (doc.world_transform(id) * Point::ZERO) - expected;
|
||||
if d.hypot() <= 1e-9 {
|
||||
return false;
|
||||
@@ -1017,7 +1094,12 @@ fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> {
|
||||
fn route_mid(doc: &Document, id: NodeId) -> Option<Point> {
|
||||
let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None };
|
||||
let w = doc.world_transform(id);
|
||||
let pts: Vec<Point> = network.vertices.iter().map(|v| w * v.point()).collect();
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
figcraft_geom::kurbo::flatten(network.stroke_path().iter(), 0.25, |el| {
|
||||
if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
|
||||
pts.push(w * p);
|
||||
}
|
||||
});
|
||||
let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum();
|
||||
let mut left = total / 2.0;
|
||||
for s in pts.windows(2) {
|
||||
@@ -1031,19 +1113,42 @@ fn route_mid(doc: &Document, id: NodeId) -> Option<Point> {
|
||||
pts.first().copied()
|
||||
}
|
||||
|
||||
/// Write the route `pts` (page points) into connector `id`: its box, place and path. Untouched
|
||||
/// when it already runs there, so a connector that didn't move isn't an edit.
|
||||
fn place_route(doc: &mut Document, id: NodeId, pts: &[Point]) {
|
||||
/// The path through a route's page points `pts` (see [`route`]): one curve for a curved
|
||||
/// connector, straight runs otherwise.
|
||||
fn route_path(pts: &[Point], routing: Routing) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
match (routing, pts) {
|
||||
(Routing::Curved, [p0, c0, c1, p1]) => {
|
||||
path.move_to(*p0);
|
||||
path.curve_to(*c0, *c1, *p1);
|
||||
}
|
||||
_ => {
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if i == 0 { path.move_to(*p) } else { path.line_to(*p) }
|
||||
}
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// The box a path is drawn in, when it is finite.
|
||||
fn finite_box(path: &BezPath) -> Option<Rect> {
|
||||
let b = path.bounding_box();
|
||||
(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()).then_some(b)
|
||||
}
|
||||
|
||||
/// Write the route `pts` (page points, see [`route`]) into connector `id`: its box, place and
|
||||
/// path. Untouched when it already runs there, so a connector that didn't move isn't an edit.
|
||||
fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing: Routing) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p));
|
||||
let inv = pw.inverse();
|
||||
let local: Vec<Point> = pts.iter().map(|p| inv * *p).collect();
|
||||
let Some(b) = figcraft_geom::points_bounds(local.iter().copied()) else { return };
|
||||
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let rel: Vec<Point> = local.iter().map(|p| Point::new(p.x - b.x0, p.y - b.y0)).collect();
|
||||
let network = VectorNetwork::from_points(&rel, false);
|
||||
let local = pw.inverse() * route_path(pts, routing);
|
||||
let Some(b) = finite_box(&local) else { return };
|
||||
let rel = Affine::translate((-b.x0, -b.y0)) * local;
|
||||
let network = match routing {
|
||||
Routing::Curved => VectorNetwork::from_path(&rel),
|
||||
_ => VectorNetwork::from_points(&rel.elements().iter().filter_map(PathEl::end_point).collect::<Vec<_>>(), false),
|
||||
};
|
||||
let t = Affine::translate((b.x0, b.y0));
|
||||
let same =
|
||||
n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network);
|
||||
@@ -1057,8 +1162,12 @@ fn place_route(doc: &mut Document, id: NodeId, pts: &[Point]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far an elbow connector going around the objects it joins keeps from them.
|
||||
const ROOM: f64 = 24.0;
|
||||
|
||||
/// The page points connector `c` runs through: from the middle of the side of each attached layer
|
||||
/// facing the other end (or the side it names), straight or in right angles.
|
||||
/// facing the other end (or the side it names), straight, in right angles, or (curved) its two
|
||||
/// ends with the curve's two control points between them.
|
||||
pub fn route(doc: &Document, c: &Connector) -> Option<Vec<Point>> {
|
||||
let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n));
|
||||
let centre = |e: &End, b: Option<Rect>| match e {
|
||||
@@ -1067,25 +1176,36 @@ pub fn route(doc: &Document, c: &Connector) -> Option<Vec<Point>> {
|
||||
};
|
||||
let (fb, tb) = (bounds(&c.from), bounds(&c.to));
|
||||
let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?);
|
||||
let (p0, h0) = attach(&c.from, fb, tc)?;
|
||||
let (p1, h1) = attach(&c.to, tb, fc)?;
|
||||
let mut pts = match c.routing {
|
||||
Routing::Straight => vec![p0, p1],
|
||||
Routing::Elbow => elbow(p0, h0, p1, h1),
|
||||
let (p0, s0) = attach(&c.from, fb, tc)?;
|
||||
let (p1, s1) = attach(&c.to, tb, fc)?;
|
||||
let pts = match c.routing {
|
||||
Routing::Straight => tidy(vec![p0, p1]),
|
||||
Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::<Vec<_>>()),
|
||||
Routing::Curved => curve(p0, s0, p1, s1),
|
||||
};
|
||||
pts.dedup();
|
||||
// A turn that doesn't turn (the ends in line) is no point of the route.
|
||||
let in_line = |i: usize, b: &Point| match (i.checked_sub(1).and_then(|j| pts.get(j)), pts.get(i + 1)) {
|
||||
(Some(a), Some(c)) => (a.x == b.x && b.x == c.x) || (a.y == b.y && b.y == c.y),
|
||||
_ => false,
|
||||
};
|
||||
let pts: Vec<Point> = pts.iter().enumerate().filter(|(i, b)| !in_line(*i, b)).map(|(_, b)| *b).collect();
|
||||
pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts)
|
||||
}
|
||||
|
||||
/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and
|
||||
/// whether that side faces sideways (`None`: a free point).
|
||||
fn attach(e: &End, b: Option<Rect>, other: Point) -> Option<(Point, Option<bool>)> {
|
||||
/// `pts` without repeats or turns that don't turn (three in a line across or down).
|
||||
fn tidy(pts: Vec<Point>) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = Vec::with_capacity(pts.len());
|
||||
for p in pts {
|
||||
if out.last() == Some(&p) {
|
||||
continue;
|
||||
}
|
||||
if let [.., a, b] = out.as_slice()
|
||||
&& ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y))
|
||||
{
|
||||
out.pop();
|
||||
}
|
||||
out.push(p);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that
|
||||
/// side (`None`: a free point).
|
||||
fn attach(e: &End, b: Option<Rect>, other: Point) -> Option<(Point, Option<Side>)> {
|
||||
match e {
|
||||
End::Point { x, y } => Some((Point::new(*x, *y), None)),
|
||||
End::Node { side, .. } => {
|
||||
@@ -1104,14 +1224,55 @@ fn attach(e: &End, b: Option<Rect>, other: Point) -> Option<(Point, Option<bool>
|
||||
}
|
||||
s => *s,
|
||||
};
|
||||
Some((side.point(b), Some(side.horizontal())))
|
||||
Some((side.point(b), Some(side)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals
|
||||
/// (`h`: sideways; a free end follows the other).
|
||||
fn elbow(p0: Point, h0: Option<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
|
||||
/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no
|
||||
/// way): the ends and the two control points between them.
|
||||
fn curve(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>) -> Vec<Point> {
|
||||
let reach = (p1 - p0).hypot() / 2.0;
|
||||
let pull = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach);
|
||||
vec![p0, pull(p0, s0), pull(p1, s1), p1]
|
||||
}
|
||||
|
||||
/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a
|
||||
/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`,
|
||||
/// else the cheapest of a few ways around them (by length, and turns).
|
||||
fn elbow(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>, boxes: &[Rect]) -> Vec<Point> {
|
||||
let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal)));
|
||||
let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct };
|
||||
if !crosses(&direct, boxes) {
|
||||
return direct;
|
||||
}
|
||||
// Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects.
|
||||
let out = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM);
|
||||
let (a, b) = (out(p0, s0), out(p1, s1));
|
||||
let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]];
|
||||
for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] {
|
||||
ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]);
|
||||
}
|
||||
for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] {
|
||||
ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]);
|
||||
}
|
||||
let cost = |r: &Vec<Point>| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::<f64>() + ROOM * r.len() as f64;
|
||||
ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct)
|
||||
}
|
||||
|
||||
/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge,
|
||||
/// or out of one, is outside).
|
||||
fn crosses(pts: &[Point], boxes: &[Rect]) -> bool {
|
||||
const EDGE: f64 = 0.5;
|
||||
pts.windows(2).any(|s| {
|
||||
let (a, b) = (s[0], s[1]);
|
||||
boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE)
|
||||
})
|
||||
}
|
||||
|
||||
/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end
|
||||
/// follows the other): halfway across between two sides facing the same way, one turn otherwise.
|
||||
fn short_elbow(p0: Point, h0: Option<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
|
||||
let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs());
|
||||
let h1 = h1.unwrap_or(h0);
|
||||
match (h0, h1) {
|
||||
@@ -1229,16 +1390,42 @@ impl Session {
|
||||
if to.node().is_some() && to.node() == from.node() {
|
||||
return Ok(());
|
||||
}
|
||||
let json_end = |e: End| match e {
|
||||
End::Node { node, .. } => json!(node),
|
||||
End::Point { x, y } => json!({"x": x, "y": y}),
|
||||
};
|
||||
let style = self.tool.board.routing.id();
|
||||
self.execute("board.connector", &json!({"from": json_end(from), "to": json_end(to), "style": style}))?;
|
||||
self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?;
|
||||
self.board_placed();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dragging the selected connector `id`'s end (`from`: its start): a line from its other end
|
||||
/// to the pointer, and the object it would attach to outlined.
|
||||
pub(crate) fn reattach_drag(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) {
|
||||
self.tool.gesture = Gesture::Reattach { id, from, start };
|
||||
let Ok(doc) = self.doc() else { return };
|
||||
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);
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// The end let go: attached to the object under the pointer, or free there (`board.reattach`).
|
||||
/// 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;
|
||||
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let target = self.end_at(ev);
|
||||
let other = other_end(self.doc()?, id, from).and_then(|e| e.node());
|
||||
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)}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The eraser at the pointer: the marker and highlighter strokes it touches go (until the
|
||||
/// gesture ends, which is one undo step).
|
||||
pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Board connectors, continued: moving an end onto another object or off into the canvas (the
|
||||
//! command and a drag of the selected connector's end), curved connectors, and elbows that go
|
||||
//! around the objects they join.
|
||||
|
||||
use figcraft_doc::{BoardItem, End, NodeKind, Routing, Side};
|
||||
use figcraft_geom::{Point, Rect};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::board::route;
|
||||
use crate::board_tests::{board, connector, drawn, id, near, node};
|
||||
use crate::tools::{PointerEvent, PointerKind, handle_at};
|
||||
|
||||
/// A board with three 100 × 100 shapes in a row 300 apart, and an elbow connector from the
|
||||
/// first to the second: (a, b, c, connector).
|
||||
fn three() -> (Session, [figcraft_doc::NodeId; 4]) {
|
||||
let mut s = board();
|
||||
let mut shape = |x: f64| id(&s.execute("board.shape", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap(), "id");
|
||||
let (a, b, c) = (shape(0.0), shape(300.0), shape(600.0));
|
||||
let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap(), "id");
|
||||
(s, [a, b, c, k])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reattach_moves_one_end_and_undoes() {
|
||||
let (mut s, [a, b, c, k]) = three();
|
||||
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": c, "side": "top"})).unwrap();
|
||||
assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top });
|
||||
assert_eq!(connector(&s, k).from.node(), Some(a));
|
||||
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 0.0)), "arrives on c's top: {:?}", drawn(&s, k));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(connector(&s, k).to.node(), Some(b), "one undo step");
|
||||
// Off into the canvas: the end stays there, free. The selection is the default connector.
|
||||
s.execute("select.set", &json!({"ids": [k]})).unwrap();
|
||||
s.execute("board.reattach", &json!({"end": "from", "target": {"x": -200, "y": 400}})).unwrap();
|
||||
assert_eq!(connector(&s, k).from, End::Point { x: -200.0, y: 400.0 });
|
||||
assert!(near(drawn(&s, k)[0], Point::new(-200.0, 400.0)));
|
||||
// A label stands for its connector.
|
||||
let label = id(&s.execute("board.label", &json!({"id": k, "text": "next"})).unwrap(), "label");
|
||||
s.execute("board.reattach", &json!({"id": label, "end": "from", "target": c})).unwrap();
|
||||
assert_eq!(connector(&s, k).from.node(), Some(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reattach_refuses_what_it_cant_join() {
|
||||
let (mut s, [a, b, _, k]) = three();
|
||||
let refused = |s: &mut Session, p| s.execute("board.reattach", &p).is_err();
|
||||
assert!(refused(&mut s, json!({"id": k, "end": "to", "target": a})), "both ends on one layer");
|
||||
assert!(refused(&mut s, json!({"id": k, "end": "middle", "target": b})));
|
||||
assert!(refused(&mut s, json!({"id": k, "end": "to"})), "no target");
|
||||
assert!(refused(&mut s, json!({"id": a, "end": "to", "target": b})), "not a connector");
|
||||
assert!(refused(&mut s, json!({"id": k, "end": "to", "target": k})), "a connector isn't an end");
|
||||
assert!(refused(&mut s, json!({"id": k, "end": "to", "target": {"x": "a", "y": 1}})));
|
||||
assert!(refused(&mut s, json!({"id": k, "end": "to", "target": b, "side": "inside"})));
|
||||
let mut d = Session::new();
|
||||
d.execute("file.new", &json!({})).unwrap();
|
||||
assert!(d.execute("board.reattach", &json!({"end": "to", "target": {"x": 0, "y": 0}})).is_err(), "boards only");
|
||||
assert_eq!(connector(&s, k).to.node(), Some(b), "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_selected_connectors_end_reattaches_it() {
|
||||
let (mut s, [a, b, c, k]) = three();
|
||||
s.execute("select.set", &json!({"ids": [k]})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(handle_at(doc, &[k], Point::new(100.0, 50.0), 1.0), None, "no box handles on a connector");
|
||||
let end = *drawn(&s, k).last().unwrap();
|
||||
let ev = |k, p: Point| PointerEvent::at(k, p.x, p.y, 1.0);
|
||||
// Onto c.
|
||||
s.pointer(&ev(PointerKind::Down, end)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, Point::new(500.0, 50.0))).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, Point::new(650.0, 50.0))).unwrap();
|
||||
assert_eq!(s.tool.overlay.drop_target, Some(c), "the object it would attach to shows");
|
||||
assert!(s.tool.overlay.rubber.is_some());
|
||||
s.pointer(&ev(PointerKind::Up, Point::new(650.0, 50.0))).unwrap();
|
||||
assert_eq!(connector(&s, k).to.node(), Some(c));
|
||||
assert_eq!(connector(&s, k).from.node(), Some(a));
|
||||
assert_eq!((s.tool.overlay.drop_target, s.tool.overlay.rubber), (None, None));
|
||||
assert_eq!(s.selection(), vec![k], "still selected");
|
||||
// The start into empty canvas: free where it was let go.
|
||||
let start = drawn(&s, k)[0];
|
||||
s.pointer(&ev(PointerKind::Down, start + figcraft_geom::Vec2::new(3.0, 0.0))).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, Point::new(100.0, 400.0))).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, Point::new(100.0, 400.0))).unwrap();
|
||||
assert_eq!(connector(&s, k).from, End::Point { x: 100.0, y: 400.0 });
|
||||
// A click on an end, or a drop on the other end's object, changes nothing.
|
||||
let before = connector(&s, k);
|
||||
let end = *drawn(&s, k).last().unwrap();
|
||||
s.pointer(&ev(PointerKind::Down, end)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, end)).unwrap();
|
||||
assert_eq!(connector(&s, k), before);
|
||||
s.execute("board.reattach", &json!({"id": k, "end": "from", "target": b})).unwrap();
|
||||
let before = connector(&s, k);
|
||||
s.pointer(&ev(PointerKind::Down, *drawn(&s, k).last().unwrap())).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, Point::new(350.0, 50.0))).unwrap();
|
||||
assert_eq!(s.tool.overlay.drop_target, None, "not onto the other end's object");
|
||||
s.pointer(&ev(PointerKind::Up, Point::new(350.0, 50.0))).unwrap();
|
||||
assert_eq!(connector(&s, k), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_connectors_leave_and_arrive_along_the_sides() {
|
||||
let (mut s, [a, _, c, k]) = three();
|
||||
s.execute("board.setStyle", &json!({"ids": [k], "style": "curved"})).unwrap();
|
||||
assert_eq!(connector(&s, k).routing, Routing::Curved);
|
||||
let n = node(&s, k);
|
||||
let NodeKind::Vector { network } = &n.kind else { panic!("a vector") };
|
||||
assert_eq!((network.vertices.len(), network.segments.len()), (2, 1));
|
||||
assert!(!network.segments[0].is_straight(), "one curve");
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]);
|
||||
// Side to side in a line: the curve's control points lie on that line, so its box is flat.
|
||||
assert!(n.height.abs() < 1e-9 && (n.width - 200.0).abs() < 1e-9, "{} × {}", n.width, n.height);
|
||||
// From a's bottom to c's top: out downwards and in from above; the box holds the whole curve.
|
||||
let k2 = id(
|
||||
&s.execute("board.connector", &json!({"from": a, "to": c, "fromSide": "bottom", "toSide": "top", "style": "curved", "label": "x"})).unwrap(),
|
||||
"id",
|
||||
);
|
||||
let pts = route(s.doc().unwrap(), &connector(&s, k2)).unwrap();
|
||||
assert_eq!(pts.len(), 4);
|
||||
assert!(pts[1].y > pts[0].y && pts[2].y < pts[3].y, "{pts:?}");
|
||||
let b = s.doc().unwrap().world_bounds(k2).unwrap();
|
||||
assert!(b.y0 <= 0.0 + 1e-9 && b.y1 >= 100.0 - 1e-9, "{b:?}");
|
||||
// The label sits on the curve's middle (by length): here, by symmetry, the middle of the box.
|
||||
let label = crate::board::label_of(s.doc().unwrap(), k2).unwrap();
|
||||
let lc = s.doc().unwrap().world_bounds(label).unwrap().center();
|
||||
assert!((lc - b.center()).hypot() < 0.5, "{lc:?} vs {:?}", b.center());
|
||||
assert!(s.execute("board.setStyle", &json!({"ids": [k], "style": "wavy"})).is_err());
|
||||
s.execute("tool.connector", &json!({"style": "curved"})).unwrap();
|
||||
assert_eq!(s.tool.board.routing, Routing::Curved);
|
||||
}
|
||||
|
||||
/// Whether `pts` (a right-angled route) runs through the inside of `r`.
|
||||
fn through(pts: &[Point], r: Rect) -> bool {
|
||||
pts.windows(2).any(|w| {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
a.x.min(b.x) < r.x1 - 0.5 && a.x.max(b.x) > r.x0 + 0.5 && a.y.min(b.y) < r.y1 - 0.5 && a.y.max(b.y) > r.y0 + 0.5
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elbows_go_around_the_objects_they_join() {
|
||||
let (mut s, [a, b, _, k]) = three();
|
||||
// Out of a's left and b's right: the short way runs through both, so it goes over them.
|
||||
s.execute("board.connector", &json!({"from": a, "to": b, "fromSide": "left", "toSide": "right", "style": "elbow"})).unwrap();
|
||||
let k2 = s.selection()[0];
|
||||
let pts = drawn(&s, k2);
|
||||
let (ra, rb) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(300.0, 0.0, 400.0, 100.0));
|
||||
assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}");
|
||||
assert_eq!(pts.first().copied(), Some(Point::new(0.0, 50.0)));
|
||||
assert_eq!(pts.last().copied(), Some(Point::new(400.0, 50.0)));
|
||||
assert_eq!(pts.len(), 6, "out, over, down and in: {pts:?}");
|
||||
assert!(pts.iter().all(|p| p.y <= 50.0), "over the top, the nearer way round: {pts:?}");
|
||||
assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles only");
|
||||
// Facing each other, the short way stays.
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]);
|
||||
// An end at the bottom of b leaving towards a above it: around b's side and up.
|
||||
s.execute("board.reattach", &json!({"id": k2, "end": "to", "target": b, "side": "bottom"})).unwrap();
|
||||
let pts = drawn(&s, k2);
|
||||
assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}");
|
||||
assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reattach_survives_junk() {
|
||||
let (mut s, [_, _, _, k]) = three();
|
||||
for p in [
|
||||
json!({"id": k, "end": "to", "target": {"x": 1e308, "y": -1e308}}),
|
||||
json!({"id": k, "end": "from", "target": {"x": f64::MAX, "y": 0}, "side": 5}),
|
||||
json!({"id": [k], "end": ["to"], "target": [1]}),
|
||||
json!({"ids": "x", "end": null, "target": null}),
|
||||
json!({"id": k, "end": "to", "target": 99_999}),
|
||||
] {
|
||||
let _ = s.execute("board.reattach", &p);
|
||||
let _ = s.execute("board.setStyle", &json!({"ids": [k], "style": "curved"}));
|
||||
let _ = s.execute("board.reattach", &p);
|
||||
}
|
||||
assert!(matches!(node(&s, k).board, Some(BoardItem::Connector(_))));
|
||||
assert!(drawn(&s, k).iter().all(|p| p.x.is_finite() && p.y.is_finite()));
|
||||
}
|
||||
@@ -277,6 +277,8 @@ fn board_commands_survive_junk() {
|
||||
json!({"ids": ["x", -1, 99_999], "color": "#GGG", "fontSize": -1, "side": "up", "edit": "yes"}),
|
||||
json!({"id": 2, "text": "", "path": "M0 0 L", "points": [[1]], "highlighter": "yes"}),
|
||||
json!({"path": "M0 0 L 1e308 1e308", "points": [[0, 0], [1e308, 1]]}),
|
||||
json!({"end": "from", "target": {"x": 1e308, "y": -1e308}, "side": "inside", "style": "curved"}),
|
||||
json!({"id": 99_999, "end": 7, "target": [1], "style": "wavy"}),
|
||||
];
|
||||
let ids: Vec<&str> = crate::cmd::all_commands()
|
||||
.iter()
|
||||
|
||||
@@ -458,7 +458,7 @@ fn entry_label(plan: &Plan, entry: &Entry, value: Option<&Value>) -> Option<(&'s
|
||||
Entry::Variable(_) => "variable",
|
||||
Entry::Collection(_) => "variable collection",
|
||||
Entry::Comment(_) => "comment",
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) => return None,
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => return None,
|
||||
};
|
||||
let base = plan.base().value(&Target::Entry(entry.clone())).ok().flatten();
|
||||
let named = value.or(base.as_ref().map(|j| &j.0)).and_then(|v| v.get("name").or_else(|| v.get("text"))).and_then(Value::as_str);
|
||||
|
||||
@@ -235,6 +235,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
|
||||
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
|
||||
spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)),
|
||||
spec!("fill.copy", "Copy fill", "{id?, index? (default the top fill)}", false, |s, p| paint_copy(s, p, true)),
|
||||
spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one)}", true, |s, p| paint_paste(s, p, true)),
|
||||
spec!(
|
||||
"stroke.copy",
|
||||
"Copy stroke",
|
||||
"{id?, index? (default the top stroke)} its paint and the stroke's weight, alignment, sides, caps, joins and dashes",
|
||||
false,
|
||||
|s, p| paint_copy(s, p, false)
|
||||
),
|
||||
spec!(
|
||||
"stroke.paste",
|
||||
"Paste stroke",
|
||||
"{ids?, index? (replace the stroke there; without: add one)} the copied stroke's settings come with its paint",
|
||||
true,
|
||||
|s, p| paint_paste(s, p, false)
|
||||
),
|
||||
spec!(
|
||||
"fill.pattern",
|
||||
"Pattern fill",
|
||||
@@ -282,6 +298,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("effect.copy", "Copy effect", "{id?, index? (default the top effect)}", false, effect_copy),
|
||||
spec!("effect.paste", "Paste effect", "{ids?, index? (replace the effect there; without: add one)}", true, effect_paste),
|
||||
spec!("grid.add", "Add layout grid", "{ids?, grid? {kind: grid|columns|rows, ..}}", true, |s, p| {
|
||||
let g: LayoutGrid = parse("grid.add", p, "grid")?.unwrap_or_default();
|
||||
for id in req_targets("grid.add", s, p)? {
|
||||
@@ -620,13 +638,21 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into()));
|
||||
}
|
||||
let (nodes, images, bounds) = copied(&doc, &tops);
|
||||
// A design-app paste replaces the copied layers, not the copied rows: a `fill.copy` before it
|
||||
// still pastes afterwards.
|
||||
let props = s.clipboard.props.take();
|
||||
let paint = s.clipboard.paint.take();
|
||||
let stroke = s.clipboard.stroke.take();
|
||||
let effect = s.clipboard.effect.take();
|
||||
// No open file has id 0. So paste treats this copy as a copy from a different file.
|
||||
let layers_generation = s.clipboard.layers_generation.wrapping_add(1);
|
||||
s.clipboard = crate::Clipboard {
|
||||
nodes,
|
||||
images,
|
||||
props,
|
||||
paint,
|
||||
stroke,
|
||||
effect,
|
||||
bounds,
|
||||
source_doc: 0,
|
||||
source: Some(doc),
|
||||
@@ -1434,6 +1460,61 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke
|
||||
/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along.
|
||||
fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
let cmd = if fills { "fill.copy" } else { "stroke.copy" };
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
|
||||
let at = opt_index(cmd, p, "index")?;
|
||||
let (paint, stroke) = {
|
||||
let doc = s.doc()?;
|
||||
let n = doc.get(id)?;
|
||||
let list = if fills { &n.fills } else { &n.strokes };
|
||||
let at = at.unwrap_or(list.len().saturating_sub(1));
|
||||
let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?;
|
||||
(paint, (!fills).then(|| n.stroke.clone()))
|
||||
};
|
||||
s.clipboard.paint = Some((fills, paint.clone()));
|
||||
s.clipboard.stroke = stroke;
|
||||
serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A
|
||||
/// stroke's copied settings go onto each target too.
|
||||
fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
let cmd = if fills { "fill.paste" } else { "stroke.paste" };
|
||||
let Some((copied_fills, paint)) = s.clipboard.paint.clone() else {
|
||||
return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into()));
|
||||
};
|
||||
if copied_fills != fills {
|
||||
let what = if fills { "stroke" } else { "fill" };
|
||||
return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}")));
|
||||
}
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
// A copied pattern pointing at a target or a layer inside it would make the layer pattern
|
||||
// itself; `fill.pattern` refuses the same, and the renderer would drop the pattern.
|
||||
if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind {
|
||||
let doc = s.doc()?;
|
||||
if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) {
|
||||
return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it"));
|
||||
}
|
||||
}
|
||||
let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?});
|
||||
let out = match opt_index(cmd, p, "index")? {
|
||||
Some(at) => {
|
||||
q["index"] = json!(at);
|
||||
paint_set(s, &q, fills)?
|
||||
}
|
||||
None => paint_list(s, &q, fills, true)?,
|
||||
};
|
||||
if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) {
|
||||
for id in ids {
|
||||
s.doc_mut()?.get_mut(id)?.stroke = stroke.clone();
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "fill.pattern";
|
||||
let src = req_id(CMD, p, "source")?;
|
||||
@@ -1470,3 +1551,39 @@ fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Copy effect `index` (the top one by default) of the first target into the clipboard.
|
||||
fn effect_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "effect.copy";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?;
|
||||
let at = opt_index(CMD, p, "index")?;
|
||||
let e = {
|
||||
let doc = s.doc()?;
|
||||
let list = &doc.get(id)?.effects;
|
||||
let at = at.unwrap_or(list.len().saturating_sub(1));
|
||||
list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))?
|
||||
};
|
||||
s.clipboard.effect = Some(e.clone());
|
||||
serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string()))
|
||||
}
|
||||
|
||||
/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one.
|
||||
fn effect_paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "effect.paste";
|
||||
let Some(e) = s.clipboard.effect.clone() else {
|
||||
return Err(EngineError::Disabled(CMD.into(), "no copied effect".into()));
|
||||
};
|
||||
let at = opt_index(CMD, p, "index")?;
|
||||
for id in req_targets(CMD, s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
match at {
|
||||
Some(i) => {
|
||||
if let Some(slot) = n.effects.get_mut(i) {
|
||||
*slot = e.clone();
|
||||
}
|
||||
}
|
||||
None => n.effects.push(e.clone()),
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Running a session on a board: a countdown everyone sees and votes on objects.
|
||||
//!
|
||||
//! Both live in the document ([`figcraft_doc::facilitation`]) so they reach everyone in a live
|
||||
//! session, and neither is an undo step: undoing an edit leaves the timer and the vote as they are.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTES_MAX, Vote, VoteScope};
|
||||
use figcraft_geom::{Point, Rect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::board::{require, text_of};
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::{id, num, req_string, req_targets, string};
|
||||
use crate::tools::PointerEvent;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// Minutes a timer starts with, and adds, when none are given.
|
||||
const START_MINUTES: f64 = 5.0;
|
||||
const ADD_MINUTES: f64 = 1.0;
|
||||
/// Votes each person gets when none are given.
|
||||
const VOTES: u32 = 3;
|
||||
/// A vote badge's radius on screen (logical pixels).
|
||||
pub const BADGE_PX: f64 = 11.0;
|
||||
/// The longest a voter name is kept.
|
||||
const MAX_VOTER: usize = 64;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"board.timer",
|
||||
"Timer",
|
||||
"{action: start|pause|resume|add|stop, minutes? (start: default 5; add: default 1; up to 1440)} → the timer's state (as board.timerState) one countdown per board, the same for everyone in a live session",
|
||||
false,
|
||||
timer_cmd
|
||||
),
|
||||
spec!(
|
||||
"board.timerState",
|
||||
"Timer state",
|
||||
"{} → {state: off|running|paused|done, left (ms), text (m:ss), ends? (ms since 1970, while running)}",
|
||||
false,
|
||||
|s, _| {
|
||||
require(s, "board.timerState")?;
|
||||
Ok(timer_json(s.doc()?.timer, now_ms()))
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"board.vote",
|
||||
"Voting session",
|
||||
"{action: start|cast|retract|end|clear, votes? (start: per person, 1–99, default 3), on? (start: all|stickies|selection; default all), ids? (start with on: selection; default the selection), id (cast, retract: the object), voter? (default you)} → the vote's state (as board.voteResults) one vote at a time; others' votes show once it ends",
|
||||
false,
|
||||
vote_cmd
|
||||
),
|
||||
spec!(
|
||||
"board.voteResults",
|
||||
"Voting results",
|
||||
"{voter?} → {state: off|running|ended, votesPerPerson, on, by, left (your votes left), mine [{id, votes}], voters, results? [{id, name, votes, rank}] (once ended, most votes first)}",
|
||||
false,
|
||||
|s, p| {
|
||||
require(s, "board.voteResults")?;
|
||||
let who = voter(s, p);
|
||||
Ok(vote_json(s.doc()?, &who))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Milliseconds since the Unix epoch (0 if the clock is before it).
|
||||
pub fn now_ms() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
|
||||
}
|
||||
|
||||
/// `left` milliseconds as a clock: "4:05", "1:02:03" (whole seconds, rounded up).
|
||||
pub fn clock(left: u64) -> String {
|
||||
let s = left.div_ceil(1000);
|
||||
let (h, m, s) = (s / 3600, s / 60 % 60, s % 60);
|
||||
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
|
||||
}
|
||||
|
||||
/// The timer's state at `now`: off, running, paused or done (run out, until someone stops it).
|
||||
pub fn timer_state(t: Option<BoardTimer>, now: u64) -> &'static str {
|
||||
match t {
|
||||
None => "off",
|
||||
Some(BoardTimer::Paused { .. }) => "paused",
|
||||
Some(t) if t.left(now) == 0 => "done",
|
||||
Some(_) => "running",
|
||||
}
|
||||
}
|
||||
|
||||
fn timer_json(t: Option<BoardTimer>, now: u64) -> Value {
|
||||
let left = t.map_or(0, |t| t.left(now));
|
||||
let ends = match t {
|
||||
Some(BoardTimer::Running { ends }) => Some(ends),
|
||||
_ => None,
|
||||
};
|
||||
json!({"state": timer_state(t, now), "left": left, "text": clock(left), "ends": ends})
|
||||
}
|
||||
|
||||
/// `minutes` (or `default`) as milliseconds: more than 0, at most a day.
|
||||
fn duration(cmd: &str, p: &Value, default: f64) -> Result<u64> {
|
||||
let m = match p.get("minutes").filter(|v| !v.is_null()) {
|
||||
Some(_) => num(p, "minutes").ok_or_else(|| bad(cmd, "`minutes` must be a number"))?,
|
||||
None => default,
|
||||
};
|
||||
let ms = (m * 60_000.0).round();
|
||||
if !(1000.0..=TIMER_MAX_MS as f64).contains(&ms) {
|
||||
return Err(bad(cmd, "`minutes` must be from 1 second to 1440 minutes"));
|
||||
}
|
||||
Ok(ms as u64)
|
||||
}
|
||||
|
||||
/// The timer after `action` at `now` (`Ok(None)`: stopped).
|
||||
pub fn timer_after(cmd: &str, t: Option<BoardTimer>, action: &str, p: &Value, now: u64) -> Result<Option<BoardTimer>> {
|
||||
let off = || EngineError::Disabled(cmd.into(), "there is no timer: start one".into());
|
||||
Ok(match action {
|
||||
"start" => Some(BoardTimer::Running { ends: now.saturating_add(duration(cmd, p, START_MINUTES)?) }),
|
||||
"stop" => None,
|
||||
"pause" => match t {
|
||||
Some(r @ BoardTimer::Running { .. }) if r.left(now) > 0 => Some(BoardTimer::Paused { left: r.left(now) }),
|
||||
_ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't running".into())),
|
||||
},
|
||||
"resume" => match t {
|
||||
Some(BoardTimer::Paused { left }) => Some(BoardTimer::Running { ends: now.saturating_add(left) }),
|
||||
_ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't paused".into())),
|
||||
},
|
||||
"add" => {
|
||||
let more = duration(cmd, p, ADD_MINUTES)?;
|
||||
let left = t.ok_or_else(off)?.left(now).saturating_add(more).min(TIMER_MAX_MS);
|
||||
match t {
|
||||
Some(BoardTimer::Paused { .. }) => Some(BoardTimer::Paused { left }),
|
||||
_ => Some(BoardTimer::Running { ends: now.saturating_add(left) }),
|
||||
}
|
||||
}
|
||||
other => return Err(bad(cmd, format!("unknown action `{other}` (start|pause|resume|add|stop)"))),
|
||||
})
|
||||
}
|
||||
|
||||
fn timer_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
require(s, "board.timer")?;
|
||||
let action = req_string("board.timer", p, "action")?;
|
||||
let now = now_ms();
|
||||
let t = timer_after("board.timer", s.doc()?.timer, &action, p, now)?;
|
||||
if s.doc()?.timer != t {
|
||||
s.doc_mut()?.timer = t;
|
||||
}
|
||||
Ok(timer_json(t, now))
|
||||
}
|
||||
|
||||
/// Who votes: `voter` from the params, else you as the live session knows you (your client id
|
||||
/// there, so two people of the same name vote apart), else your name.
|
||||
pub fn voter(s: &Session, p: &Value) -> String {
|
||||
if let Some(v) = string(p, "voter").map(|v| v.trim().chars().filter(|c| !c.is_control()).take(MAX_VOTER).collect::<String>())
|
||||
&& !v.is_empty()
|
||||
{
|
||||
return v;
|
||||
}
|
||||
let live = s.ds().ok().filter(|ds| s.live_doc(ds.uid)).and_then(|ds| s.live_of(ds.uid).info.as_ref());
|
||||
match live {
|
||||
Some(info) => format!("peer {}", info.me),
|
||||
None => crate::cmd::comment::user_name(s),
|
||||
}
|
||||
}
|
||||
|
||||
fn running<'a>(cmd: &str, doc: &'a Document) -> Result<&'a Vote> {
|
||||
match &doc.vote {
|
||||
Some(v) if !v.ended => Ok(v),
|
||||
_ => Err(EngineError::Disabled(cmd.into(), "no vote is running".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn vote_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "board.vote";
|
||||
require(s, CMD)?;
|
||||
let action = req_string(CMD, p, "action")?;
|
||||
let who = voter(s, p);
|
||||
match action.as_str() {
|
||||
"start" => start_vote(s, p, CMD)?,
|
||||
"cast" | "retract" => change_ballot(s, p, CMD, &who, action == "cast")?,
|
||||
"end" => {
|
||||
running(CMD, s.doc()?)?;
|
||||
if let Some(v) = s.doc_mut()?.vote.as_mut() {
|
||||
v.ended = true;
|
||||
}
|
||||
}
|
||||
"clear" => {
|
||||
let doc = s.doc()?;
|
||||
if doc.vote.is_some() || !doc.votes.is_empty() {
|
||||
let doc = s.doc_mut()?;
|
||||
doc.vote = None;
|
||||
doc.votes.clear();
|
||||
}
|
||||
}
|
||||
other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear)"))),
|
||||
}
|
||||
Ok(vote_json(s.doc()?, &who))
|
||||
}
|
||||
|
||||
/// `board.vote {action: start}`: a new vote (votes per person, on what), clearing the last results.
|
||||
fn start_vote(s: &mut Session, p: &Value, cmd: &str) -> Result<()> {
|
||||
if s.doc()?.vote.as_ref().is_some_and(|v| !v.ended) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "a vote is already running: end it first".into()));
|
||||
}
|
||||
let per = match p.get("votes").filter(|v| !v.is_null()) {
|
||||
Some(_) => num(p, "votes")
|
||||
.filter(|n| n.fract() == 0.0 && (1.0..=f64::from(VOTES_MAX)).contains(n))
|
||||
.ok_or_else(|| bad(cmd, "`votes` must be a whole number from 1 to 99"))?,
|
||||
None => f64::from(VOTES),
|
||||
} as u32;
|
||||
let scope = match string(p, "on").as_deref().unwrap_or("all") {
|
||||
"all" => VoteScope::All,
|
||||
"stickies" => VoteScope::Stickies,
|
||||
"selection" => {
|
||||
let doc = s.doc()?;
|
||||
let ids: Vec<NodeId> = req_targets(cmd, s, p)?.into_iter().filter(|i| VoteScope::All.takes(doc, *i)).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "pick the objects to vote on (not pages)"));
|
||||
}
|
||||
VoteScope::Only { ids }
|
||||
}
|
||||
other => return Err(bad(cmd, format!("unknown `on` `{other}` (all|stickies|selection)"))),
|
||||
};
|
||||
let by = crate::board::author(s);
|
||||
let doc = s.doc_mut()?;
|
||||
doc.votes.clear();
|
||||
doc.vote = Some(Vote { per_person: per, scope, ended: false, by });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `board.vote {action: cast|retract, id}`: one vote of `who`'s placed on (`cast`) or taken back
|
||||
/// from an object, within the running vote's scope and votes per person.
|
||||
fn change_ballot(s: &mut Session, p: &Value, cmd: &str, who: &str, cast: bool) -> Result<()> {
|
||||
let target = id(p, "id").ok_or_else(|| bad(cmd, "`id` must be the object to vote on"))?;
|
||||
let doc = s.doc()?;
|
||||
let vote = running(cmd, doc)?;
|
||||
let mut ballot: Vec<NodeId> = vote.counted(doc, doc.votes.get(who).map_or(&[], Vec::as_slice)).collect();
|
||||
if cast {
|
||||
if !vote.scope.takes(doc, target) {
|
||||
return Err(match doc.node(target) {
|
||||
None => EngineError::NoNode(target),
|
||||
Some(_) => EngineError::Disabled(cmd.into(), "this vote isn't on that object".into()),
|
||||
});
|
||||
}
|
||||
if ballot.len() >= vote.per_person as usize {
|
||||
return Err(EngineError::Disabled(cmd.into(), "you have no votes left: remove one first".into()));
|
||||
}
|
||||
ballot.push(target);
|
||||
} else {
|
||||
let i =
|
||||
ballot.iter().rposition(|i| *i == target).ok_or_else(|| EngineError::Disabled(cmd.into(), "you have no vote on that object".into()))?;
|
||||
ballot.remove(i);
|
||||
}
|
||||
let votes = &mut s.doc_mut()?.votes;
|
||||
if ballot.is_empty() {
|
||||
votes.remove(who);
|
||||
} else {
|
||||
votes.insert(who.to_string(), ballot);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `who`'s votes per object, in the order first voted on.
|
||||
pub fn mine(doc: &Document, who: &str) -> Vec<(NodeId, u32)> {
|
||||
let (Some(vote), Some(ballot)) = (&doc.vote, doc.votes.get(who)) else { return Vec::new() };
|
||||
let mut out: Vec<(NodeId, u32)> = Vec::new();
|
||||
for id in vote.counted(doc, ballot) {
|
||||
match out.iter_mut().find(|(i, _)| *i == id) {
|
||||
Some((_, n)) => *n += 1,
|
||||
None => out.push((id, 1)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What an object is called in the results: its text (a sticky's note), else its name.
|
||||
pub fn object_name(doc: &Document, id: NodeId) -> String {
|
||||
let text = text_of(doc, id).and_then(|t| doc.node(t)).and_then(|n| n.kind.text()).map(|t| t.characters.trim().to_string());
|
||||
let name = text.filter(|t| !t.is_empty()).or_else(|| doc.node(id).map(|n| n.name.clone())).unwrap_or_default();
|
||||
let line = name.lines().next().unwrap_or_default();
|
||||
let short: String = line.chars().take(60).collect();
|
||||
if short.len() < name.len() { format!("{short}…") } else { short }
|
||||
}
|
||||
|
||||
fn vote_json(doc: &Document, who: &str) -> Value {
|
||||
let Some(vote) = &doc.vote else { return json!({"state": "off"}) };
|
||||
let on = match &vote.scope {
|
||||
VoteScope::All => json!("all"),
|
||||
VoteScope::Stickies => json!("stickies"),
|
||||
VoteScope::Only { ids } => json!({"selection": ids}),
|
||||
};
|
||||
let mine = mine(doc, who);
|
||||
let used: u32 = mine.iter().map(|m| m.1).sum();
|
||||
let voters = doc.votes.values().filter(|b| vote.counted(doc, b).next().is_some()).count();
|
||||
let mut out = json!({
|
||||
"state": if vote.ended { "ended" } else { "running" },
|
||||
"votesPerPerson": vote.per_person,
|
||||
"on": on,
|
||||
"by": vote.by,
|
||||
"left": vote.per_person.saturating_sub(used),
|
||||
"mine": mine.iter().map(|(id, n)| json!({"id": id, "votes": n})).collect::<Vec<_>>(),
|
||||
"voters": voters,
|
||||
});
|
||||
if vote.ended {
|
||||
let results: Vec<Value> =
|
||||
ranked(doc, vote).into_iter().map(|(rank, id, n)| json!({"id": id, "name": object_name(doc, id), "votes": n, "rank": rank})).collect();
|
||||
out["results"] = json!(results);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The objects with votes, most first, each with its rank (ties share one: "1, 2, 2, 4").
|
||||
pub fn ranked(doc: &Document, vote: &Vote) -> Vec<(usize, NodeId, u32)> {
|
||||
let mut last = None;
|
||||
let mut rank = 0;
|
||||
vote.tally(doc, &doc.votes)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, n))| {
|
||||
if last != Some(n) {
|
||||
rank = i + 1;
|
||||
last = Some(n);
|
||||
}
|
||||
(rank, id, n)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where object `b`'s vote badge sits (its top-right corner) and its radius, in page units at
|
||||
/// `zoom` screen pixels per unit.
|
||||
pub fn badge(b: Rect, zoom: f64) -> (Point, f64) {
|
||||
(Point::new(b.x1, b.y0), BADGE_PX / zoom.max(1e-6))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// A press with the Move tool while a vote runs: on one of your vote badges, or with Alt, it
|
||||
/// takes that vote back; on an object the vote is on, it puts a vote there. False when the
|
||||
/// press is for the tool (no vote running, or nothing to vote on there).
|
||||
pub(crate) fn vote_down(&mut self, ev: &PointerEvent) -> Result<bool> {
|
||||
let doc = self.doc()?;
|
||||
let Some(vote) = doc.vote.as_ref().filter(|v| !v.ended) else { return Ok(false) };
|
||||
let who = voter(self, &Value::Null);
|
||||
let p = ev.p();
|
||||
let on_badge = mine(doc, &who).into_iter().map(|(id, _)| id).find(|id| {
|
||||
doc.world_bounds(*id).is_some_and(|b| {
|
||||
let (c, r) = badge(b, ev.zoom);
|
||||
(p - c).hypot() <= r
|
||||
})
|
||||
});
|
||||
let hit = || {
|
||||
let leaf = self.hit(doc, self.page().ok()?, p, 2.0 / ev.zoom.max(1e-6))?;
|
||||
vote.scope.target(doc, leaf, figcraft_doc::geometry::select_target(doc, leaf, &[], false))
|
||||
};
|
||||
let (action, target) = match on_badge {
|
||||
Some(id) => ("retract", id),
|
||||
None => match hit() {
|
||||
Some(id) => (if ev.alt { "retract" } else { "cast" }, id),
|
||||
None => return Ok(false),
|
||||
},
|
||||
};
|
||||
self.execute("board.vote", &json!({"action": action, "id": target}))?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo and redo bring back a document as it was, but the timer and the vote aren't undoable:
|
||||
/// keep them as they are now (`from`).
|
||||
pub(crate) fn keep(from: &Document, to: &mut Arc<Document>) {
|
||||
if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes {
|
||||
let d = Arc::make_mut(to);
|
||||
d.timer = from.timer;
|
||||
d.vote = from.vote.clone();
|
||||
d.votes = from.votes.clone();
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.open",
|
||||
"Open",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
|
||||
false,
|
||||
open
|
||||
),
|
||||
@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.import",
|
||||
"Import",
|
||||
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
|
||||
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON, .fig and .sketch pages are appended with their variables and flows (REST, .fig and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
|
||||
true,
|
||||
import
|
||||
),
|
||||
@@ -163,7 +163,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let i = s.active;
|
||||
let uid = s.ds()?.uid;
|
||||
if let Some(slot) = s.docs.get_mut(i) {
|
||||
*slot = DocState::new(loaded.doc, Some(path)).with_versions(loaded.versions).with_branches(loaded.branches);
|
||||
*slot = DocState::new(loaded.doc, Some(path.clone()))
|
||||
.with_versions(loaded.versions)
|
||||
.with_branches(loaded.branches)
|
||||
.with_images_left_in_file(&path, loaded.images_past_budget);
|
||||
}
|
||||
// Edits on its branch tabs are reverted too.
|
||||
crate::branches::close_tabs(s, uid, None);
|
||||
@@ -555,6 +558,9 @@ fn load_with(
|
||||
if let Some(r) = load_fig(bytes, &file_stem(name_hint)) {
|
||||
return Ok((r?, true));
|
||||
}
|
||||
if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) {
|
||||
return Ok((r?, true));
|
||||
}
|
||||
if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) {
|
||||
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
link_instances(&mut doc);
|
||||
@@ -570,6 +576,219 @@ fn load_with(
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try.
|
||||
fn load_sketch(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
|
||||
if !figcraft_format::sketch::is_sketch(bytes) {
|
||||
return None;
|
||||
}
|
||||
// The import and what follows read the installed fonts: the system's must be known first (the
|
||||
// desktop app scans them in the background; this waits for that scan).
|
||||
figcraft_text::fontdb::scan_system_fonts();
|
||||
let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face);
|
||||
let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?;
|
||||
Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| {
|
||||
match_font_families(&mut doc);
|
||||
platform_tracking(&mut doc);
|
||||
sketch_line_heights(&mut doc);
|
||||
link_instances(&mut doc);
|
||||
reflow_overridden_text(&mut doc);
|
||||
figcraft_format::Loaded { issues: warnings, ..doc.into() }
|
||||
}))
|
||||
}
|
||||
|
||||
/// Instances of a stack sized apart from their main are laid out at their size, and texts in
|
||||
/// instances whose string an override changed are measured again with the auto layout around them
|
||||
/// re-flowing (Sketch saves layouts measured with the master's string). The rest keeps the layout
|
||||
/// Sketch saved.
|
||||
fn reflow_overridden_text(doc: &mut Document) {
|
||||
let changed: Vec<NodeId> = doc
|
||||
.nodes()
|
||||
.filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) {
|
||||
(NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters,
|
||||
_ => false,
|
||||
})
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
let m = crate::measure::TextMeasure;
|
||||
// Instances of a stack at another size than their main: the stack at their size, innermost first.
|
||||
let mut resized: Vec<(usize, NodeId)> = doc
|
||||
.nodes()
|
||||
.filter(|n| n.layout.is_some())
|
||||
.filter(|n| match &n.kind {
|
||||
NodeKind::Instance { component, .. } => {
|
||||
doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6)
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.map(|n| (doc.ancestors(n.id).len(), n.id))
|
||||
.collect();
|
||||
resized.sort_by_key(|r| std::cmp::Reverse(r.0));
|
||||
for (_, id) in resized {
|
||||
figcraft_layout::layout_frame(doc, id, &m);
|
||||
}
|
||||
for id in changed {
|
||||
if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) {
|
||||
figcraft_layout::set_size(doc, id, w, h);
|
||||
}
|
||||
figcraft_layout::layout_up(doc, id, &m);
|
||||
}
|
||||
}
|
||||
|
||||
/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from
|
||||
/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and
|
||||
/// case.
|
||||
fn match_font_families(doc: &mut Document) {
|
||||
let installed = figcraft_text::fontdb::family_names();
|
||||
let mut cache: std::collections::BTreeMap<String, Option<String>> = std::collections::BTreeMap::new();
|
||||
let mut fix = |style: &mut figcraft_doc::TextStyle| {
|
||||
let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed));
|
||||
if let Some(f) = found {
|
||||
style.font_family = f.clone();
|
||||
}
|
||||
};
|
||||
let ids: Vec<NodeId> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect();
|
||||
for id in ids {
|
||||
if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
fix(&mut td.style);
|
||||
for r in &mut td.runs {
|
||||
fix(&mut r.style);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shared text styles too, so they stay like the layers linked to them.
|
||||
for st in &mut doc.styles {
|
||||
if let StyleKind::Text { style } = &mut st.kind {
|
||||
fix(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine
|
||||
/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so
|
||||
/// lines measure and break as they did there. A named style's optical size follows the text size
|
||||
/// there too (FigCraft keeps the instance's), so the imported text carries it as an `opsz` value.
|
||||
fn platform_tracking(doc: &mut Document) {
|
||||
let mut cache: std::collections::BTreeMap<(String, String), Option<std::sync::Arc<figcraft_text::fontdb::Face>>> =
|
||||
std::collections::BTreeMap::new();
|
||||
let mut face = |style: &figcraft_doc::TextStyle| {
|
||||
cache
|
||||
.entry((style.font_family.clone(), style.font_style.clone()))
|
||||
.or_insert_with(|| {
|
||||
figcraft_text::fontdb::has_family(&style.font_family)
|
||||
.then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style))
|
||||
.flatten()
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
let mut track = |style: &mut figcraft_doc::TextStyle| {
|
||||
let Some(f) = face(style) else { return };
|
||||
let t = f.tracking(style.font_size);
|
||||
if t != 0.0 && t.is_finite() {
|
||||
let px = style.letter_spacing.resolve(style.font_size);
|
||||
style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t);
|
||||
}
|
||||
if f.axis("opsz").is_some() && f.instance(&style.font_style).is_some() && style.font_size.is_finite() {
|
||||
style.variations.entry("opsz".into()).or_insert(style.font_size);
|
||||
}
|
||||
};
|
||||
let ids: Vec<NodeId> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect();
|
||||
for id in ids {
|
||||
if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
track(&mut td.style);
|
||||
for r in &mut td.runs {
|
||||
track(&mut r.style);
|
||||
}
|
||||
}
|
||||
}
|
||||
for st in &mut doc.styles {
|
||||
if let StyleKind::Text { style } = &mut st.kind {
|
||||
track(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their
|
||||
/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this.
|
||||
const TALL_LINE_FAMILIES: [&str; 11] =
|
||||
["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"];
|
||||
|
||||
/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its
|
||||
/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch
|
||||
/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth
|
||||
/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres
|
||||
/// the font's own ascent and descent in the line. `(0, 0)` without metrics.
|
||||
pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) {
|
||||
let k = size / upem;
|
||||
let (a, d) = (ascent * k, descent * k);
|
||||
if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let natural = a.round() + d.round();
|
||||
let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural };
|
||||
let sketch_baseline = line - d.round();
|
||||
let figcraft_baseline = (line - (a + d)) / 2.0 + a;
|
||||
(line, sketch_baseline - figcraft_baseline)
|
||||
}
|
||||
|
||||
/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line,
|
||||
/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a
|
||||
/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text
|
||||
/// styles take the line too.
|
||||
fn sketch_line_heights(doc: &mut Document) {
|
||||
// Per family and style: the face's ascent, descent and units per em.
|
||||
type Metrics = Option<(f64, f64, f64)>;
|
||||
let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new();
|
||||
let mut set = |style: &mut TextStyle| -> f64 {
|
||||
if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| {
|
||||
figcraft_text::fontdb::has_family(&style.font_family)
|
||||
.then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style))
|
||||
.flatten()
|
||||
.map(|f| (f.ascent, f.descent, f.upem))
|
||||
});
|
||||
let Some((a, d, upem)) = m else { return 0.0 };
|
||||
let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size);
|
||||
if line > 0.0 {
|
||||
style.line_height = LineHeight::Percent(line / style.font_size * 100.0);
|
||||
}
|
||||
down
|
||||
};
|
||||
let ids: Vec<NodeId> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect();
|
||||
for id in ids {
|
||||
// An auto layout places its texts itself (its next pass would take a move back).
|
||||
let laid_out =
|
||||
doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()));
|
||||
let Some(n) = doc.node_mut(id) else { continue };
|
||||
let NodeKind::Text(td) = &mut n.kind else { continue };
|
||||
let down = set(&mut td.style);
|
||||
for r in &mut td.runs {
|
||||
set(&mut r.style);
|
||||
}
|
||||
// Down in the layer's own frame (a rotated text moves along its own axis).
|
||||
if !laid_out && down.is_finite() && down != 0.0 {
|
||||
n.transform *= figcraft_doc::geom::Affine::translate((0.0, down));
|
||||
}
|
||||
}
|
||||
for st in &mut doc.styles {
|
||||
if let StyleKind::Text { style } = &mut st.kind {
|
||||
set(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed family `family` means when it isn't installed under that exact name: the one
|
||||
/// whose name is the same without spaces, ignoring case.
|
||||
pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option<String> {
|
||||
let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::<String>();
|
||||
if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) {
|
||||
return None;
|
||||
}
|
||||
let want = key(family);
|
||||
installed.iter().find(|f| key(f) == want).cloned()
|
||||
}
|
||||
|
||||
/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try.
|
||||
pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
|
||||
let r = figcraft_format::fig::try_import(bytes, name)?;
|
||||
@@ -738,7 +957,13 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
let summary = import_summary(&loaded.doc);
|
||||
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
let images_left_out = if native { loaded.images_past_budget } else { 0 };
|
||||
s.docs.push(
|
||||
DocState::new(loaded.doc, native.then(|| path.clone()))
|
||||
.with_versions(loaded.versions)
|
||||
.with_branches(loaded.branches)
|
||||
.with_images_left_in_file(&path, images_left_out),
|
||||
);
|
||||
s.active = s.docs.len() - 1;
|
||||
let mut out = json!({"index": s.active, "summary": summary});
|
||||
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
|
||||
@@ -826,7 +1051,9 @@ impl FileSnapshot {
|
||||
let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else {
|
||||
return Ok(false);
|
||||
};
|
||||
write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?;
|
||||
let path = self.path.as_deref().ok_or(EngineError::NoDocument)?;
|
||||
refuse_dropping_images(ds, path)?;
|
||||
write(path, bytes)?;
|
||||
// The history or branches may have changed since: then they still count as unsaved.
|
||||
if ds.revision == self.revision {
|
||||
ds.versions_changed = false;
|
||||
@@ -838,6 +1065,16 @@ impl FileSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> {
|
||||
match ds.images_lost_saving_to(path) {
|
||||
Some(count) => Err(EngineError::Other(format!(
|
||||
"{} wasn't saved: {count} of its images weren't loaded (together they pass what FigCraft reads from one file), and saving over it would delete them. Save it under another name; this file keeps them.",
|
||||
file_stem(path)
|
||||
))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The branch tabs of the file in tab `uid` were saved with it.
|
||||
fn branch_tabs_saved(s: &mut Session, uid: u64) {
|
||||
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
|
||||
@@ -879,6 +1116,10 @@ fn save_to(s: &mut Session, path: Option<String>) -> Result<Value> {
|
||||
}
|
||||
let i = crate::branches::file_tab(s);
|
||||
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
|
||||
// A download (no path) writes over no file.
|
||||
if let Some(path) = path.as_deref() {
|
||||
refuse_dropping_images(ds, path)?;
|
||||
}
|
||||
let mut snapshot = FileSnapshot::of(ds);
|
||||
let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old));
|
||||
if let Some(path) = path.as_deref()
|
||||
@@ -1248,12 +1489,15 @@ fn export_path(name: &str) -> String {
|
||||
if parts.is_empty() { "Untitled".into() } else { parts.join("/") }
|
||||
}
|
||||
|
||||
/// Characters no file name may contain (path separators and ones file systems reject).
|
||||
pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|";
|
||||
|
||||
/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and
|
||||
/// control characters become `_`); everything else is kept, up to the first 200 characters.
|
||||
/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before
|
||||
/// the number and extension it adds).
|
||||
pub fn safe_file_name(name: &str) -> String {
|
||||
name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect()
|
||||
name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect()
|
||||
}
|
||||
|
||||
/// Layers on `page` that have export settings (document order).
|
||||
@@ -1384,7 +1628,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
"figcraft" | "json" | "fig" => {
|
||||
"figcraft" | "json" | "fig" | "sketch" => {
|
||||
let resolver = path.as_deref().map(rest_images);
|
||||
let images: figcraft_format::rest::ImageResolver = match &resolver {
|
||||
Some(r) => r,
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod comment;
|
||||
pub mod component;
|
||||
pub mod create;
|
||||
pub mod edit;
|
||||
pub mod facilitate;
|
||||
pub mod file;
|
||||
pub mod image;
|
||||
pub mod layout;
|
||||
@@ -113,6 +114,7 @@ fn registry() -> &'static Registry {
|
||||
list.extend(crate::datafill::specs());
|
||||
list.extend(crate::textfit::specs());
|
||||
list.extend(crate::board::specs());
|
||||
list.extend(facilitate::specs());
|
||||
list.extend(crate::board_table::specs());
|
||||
list.extend(crate::board_templates::specs());
|
||||
let mut index = HashMap::new();
|
||||
|
||||
@@ -427,7 +427,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"pref.language",
|
||||
"Interface language",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|…; else English}",
|
||||
false,
|
||||
|s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
|
||||
@@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50;
|
||||
|
||||
/// The languages a component can be connected in.
|
||||
pub fn language(s: &str) -> Option<CodeLang> {
|
||||
CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))
|
||||
CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))
|
||||
}
|
||||
|
||||
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
|
||||
@@ -64,7 +64,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
|
||||
let path = &link.import_path;
|
||||
match lang {
|
||||
_ if path.is_empty() => String::new(),
|
||||
CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
|
||||
CodeLang::React | CodeLang::ArkUi => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
|
||||
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
|
||||
_ => format!("import {path}"),
|
||||
}
|
||||
@@ -267,6 +267,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
|
||||
(Some(l), CodeLang::React) => format!("<{} />", l.name),
|
||||
(Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name),
|
||||
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
|
||||
(Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name),
|
||||
(Some(l), _) => format!("{{ {}() }}", l.name),
|
||||
(None, _) => value_expr(doc, c, p, v, lang),
|
||||
}
|
||||
@@ -317,6 +318,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
|
||||
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::<String>())
|
||||
}
|
||||
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
}
|
||||
}
|
||||
@@ -334,13 +336,14 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
|
||||
}
|
||||
let path = &link.import_path;
|
||||
let path_ok = match lang {
|
||||
CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
|
||||
CodeLang::React | CodeLang::ArkUi => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
|
||||
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
|
||||
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
|
||||
};
|
||||
if path.chars().count() > MAX_IMPORT || !path_ok {
|
||||
return Err(match lang {
|
||||
CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
|
||||
CodeLang::ArkUi => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
|
||||
CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"),
|
||||
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
|
||||
});
|
||||
@@ -377,7 +380,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
|
||||
/// The `language` parameter (default React).
|
||||
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
|
||||
match text(cmd, p, "language")? {
|
||||
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")),
|
||||
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose|arkui")),
|
||||
None => Ok(CodeLang::React),
|
||||
}
|
||||
}
|
||||
@@ -407,7 +410,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"codeConnect.set",
|
||||
"Connect to code",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "codeConnect.set";
|
||||
@@ -522,7 +525,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (Some(component), Some(lang), Some(name), Some(template)) =
|
||||
(field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str))
|
||||
else {
|
||||
warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1));
|
||||
warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose|arkui), name and template strings", i + 1));
|
||||
continue;
|
||||
};
|
||||
let link = CodeLink {
|
||||
|
||||
@@ -72,6 +72,16 @@ fn connected_instances_show_the_real_snippet() {
|
||||
json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": "<Card title={title} dotted={Show dot} />"}),
|
||||
);
|
||||
assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n<Card title={\"Say \\\"hi\\\"\"} dotted={false} />\n");
|
||||
// ArkUI: an ArkTS import and an object-literal call.
|
||||
ex(
|
||||
&mut s,
|
||||
"codeConnect.set",
|
||||
json!({"id": c, "language": "arkui", "importPath": "@acme/arkui", "template": "Card({ title: {title}, dotted: {Show dot} })"}),
|
||||
);
|
||||
assert_eq!(code(&mut s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n");
|
||||
let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "arkui"}));
|
||||
assert_eq!(v["usage"], "Card({ title: \"Say \\\"hi\\\"\", dotted: false })\n");
|
||||
assert_eq!(v["connected"]["import"], "import { Card } from \"@acme/arkui\";");
|
||||
assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path");
|
||||
ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"}));
|
||||
// inspect.componentCode: the snippet as the usage, and the link.
|
||||
@@ -105,7 +115,7 @@ fn connected_instances_show_the_real_snippet() {
|
||||
assert!(code(&mut s, i, "swiftui").starts_with("import AcmeUI"));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(code(&mut s, i, "react").starts_with("import { Card }"));
|
||||
assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 2);
|
||||
assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 3);
|
||||
assert!(s.execute("codeConnect.remove", &json!({"id": c})).is_err(), "nothing left");
|
||||
}
|
||||
|
||||
|
||||
+321
-11
@@ -1,9 +1,9 @@
|
||||
//! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the
|
||||
//! layer itself, HTML or React for its subtree, and SwiftUI or Jetpack Compose (stacks for auto
|
||||
//! layout, absolute offsets otherwise). Components become components whose properties are
|
||||
//! parameters and instances become calls passing their values (see [`crate::component_codegen`]);
|
||||
//! variables bound to a layer become CSS custom properties. The output is a starting point, not a
|
||||
//! pixel-exact translation.
|
||||
//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS;
|
||||
//! stacks for auto layout, absolute offsets otherwise). Components become components whose
|
||||
//! properties are parameters and instances become calls passing their values (see
|
||||
//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The
|
||||
//! output is a starting point, not a pixel-exact translation.
|
||||
|
||||
use figcraft_color::{Color, PaintKind};
|
||||
use figcraft_doc::*;
|
||||
@@ -18,13 +18,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"inspect.code",
|
||||
"Code for layer",
|
||||
"{id?, language?: css|html|react|leptos|tailwind|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
"{id?, language?: css|html|react|leptos|tailwind|swiftui|compose|arkui (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.code";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
|
||||
let lang = match string(p, "language") {
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?,
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|tailwind|swiftui|compose|arkui"))?,
|
||||
None => CodeLang::Css,
|
||||
};
|
||||
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
|
||||
@@ -72,11 +72,13 @@ pub enum CodeLang {
|
||||
Tailwind,
|
||||
SwiftUi,
|
||||
Compose,
|
||||
/// HarmonyOS ArkUI: an ArkTS struct (`@Component`, `build`).
|
||||
ArkUi,
|
||||
}
|
||||
|
||||
impl CodeLang {
|
||||
pub const ALL: [CodeLang; 7] =
|
||||
[CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose];
|
||||
pub const ALL: [CodeLang; 8] =
|
||||
[CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CodeLang::Css => "css",
|
||||
@@ -86,6 +88,7 @@ impl CodeLang {
|
||||
CodeLang::Tailwind => "tailwind",
|
||||
CodeLang::SwiftUi => "swiftui",
|
||||
CodeLang::Compose => "compose",
|
||||
CodeLang::ArkUi => "arkui",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -97,6 +100,7 @@ impl CodeLang {
|
||||
CodeLang::Tailwind => "React + Tailwind",
|
||||
CodeLang::SwiftUi => "SwiftUI",
|
||||
CodeLang::Compose => "Compose",
|
||||
CodeLang::ArkUi => "ArkUI",
|
||||
}
|
||||
}
|
||||
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
|
||||
@@ -108,6 +112,9 @@ impl CodeLang {
|
||||
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
|
||||
return Some(CodeLang::React);
|
||||
}
|
||||
if s.eq_ignore_ascii_case("arkts") {
|
||||
return Some(CodeLang::ArkUi);
|
||||
}
|
||||
CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
@@ -213,7 +220,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
CodeLang::Css => css(doc, id),
|
||||
CodeLang::Html => html(doc, id),
|
||||
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => crate::component_codegen::code(doc, id, lang),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1202,6 +1209,232 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ArkUI (ArkTS)
|
||||
|
||||
/// A colour as ArkUI writes it: `'#RRGGBB'`, or `'#AARRGGBB'` with transparency.
|
||||
fn ark_color(c: Color) -> String {
|
||||
let [r, g, b, a] = c.to_rgba8();
|
||||
if a == 255 { format!("'#{r:02X}{g:02X}{b:02X}'") } else { format!("'#{a:02X}{r:02X}{g:02X}{b:02X}'") }
|
||||
}
|
||||
|
||||
/// A layer's corner radii as `.borderRadius(...)` (uniform or per corner).
|
||||
fn ark_radius(n: &Node, mods: &mut Vec<String>) {
|
||||
if let Some(r) = n.corners.uniform() {
|
||||
if r > 0.0 {
|
||||
mods.push(format!(".borderRadius({})", num(r)));
|
||||
}
|
||||
} else if n.corners.radii.iter().any(|r| *r > 0.0) {
|
||||
let [tl, tr, br, bl] = n.corners.radii;
|
||||
mods.push(format!(".borderRadius({{ topLeft: {}, topRight: {}, bottomRight: {}, bottomLeft: {} }})", num(tl), num(tr), num(br), num(bl)));
|
||||
}
|
||||
}
|
||||
|
||||
/// ArkUI for a layer and its subtree at `depth`; `extra` attributes go last (a free child's
|
||||
/// position). A layer a boolean parameter shows sits in an `if`.
|
||||
pub(crate) fn arkui(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra: &[String]) {
|
||||
let Some(n) = ctx.doc.node(id) else { return };
|
||||
if id != ctx.root
|
||||
&& let Some(p) = ctx.param(n, "visible")
|
||||
{
|
||||
let pad = indent(depth);
|
||||
out.push_str(&format!("{pad}if (this.{}) {{\n", p.ident));
|
||||
arkui_layer(ctx, n, depth + 1, out, extra);
|
||||
out.push_str(&format!("{pad}}}\n"));
|
||||
return;
|
||||
}
|
||||
arkui_layer(ctx, n, depth, out, extra);
|
||||
}
|
||||
|
||||
fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
|
||||
let doc = ctx.doc;
|
||||
let pad = indent(depth);
|
||||
if ctx.too_deep(depth) {
|
||||
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
|
||||
return;
|
||||
}
|
||||
// Nested instances are calls; an instance swap parameter is a builder.
|
||||
let swap = ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root);
|
||||
let call = match swap {
|
||||
Some(p) => Some(format!("{pad}this.{}()\n", p.ident)),
|
||||
None => ctx.call(n, CodeLang::ArkUi, depth),
|
||||
};
|
||||
if let Some(call) = call {
|
||||
if let Some(p) = swap.filter(|_| !extra.is_empty()) {
|
||||
// The builder returns nothing, so a position goes on a container around the call.
|
||||
out.push_str(&format!("{pad}Stack() {{\n{pad} this.{}()\n{pad}}}\n", p.ident));
|
||||
for m in extra {
|
||||
out.push_str(&format!("{pad}{m}\n"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
out.push_str(&call);
|
||||
for m in extra {
|
||||
out.push_str(&format!("{pad} {m}\n"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut mods: Vec<String> = Vec::new();
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident));
|
||||
out.push_str(&format!("{pad}Text({text})\n"));
|
||||
let s = &t.style;
|
||||
mods.push(format!(".fontSize({})", num(s.font_size)));
|
||||
mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900)));
|
||||
mods.push(format!(".fontFamily({})", literal(&s.font_family, false)));
|
||||
if s.italic() {
|
||||
mods.push(".fontStyle(FontStyle.Italic)".into());
|
||||
}
|
||||
if let Some(c) = first_fill(n) {
|
||||
mods.push(format!(".fontColor({})", ark_color(c)));
|
||||
}
|
||||
match s.line_height {
|
||||
LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))),
|
||||
LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))),
|
||||
LineHeight::Auto => {}
|
||||
}
|
||||
match s.letter_spacing {
|
||||
LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))),
|
||||
LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))),
|
||||
_ => {}
|
||||
}
|
||||
match t.align_h {
|
||||
TextAlignH::Left => {}
|
||||
TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()),
|
||||
TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()),
|
||||
TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()),
|
||||
}
|
||||
// Aligning needs a box to align in: the text layer's own width.
|
||||
if !matches!(t.align_h, TextAlignH::Left) {
|
||||
mods.push(format!(".width({})", num(n.width)));
|
||||
}
|
||||
match s.case {
|
||||
TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()),
|
||||
TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()),
|
||||
_ => {}
|
||||
}
|
||||
match s.decoration {
|
||||
TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()),
|
||||
TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()),
|
||||
TextDecoration::None => {}
|
||||
}
|
||||
}
|
||||
NodeKind::Ellipse { .. } => {
|
||||
out.push_str(&format!("{pad}Ellipse({{ width: {}, height: {} }})\n", num(n.width), num(n.height)));
|
||||
if let Some(c) = first_fill(n) {
|
||||
mods.push(format!(".fill({})", ark_color(c)));
|
||||
}
|
||||
if let Some(c) = stroke_color(n) {
|
||||
mods.push(format!(".stroke({})", ark_color(c)));
|
||||
mods.push(format!(".strokeWidth({})", num(n.stroke.weight)));
|
||||
}
|
||||
}
|
||||
NodeKind::Rectangle => {
|
||||
out.push_str(&format!("{pad}Column()\n"));
|
||||
if let Some(c) = first_fill(n) {
|
||||
mods.push(format!(".backgroundColor({})", ark_color(c)));
|
||||
}
|
||||
ark_radius(n, &mut mods);
|
||||
mods.push(format!(".width({})", num(n.width)));
|
||||
mods.push(format!(".height({})", num(n.height)));
|
||||
}
|
||||
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
|
||||
let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
|
||||
Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)),
|
||||
Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)),
|
||||
None => "Stack({ alignContent: Alignment.TopStart })".to_string(),
|
||||
};
|
||||
out.push_str(&format!("{pad}{open} {{\n"));
|
||||
match ctx.param(n, "slot") {
|
||||
// A slot shows the instance's content, or else the main's (its default builder).
|
||||
Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)),
|
||||
None => {
|
||||
for c in ctx.kids(n) {
|
||||
// Free children sit at their position inside a Stack, and so does a child
|
||||
// that ignores its auto layout parent.
|
||||
let pos: Vec<String> = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) {
|
||||
Some(cn) => {
|
||||
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
|
||||
vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))]
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
arkui(ctx, c, depth + 1, out, &pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(&format!("{pad}}}\n"));
|
||||
if let Some(l) = &n.layout {
|
||||
let [t, r, b, lft] = l.padding;
|
||||
if [t, r, b, lft].iter().any(|v| *v > 0.0) {
|
||||
mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft)));
|
||||
}
|
||||
let justify = match l.main_align {
|
||||
MainAlign::Min => None,
|
||||
MainAlign::Center => Some("FlexAlign.Center"),
|
||||
MainAlign::Max => Some("FlexAlign.End"),
|
||||
MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"),
|
||||
MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"),
|
||||
MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"),
|
||||
};
|
||||
if let Some(j) = justify {
|
||||
mods.push(format!(".justifyContent({j})"));
|
||||
}
|
||||
let cross = match l.cross_align {
|
||||
CrossAlign::Min => None,
|
||||
CrossAlign::Center => Some("Center"),
|
||||
CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }),
|
||||
CrossAlign::Baseline => None,
|
||||
};
|
||||
if let Some(c) = cross {
|
||||
let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" };
|
||||
mods.push(format!(".alignItems({ty}.{c})"));
|
||||
}
|
||||
}
|
||||
mods.push(format!(".width({})", num(n.width)));
|
||||
mods.push(format!(".height({})", num(n.height)));
|
||||
if let Some(c) = first_fill(n) {
|
||||
mods.push(format!(".backgroundColor({})", ark_color(c)));
|
||||
}
|
||||
if radius(n) > 0.0 {
|
||||
mods.push(".clip(true)".into());
|
||||
}
|
||||
ark_radius(n, &mut mods);
|
||||
}
|
||||
_ => {
|
||||
out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image($r('app.media.asset'))\n", n.kind.label()));
|
||||
mods.push(format!(".width({})", num(n.width)));
|
||||
mods.push(format!(".height({})", num(n.height)));
|
||||
}
|
||||
}
|
||||
// Ellipses take their stroke as a shape attribute (above).
|
||||
if !matches!(n.kind, NodeKind::Ellipse { .. })
|
||||
&& let Some(c) = stroke_color(n)
|
||||
{
|
||||
mods.push(format!(".border({{ width: {}, color: {} }})", num(n.stroke.weight), ark_color(c)));
|
||||
}
|
||||
if let Some(s) = drop_shadow(n) {
|
||||
mods.push(format!(
|
||||
".shadow({{ radius: {}, color: {}, offsetX: {}, offsetY: {} }})",
|
||||
num(s.radius / 2.0),
|
||||
ark_color(s.color),
|
||||
num(s.offset.0),
|
||||
num(s.offset.1)
|
||||
));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
mods.push(format!(".opacity({})", num(f64::from(n.opacity))));
|
||||
}
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 0.01 && rot.is_finite() {
|
||||
mods.push(format!(".rotate({{ angle: {} }})", num(-rot)));
|
||||
}
|
||||
mods.extend(extra.iter().cloned());
|
||||
for m in mods {
|
||||
out.push_str(&format!("{pad} {m}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1249,6 +1482,33 @@ mod tests {
|
||||
assert!(s.execute("inspect.code", &json!({"id": f, "language": "cobol"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arkui_for_an_auto_layout_card() {
|
||||
let mut s = Session::new();
|
||||
let f = card(&mut s);
|
||||
let doc = s.doc().unwrap();
|
||||
let ark = code_for(doc, f, CodeLang::ArkUi);
|
||||
for want in [
|
||||
"Row({ space: ",
|
||||
"Text(\"Say \\\"hi\\\" $5\\nnow\")",
|
||||
".fontSize(",
|
||||
".fontWeight(",
|
||||
"Ellipse({ width: 24, height: 24 })",
|
||||
".backgroundColor('#FFFFFF')",
|
||||
".borderRadius(12)",
|
||||
".width(",
|
||||
".height(100)",
|
||||
".clip(true)",
|
||||
] {
|
||||
assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
|
||||
}
|
||||
// Through the command, and "arkts" reads as ArkUI too.
|
||||
let v = s.execute("inspect.code", &json!({"id": f, "language": "arkui"})).unwrap();
|
||||
assert_eq!(v["code"].as_str().unwrap(), ark);
|
||||
assert_eq!(v["language"], "arkui");
|
||||
assert_eq!(s.execute("inspect.code", &json!({"id": f, "language": "arkts"})).unwrap()["language"], "arkui");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_children_get_offsets_and_deep_trees_are_capped() {
|
||||
let mut s = Session::new();
|
||||
@@ -1264,9 +1524,59 @@ mod tests {
|
||||
assert!(sw.contains("…"), "depth capped");
|
||||
let kt = code_for(doc, root, CodeLang::Compose);
|
||||
assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
|
||||
let ark = code_for(doc, root, CodeLang::ArkUi);
|
||||
assert!(ark.contains(".position({ x: 7, y: 9 })"), "{ark}");
|
||||
assert!(ark.contains("…"), "depth capped");
|
||||
assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arkui_aligned_text_gets_the_layers_width() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap();
|
||||
let t = id_of(&s.execute("text.create", &json!({"text": "Hi", "x": 0, "y": 0, "parent": f})).unwrap()["id"]).unwrap();
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textAlign": "center", "width": 120.0}})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let ark = code_for(doc, f, CodeLang::ArkUi);
|
||||
assert!(ark.contains(".textAlign(TextAlign.Center)"), "{ark}");
|
||||
let width = doc.node(t).map(|n| n.width).unwrap_or_default();
|
||||
assert!(ark.contains(&format!(".width({})", num(width))), "the alignment has a box: {ark}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arkui_places_a_child_that_ignores_its_auto_layout_parent() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap();
|
||||
let c = id_of(&s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 20, "height": 20, "parent": f})).unwrap()["id"]).unwrap();
|
||||
s.execute("node.set", &json!({"id": c, "props": {"absolute": true}})).unwrap();
|
||||
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let [_, _, _, _, x, y] = doc.node(c).unwrap().transform.as_coeffs();
|
||||
let ark = code_for(doc, f, CodeLang::ArkUi);
|
||||
assert!(ark.contains("Row(") || ark.contains("Column("), "the parent is still auto layout: {ark}");
|
||||
assert!(ark.contains(&format!(".position({{ x: {}, y: {} }})", num(x), num(y))), "the absolute child keeps its place: {ark}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arkui_wraps_a_positioned_instance_swap_so_the_position_lands_on_a_container() {
|
||||
let mut s = Session::new();
|
||||
let icon = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 400, "width": 24, "height": 24, "name": "Icon"})).unwrap()["id"]).unwrap();
|
||||
s.execute("shape.star", &json!({"x": 0, "y": 0, "width": 24, "height": 24, "parent": icon})).unwrap();
|
||||
let heart = id_of(&s.execute("component.create", &json!({"ids": [icon]})).unwrap()["id"]).unwrap();
|
||||
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 120, "height": 40, "name": "Chip"})).unwrap()["id"]).unwrap();
|
||||
let nested = id_of(&s.execute("instance.create", &json!({"component": heart, "x": 8, "y": 8, "parent": f})).unwrap()["id"]).unwrap();
|
||||
let chip = id_of(&s.execute("component.create", &json!({"ids": [f]})).unwrap()["id"]).unwrap();
|
||||
s.execute("component.addProperty", &json!({"id": chip, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let ark = code_for(doc, chip, CodeLang::ArkUi);
|
||||
assert!(ark.contains("Stack() {\n"), "the builder is wrapped: {ark}");
|
||||
assert!(ark.contains("this.icon()"), "{ark}");
|
||||
// The position follows the wrapper's closing brace, not the void builder call.
|
||||
let lines: Vec<&str> = ark.lines().collect();
|
||||
let at = lines.iter().position(|l| l.contains(".position({ x: 8, y: 8 })")).unwrap_or_else(|| panic!("no position: {ark}"));
|
||||
assert_eq!(lines[at - 1].trim(), "}", "the position is on a container: {ark}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() {
|
||||
let mut s = Session::new();
|
||||
@@ -1279,7 +1589,7 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
for lang in [CodeLang::SwiftUi, CodeLang::Compose] {
|
||||
for lang in [CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi] {
|
||||
let code = code_for(doc, root, lang);
|
||||
assert!(code.contains("// deep evil() more() …\n"), "{code}");
|
||||
for line in code.lines() {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack
|
||||
//! Compose component whose properties are its parameters (a variant property → a union or enum, boolean →
|
||||
//! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot →
|
||||
//! optional content), and an instance becomes a call passing the values it sets. Variants of a set
|
||||
//! share one definition (the set's default variant); in React and Leptos their differences become CSS
|
||||
//! rules on `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
|
||||
//! connected to code (see [`crate::code_connect`]) shows its real snippet instead.
|
||||
//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI, Jetpack
|
||||
//! Compose or ArkUI component whose properties are its parameters (a variant property → a union or
|
||||
//! enum, boolean → a flag showing the layers bound to it, text → a string, instance swap → a view
|
||||
//! parameter, slot → optional content), and an instance becomes a call passing the values it sets.
|
||||
//! Variants of a set share one definition (the set's default variant); in React and Leptos their
|
||||
//! differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode` and
|
||||
//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its
|
||||
//! real snippet instead.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::codegen::{CodeLang, Ctx, Markup, class_name, compose, indent, literal, swift};
|
||||
use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift};
|
||||
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
|
||||
use crate::params::*;
|
||||
use crate::{bad, spec};
|
||||
@@ -67,14 +68,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"inspect.componentCode",
|
||||
"Component code",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.componentCode";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
|
||||
let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) {
|
||||
Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l,
|
||||
Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")),
|
||||
Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))) => l,
|
||||
Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose|arkui")),
|
||||
None => CodeLang::React,
|
||||
};
|
||||
let doc = s.doc()?;
|
||||
@@ -107,9 +108,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)]
|
||||
}
|
||||
|
||||
/// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's
|
||||
/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it
|
||||
/// in a function component), with the imports of the connected components it calls at the top.
|
||||
/// Code for any layer in React, Leptos, SwiftUI, Compose or ArkUI: an instance's call and its
|
||||
/// component's definition, a component's (or set's) definition, or the layer tree (React and
|
||||
/// Leptos wrap it in a function component), with the imports of the connected components it calls
|
||||
/// at the top.
|
||||
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
let Some(n) = doc.node(id) else { return String::new() };
|
||||
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
|
||||
@@ -129,6 +131,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
swift(&Ctx::new(doc, id), id, 0, &mut out, &[]);
|
||||
out
|
||||
}
|
||||
CodeLang::ArkUi => {
|
||||
let mut out = String::new();
|
||||
arkui(&Ctx::new(doc, id), id, 0, &mut out, &[]);
|
||||
out
|
||||
}
|
||||
_ => {
|
||||
let mut out = String::new();
|
||||
compose(&Ctx::new(doc, id), id, 0, &mut out);
|
||||
@@ -295,7 +302,7 @@ const RESERVED: &[&str] = &[
|
||||
"yield",
|
||||
];
|
||||
|
||||
/// Types the generated SwiftUI and Compose code names: a variant enum may not shadow them.
|
||||
/// Types the generated SwiftUI, Compose and ArkUI code names: a variant enum may not shadow them.
|
||||
const TYPES: &[&str] = &[
|
||||
"Alignment",
|
||||
"AnyView",
|
||||
@@ -304,6 +311,7 @@ const TYPES: &[&str] = &[
|
||||
"Boolean",
|
||||
"Box",
|
||||
"CaseIterable",
|
||||
"Circle",
|
||||
"CircleShape",
|
||||
"Color",
|
||||
"Column",
|
||||
@@ -312,21 +320,30 @@ const TYPES: &[&str] = &[
|
||||
"EdgeInsets",
|
||||
"Ellipse",
|
||||
"EmptyView",
|
||||
"FlexAlign",
|
||||
"Font",
|
||||
"FontStyle",
|
||||
"FontWeight",
|
||||
"Group",
|
||||
"HStack",
|
||||
"HorizontalAlign",
|
||||
"Image",
|
||||
"Modifier",
|
||||
"Rect",
|
||||
"RectangleShape",
|
||||
"RoundedCornerShape",
|
||||
"RoundedRectangle",
|
||||
"Row",
|
||||
"Shape",
|
||||
"Stack",
|
||||
"String",
|
||||
"Text",
|
||||
"TextAlign",
|
||||
"TextCase",
|
||||
"TextDecorationType",
|
||||
"Unit",
|
||||
"VStack",
|
||||
"VerticalAlign",
|
||||
"View",
|
||||
"ZStack",
|
||||
];
|
||||
@@ -488,6 +505,9 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
||||
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
|
||||
(None, CodeLang::Leptos) => "|| ()".into(),
|
||||
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
|
||||
// ArkUI takes a builder: `@BuilderParam` is a `() => void`.
|
||||
(Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"),
|
||||
(None, CodeLang::ArkUi) => "() => {}".into(),
|
||||
(Some(t), _) => format!("{{ {t}() }}"),
|
||||
(None, CodeLang::React) => "null".into(),
|
||||
(None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(),
|
||||
@@ -566,6 +586,11 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
|
||||
format!("{pad}<{t}{attrs} />\n")
|
||||
}
|
||||
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
|
||||
CodeLang::ArkUi => {
|
||||
let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ");
|
||||
// ArkTS calls take an object literal, or nothing at all.
|
||||
if list.is_empty() { format!("{pad}{t}()\n") } else { format!("{pad}{t}({{ {list} }})\n") }
|
||||
}
|
||||
_ => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n} = {e}")).collect::<Vec<_>>().join(", ")),
|
||||
})
|
||||
}
|
||||
@@ -598,6 +623,13 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S
|
||||
}
|
||||
format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1))
|
||||
}
|
||||
CodeLang::ArkUi => {
|
||||
let mut out = String::new();
|
||||
for k in kids {
|
||||
arkui(&ctx, k, depth + 2, &mut out, &[]);
|
||||
}
|
||||
format!("() => {{\n{out}{}}}", indent(depth + 1))
|
||||
}
|
||||
_ => {
|
||||
let mut out = String::new();
|
||||
for k in kids {
|
||||
@@ -616,6 +648,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
|
||||
CodeLang::SwiftUi => swift_definition(doc, c),
|
||||
CodeLang::Compose => compose_definition(doc, c),
|
||||
CodeLang::Leptos => leptos(doc, c),
|
||||
CodeLang::ArkUi => arkui_definition(doc, c),
|
||||
_ => react(doc, c),
|
||||
}
|
||||
}
|
||||
@@ -812,3 +845,67 @@ fn compose_definition(doc: &Document, c: &Component) -> String {
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// An ArkUI (`@Component`) struct: variant enums, `@Prop` parameters, a default builder for each
|
||||
/// instance swap and slot, and the `build` body.
|
||||
fn arkui_definition(doc: &Document, c: &Component) -> String {
|
||||
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 1 };
|
||||
let mut out = String::new();
|
||||
for p in &c.params {
|
||||
if let ParamKind::Variant(opts) = &p.kind {
|
||||
out.push_str(&format!("export enum {} {{ {} }}\n\n", enum_name(c, p, false), cases(opts, false).join(", ")));
|
||||
}
|
||||
}
|
||||
// A default builder per instance swap or slot, named apart from every parameter.
|
||||
let mut used: Vec<String> = c.params.iter().map(|p| p.ident.clone()).collect();
|
||||
let mut builders: Vec<(String, String)> = Vec::new();
|
||||
for p in &c.params {
|
||||
if !matches!(p.kind, ParamKind::InstanceSwap | ParamKind::Slot) {
|
||||
continue;
|
||||
}
|
||||
let base = format!("default{}Builder", capitalized(&p.ident));
|
||||
let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
|
||||
used.push(name.clone());
|
||||
builders.push((p.ident.clone(), name));
|
||||
}
|
||||
out.push_str("@Component\nexport struct ");
|
||||
out.push_str(&c.type_name);
|
||||
out.push_str(" {\n");
|
||||
for p in &c.params {
|
||||
match &p.kind {
|
||||
ParamKind::Variant(_) => {
|
||||
out.push_str(&format!(" @Prop {}: {} = {}\n", p.ident, enum_name(c, p, false), value_expr(doc, c, p, &p.default, CodeLang::ArkUi)))
|
||||
}
|
||||
ParamKind::Boolean => out.push_str(&format!(" @Prop {}: boolean = {}\n", p.ident, p.default.as_bool().unwrap_or(false))),
|
||||
ParamKind::Text => out.push_str(&format!(" @Prop {}: string = {}\n", p.ident, value_expr(doc, c, p, &p.default, CodeLang::ArkUi))),
|
||||
ParamKind::InstanceSwap | ParamKind::Slot => {
|
||||
let name = builders.iter().find(|(ident, _)| *ident == p.ident).map_or("", |(_, name)| name.as_str());
|
||||
out.push_str(&format!(" @BuilderParam {}: () => void = this.{name}\n", p.ident));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (ident, name) in &builders {
|
||||
let Some(p) = c.params.iter().find(|p| p.ident == *ident) else { continue };
|
||||
out.push_str(&format!("\n @Builder\n {name}() {{\n"));
|
||||
match &p.kind {
|
||||
ParamKind::InstanceSwap => {
|
||||
if let Some(t) = id_of(&p.default).and_then(|i| component_type(doc, i)) {
|
||||
out.push_str(&format!(" {t}()\n"));
|
||||
}
|
||||
}
|
||||
ParamKind::Slot => {
|
||||
let layer = slots_of(doc, c.comp).into_iter().find(|(_, slot)| slot == &p.prop).map(|(layer, _)| layer);
|
||||
let kids = layer.and_then(|l| doc.node(l)).map(|n| ctx.kids(n)).unwrap_or_default();
|
||||
for k in kids {
|
||||
arkui(&ctx, k, 2, &mut out, &[]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out.push_str(" }\n");
|
||||
}
|
||||
out.push_str("\n build() {\n");
|
||||
arkui(&ctx, c.comp, 2, &mut out, &[]);
|
||||
out.push_str(" }\n}\n");
|
||||
out
|
||||
}
|
||||
|
||||
@@ -83,6 +83,17 @@ fn components_take_their_properties_as_parameters() {
|
||||
for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] {
|
||||
assert!(kt.contains(want), "Compose has {want:?}:\n{kt}");
|
||||
}
|
||||
let ark = code(&mut s, c, "arkui");
|
||||
for want in [
|
||||
"@Component\nexport struct Card {",
|
||||
" @Prop showDot: boolean = true",
|
||||
" @Prop title: string = \"Hello\"",
|
||||
" build() {",
|
||||
" if (this.showDot) {",
|
||||
"Text(this.title)",
|
||||
] {
|
||||
assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
|
||||
}
|
||||
let leptos = code(&mut s, c, "leptos");
|
||||
for want in [
|
||||
"use leptos::prelude::*;\n\n#[component]\npub fn Card(\n",
|
||||
@@ -129,12 +140,14 @@ fn instances_are_calls_passing_the_values_they_set() {
|
||||
assert!(react.contains("export function Card({"));
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title=\"Buy\" />\n");
|
||||
// Text that needs escaping is an expression in JSX and escaped per language.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}}));
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Card showDot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
|
||||
}
|
||||
|
||||
@@ -185,6 +198,9 @@ fn variant_sets_share_one_definition() {
|
||||
let kt = code(&mut s, set, "compose");
|
||||
assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}");
|
||||
assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}");
|
||||
let ark = code(&mut s, set, "arkui");
|
||||
assert!(ark.contains("export enum ButtonState { Default, Hover }"), "{ark}");
|
||||
assert!(ark.contains(" @Prop state: ButtonState = ButtonState.Default"), "{ark}");
|
||||
let leptos = code(&mut s, set, "leptos");
|
||||
for want in [
|
||||
"#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}",
|
||||
@@ -201,6 +217,7 @@ fn variant_sets_share_one_definition() {
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Button state=\"Hover\" />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Button(state: .hover)\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Button(state = ButtonState.Hover)\n");
|
||||
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Button({ state: ButtonState.Hover })\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Button state=ButtonState::Hover />\n");
|
||||
}
|
||||
|
||||
@@ -224,6 +241,14 @@ fn instance_swap_properties_take_views() {
|
||||
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
|
||||
let kt = code(&mut s, chip, "compose");
|
||||
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
|
||||
let ark = code(&mut s, chip, "arkui");
|
||||
for want in [
|
||||
" @BuilderParam icon: () => void = this.defaultIconBuilder",
|
||||
" @Builder\n defaultIconBuilder() {\n HeartIcon()\n }",
|
||||
" this.icon()",
|
||||
] {
|
||||
assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
|
||||
}
|
||||
let leptos = code(&mut s, chip, "leptos");
|
||||
assert!(leptos.contains(" #[prop(into, default = (|| view! { <HeartIcon /> }).into())] icon: ViewFn,"), "{leptos}");
|
||||
assert!(leptos.contains("{icon.run()}"), "{leptos}");
|
||||
@@ -232,6 +257,7 @@ fn instance_swap_properties_take_views() {
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Chip icon={<StarIcon />} />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
|
||||
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Chip({ icon: () => { StarIcon() } })\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
|
||||
}
|
||||
|
||||
@@ -247,6 +273,14 @@ fn slots_take_optional_content() {
|
||||
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
|
||||
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
|
||||
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
|
||||
let ark = code(&mut s, panel, "arkui");
|
||||
for want in [
|
||||
" @BuilderParam content: () => void = this.defaultContentBuilder",
|
||||
" @Builder\n defaultContentBuilder() {\n",
|
||||
" this.content()",
|
||||
] {
|
||||
assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
|
||||
}
|
||||
let leptos = code(&mut s, panel, "leptos");
|
||||
assert!(leptos.contains(" #[prop(optional, into)] content: Option<ViewFn>,"), "{leptos}");
|
||||
assert!(leptos.contains("{match &content { Some(c) => c.run(), None => view! {\n") && leptos.contains("}.into_any() }}\n"), "{leptos}");
|
||||
@@ -260,6 +294,8 @@ fn slots_take_optional_content() {
|
||||
assert!(usage.starts_with("<Panel content={(\n") && usage.contains("<Badge />"), "{usage}");
|
||||
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("<Panel content={|| view! {\n") && usage.contains("<Badge />") && usage.ends_with("}} />\n"), "{usage}");
|
||||
let usage = component_code(&mut s, i, "arkui")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("Panel({ content: () => {\n") && usage.contains("Badge()") && usage.ends_with("} })\n"), "{usage}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -274,13 +274,14 @@ fn vector_edit_mode_shows_each_tool_and_modifier() {
|
||||
let mut s = Session::new();
|
||||
let path = id(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [300, 100], [300, 300]], "closed": false})));
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": path}));
|
||||
let (point, segment, empty) = ((300.0, 100.0), (200.0, 100.0), (200.0, 250.0));
|
||||
let (point, segment, midpoint, empty) = ((300.0, 100.0), (150.0, 100.0), (200.0, 100.0), (200.0, 250.0));
|
||||
use Cursor::*;
|
||||
// (vector tool, modifiers, where, cursor, why)
|
||||
type Row = (&'static str, Mods, (f64, f64), Cursor, &'static str);
|
||||
let rows: Vec<Row> = vec![
|
||||
("move", NONE, point, Default, "the arrow over a point"),
|
||||
("move", NONE, segment, Default, "the arrow over a segment"),
|
||||
("move", NONE, midpoint, Pen(PenCursor::Add), "the arrow over a segment's midpoint: adds a point there"),
|
||||
("move", NONE, empty, Default, "the arrow elsewhere"),
|
||||
("move", ALT, segment, Pen(PenCursor::Add), "Alt over a segment adds a point there"),
|
||||
("move", ALT, point, Default, "Alt over a point adds nothing"),
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
//! A board's timer and votes: the commands, what undo leaves alone, saving, live sessions, clicks
|
||||
//! that vote, and junk parameters.
|
||||
|
||||
use figcraft_doc::{BoardTimer, NodeId, VoteScope};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::board_tests::{board, id};
|
||||
use crate::cmd::facilitate::{clock, timer_after, timer_state};
|
||||
use crate::collab_live_tests::Room;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
const MIN: u64 = 60_000;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn sticky(s: &mut Session, x: f64, text: &str) -> NodeId {
|
||||
id(&ex(s, "board.sticky", json!({"x": x, "y": 0, "text": text})), "id")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_timer_counts_from_its_end_time() {
|
||||
let now = 1_000_000;
|
||||
let none = json!({});
|
||||
let t = timer_after("t", None, "start", &json!({"minutes": 2}), now).unwrap();
|
||||
assert_eq!(t, Some(BoardTimer::Running { ends: now + 2 * MIN }));
|
||||
assert_eq!(timer_state(t, now + MIN), "running");
|
||||
assert_eq!(timer_state(t, now + 3 * MIN), "done", "run out: done until stopped");
|
||||
// Paused half way: the time left stays put however long the pause.
|
||||
let p = timer_after("t", t, "pause", &none, now + MIN).unwrap();
|
||||
assert_eq!(p, Some(BoardTimer::Paused { left: MIN }));
|
||||
assert_eq!(p.unwrap().left(now + 100 * MIN), MIN);
|
||||
// A minute more while paused, then on again.
|
||||
let p = timer_after("t", p, "add", &none, now + 5 * MIN).unwrap();
|
||||
assert_eq!(p, Some(BoardTimer::Paused { left: 2 * MIN }));
|
||||
let r = timer_after("t", p, "resume", &none, now + 10 * MIN).unwrap();
|
||||
assert_eq!(r, Some(BoardTimer::Running { ends: now + 12 * MIN }));
|
||||
// Adding to a timer that ran out starts from now.
|
||||
let r = timer_after("t", r, "add", &json!({"minutes": 0.5}), now + 20 * MIN).unwrap();
|
||||
assert_eq!(r, Some(BoardTimer::Running { ends: now + 20 * MIN + MIN / 2 }));
|
||||
assert_eq!(timer_after("t", r, "stop", &none, now).unwrap(), None);
|
||||
// What can't be done says so.
|
||||
assert!(timer_after("t", None, "pause", &none, now).is_err());
|
||||
assert!(timer_after("t", None, "add", &none, now).is_err());
|
||||
assert!(timer_after("t", r, "resume", &none, now).is_err());
|
||||
assert!(timer_after("t", Some(BoardTimer::Running { ends: now }), "pause", &none, now).is_err(), "nothing left to pause");
|
||||
for m in [json!(0), json!(-1), json!(1441), json!("NaN"), json!(1e308), json!("x"), json!([1])] {
|
||||
assert!(timer_after("t", None, "start", &json!({"minutes": m}), now).is_err(), "{m}");
|
||||
}
|
||||
// At most a day, also by adding.
|
||||
let long = timer_after("t", None, "start", &json!({"minutes": 1440}), now).unwrap();
|
||||
assert_eq!(timer_after("t", long, "add", &none, now).unwrap().unwrap().left(now), 1440 * MIN);
|
||||
assert!(timer_after("t", None, "start", &json!({"minutes": 1}), u64::MAX).is_ok(), "no overflow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clocks_read_minutes_and_seconds() {
|
||||
assert_eq!(clock(0), "0:00");
|
||||
assert_eq!(clock(1), "0:01", "a part of a second left still shows");
|
||||
assert_eq!(clock(5 * MIN), "5:00");
|
||||
assert_eq!(clock(65_000), "1:05");
|
||||
assert_eq!(clock(3_723_000), "1:02:03");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_commands_drive_the_boards_timer() {
|
||||
let mut s = board();
|
||||
assert_eq!(ex(&mut s, "board.timerState", json!({}))["state"], "off");
|
||||
let r = ex(&mut s, "board.timer", json!({"action": "start", "minutes": 3}));
|
||||
assert_eq!(r["state"], "running");
|
||||
let left = r["left"].as_u64().unwrap();
|
||||
assert!(left > 3 * MIN - 5000 && left <= 3 * MIN, "{r}");
|
||||
assert!(r["ends"].as_u64().is_some());
|
||||
assert_eq!(ex(&mut s, "board.timer", json!({"action": "pause"}))["state"], "paused");
|
||||
let r = ex(&mut s, "board.timer", json!({"action": "add"}));
|
||||
assert!(r["left"].as_u64().unwrap() > 4 * MIN - 5000, "{r}");
|
||||
assert_eq!(ex(&mut s, "board.timer", json!({"action": "resume"}))["state"], "running");
|
||||
assert_eq!(ex(&mut s, "board.timer", json!({"action": "stop"}))["state"], "off");
|
||||
assert!(s.doc().unwrap().timer.is_none());
|
||||
// Not an undo step.
|
||||
assert_eq!(s.undo_depth().0, 0);
|
||||
assert!(matches!(s.execute("board.timer", &json!({"action": "pause"})), Err(EngineError::Disabled(..))));
|
||||
assert!(s.execute("board.timer", &json!({"action": "jump"})).is_err());
|
||||
// Boards only.
|
||||
let mut d = Session::new();
|
||||
d.execute("file.new", &json!({})).unwrap();
|
||||
for (cmd, p) in [
|
||||
("board.timer", json!({"action": "start"})),
|
||||
("board.timerState", json!({})),
|
||||
("board.vote", json!({"action": "start"})),
|
||||
("board.voteResults", json!({})),
|
||||
] {
|
||||
assert!(matches!(d.execute(cmd, &p), Err(EngineError::Disabled(..))), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn votes_are_capped_hidden_until_the_end_and_ranked() {
|
||||
let mut s = board();
|
||||
let a = sticky(&mut s, 0.0, "Ship it");
|
||||
let b = sticky(&mut s, 300.0, "Wait");
|
||||
let c = sticky(&mut s, 600.0, "Rethink");
|
||||
let shape = id(&ex(&mut s, "board.shape", json!({"x": 0, "y": 400})), "id");
|
||||
let r = ex(&mut s, "board.vote", json!({"action": "start", "votes": 2, "on": "stickies"}));
|
||||
assert_eq!((r["state"].as_str(), r["left"].as_u64(), r["on"].as_str()), (Some("running"), Some(2), Some("stickies")));
|
||||
assert!(matches!(s.execute("board.vote", &json!({"action": "start"})), Err(EngineError::Disabled(..))), "one vote at a time");
|
||||
let cast = |s: &mut Session, who: &str, on: NodeId| s.execute("board.vote", &json!({"action": "cast", "id": on, "voter": who}));
|
||||
// Ada puts both her votes on one sticky; a third is refused, and so is a shape.
|
||||
cast(&mut s, "Ada", a).unwrap();
|
||||
let r = cast(&mut s, "Ada", a).unwrap();
|
||||
assert_eq!((r["left"].as_u64(), r["mine"].clone()), (Some(0), json!([{"id": a.0, "votes": 2}])));
|
||||
assert!(matches!(cast(&mut s, "Ada", b), Err(EngineError::Disabled(..))), "no votes left");
|
||||
assert!(matches!(cast(&mut s, "Bo", shape), Err(EngineError::Disabled(..))), "stickies only");
|
||||
assert!(matches!(cast(&mut s, "Bo", NodeId(99_999)), Err(EngineError::NoNode(_))));
|
||||
cast(&mut s, "Bo", b).unwrap();
|
||||
cast(&mut s, "Bo", c).unwrap();
|
||||
cast(&mut s, "Cy", b).unwrap();
|
||||
cast(&mut s, "Cy", c).unwrap();
|
||||
// Cy takes one back and puts it elsewhere.
|
||||
s.execute("board.vote", &json!({"action": "retract", "id": c, "voter": "Cy"})).unwrap();
|
||||
assert!(s.execute("board.vote", &json!({"action": "retract", "id": c, "voter": "Cy"})).is_err(), "no vote there any more");
|
||||
cast(&mut s, "Cy", a).unwrap();
|
||||
// While it runs, you see your own votes only.
|
||||
let r = ex(&mut s, "board.voteResults", json!({"voter": "Bo"}));
|
||||
assert!(r.get("results").is_none(), "{r}");
|
||||
assert_eq!(r["mine"], json!([{"id": b.0, "votes": 1}, {"id": c.0, "votes": 1}]));
|
||||
assert_eq!(r["voters"], 3);
|
||||
assert_eq!(s.undo_depth().0, 4, "votes are no undo steps (only the four objects)");
|
||||
ex(&mut s, "board.vote", json!({"action": "end"}));
|
||||
assert!(s.execute("board.vote", &json!({"action": "cast", "id": b, "voter": "Dee"})).is_err(), "ended");
|
||||
let r = ex(&mut s, "board.voteResults", json!({}));
|
||||
assert_eq!(r["state"], "ended");
|
||||
assert_eq!(
|
||||
r["results"],
|
||||
json!([
|
||||
{"id": a.0, "name": "Ship it", "votes": 3, "rank": 1},
|
||||
{"id": b.0, "name": "Wait", "votes": 2, "rank": 2},
|
||||
{"id": c.0, "name": "Rethink", "votes": 1, "rank": 3},
|
||||
])
|
||||
);
|
||||
// A deleted object's votes no longer count.
|
||||
ex(&mut s, "edit.delete", json!({"ids": [a]}));
|
||||
assert_eq!(ex(&mut s, "board.voteResults", json!({}))["results"][0]["id"], b.0);
|
||||
ex(&mut s, "board.vote", json!({"action": "clear"}));
|
||||
assert_eq!(ex(&mut s, "board.voteResults", json!({}))["state"], "off");
|
||||
assert!(s.doc().unwrap().votes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vote_on_the_selection_takes_only_those() {
|
||||
let mut s = board();
|
||||
let a = sticky(&mut s, 0.0, "A");
|
||||
let b = sticky(&mut s, 300.0, "B");
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let r = ex(&mut s, "board.vote", json!({"action": "start", "on": "selection"}));
|
||||
assert_eq!(r["on"], json!({"selection": [a.0]}));
|
||||
assert_eq!(r["votesPerPerson"], 3);
|
||||
assert!(s.execute("board.vote", &json!({"action": "cast", "id": b})).is_err());
|
||||
ex(&mut s, "board.vote", json!({"action": "cast", "id": a}));
|
||||
// A finished vote may be replaced by a new one, which starts with no votes.
|
||||
ex(&mut s, "board.vote", json!({"action": "end"}));
|
||||
ex(&mut s, "board.vote", json!({"action": "start", "on": "all", "votes": 1}));
|
||||
assert!(s.doc().unwrap().votes.is_empty());
|
||||
assert_eq!(s.doc().unwrap().vote.as_ref().unwrap().scope, VoteScope::All);
|
||||
for bad in [json!({"votes": 0}), json!({"votes": 100}), json!({"votes": 1.5}), json!({"on": "pages"}), json!({"on": "selection", "ids": []})] {
|
||||
let mut p = bad.clone();
|
||||
p["action"] = json!("start");
|
||||
ex(&mut s, "board.vote", json!({"action": "clear"}));
|
||||
assert!(s.execute("board.vote", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_leaves_the_timer_and_the_vote_alone() {
|
||||
let mut s = board();
|
||||
let a = sticky(&mut s, 0.0, "A");
|
||||
ex(&mut s, "board.timer", json!({"action": "start"}));
|
||||
ex(&mut s, "board.vote", json!({"action": "start"}));
|
||||
ex(&mut s, "board.vote", json!({"action": "cast", "id": a}));
|
||||
let b = sticky(&mut s, 300.0, "B");
|
||||
s.undo().unwrap();
|
||||
s.undo().unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(a).is_none() && doc.node(b).is_none(), "the stickies are undone");
|
||||
assert!(doc.timer.is_some() && doc.vote.is_some() && doc.votes.len() == 1, "the timer and the vote stay");
|
||||
ex(&mut s, "board.timer", json!({"action": "stop"}));
|
||||
s.redo().unwrap();
|
||||
assert!(s.doc().unwrap().timer.is_none(), "redo doesn't bring a stopped timer back");
|
||||
assert!(s.doc().unwrap().node(a).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_timer_and_votes_save_and_old_files_open() {
|
||||
let mut s = board();
|
||||
let a = sticky(&mut s, 0.0, "A");
|
||||
ex(&mut s, "board.timer", json!({"action": "start"}));
|
||||
ex(&mut s, "board.timer", json!({"action": "pause"}));
|
||||
ex(&mut s, "board.vote", json!({"action": "start", "on": "stickies"}));
|
||||
ex(&mut s, "board.vote", json!({"action": "cast", "id": a}));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
|
||||
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
assert_eq!((back.timer, &back.vote, &back.votes), (doc.timer, &doc.vote, &doc.votes));
|
||||
// A file from before: none of the fields.
|
||||
let mut v = figcraft_format::to_json(&doc).unwrap();
|
||||
for k in ["timer", "vote", "votes"] {
|
||||
assert!(v.as_object_mut().unwrap().remove(k).is_some(), "{k} is saved");
|
||||
}
|
||||
let (old, _) = figcraft_format::from_json(&v.to_string()).unwrap();
|
||||
assert!(old.timer.is_none() && old.vote.is_none() && old.votes.is_empty());
|
||||
// Design files don't write them.
|
||||
let plain = figcraft_format::to_json(&figcraft_doc::Document::new("D")).unwrap();
|
||||
assert!(plain.get("timer").is_none() && plain.get("vote").is_none() && plain.get("votes").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everyone_in_a_live_session_shares_the_timer_and_the_vote() {
|
||||
let mut s = board();
|
||||
let a = sticky(&mut s, 0.0, "A");
|
||||
let b = sticky(&mut s, 300.0, "B");
|
||||
ex(&mut s, "board.timer", json!({"action": "start", "minutes": 10}));
|
||||
let mut room = Room::new(s.doc().unwrap().clone(), 2, 11);
|
||||
assert!(room.s(1).doc().unwrap().timer.is_some(), "joiners get the running timer");
|
||||
let t0 = room.s(0).doc().unwrap().timer;
|
||||
assert_eq!(room.s(1).doc().unwrap().timer, t0);
|
||||
ex(room.s(1), "board.timer", json!({"action": "pause"}));
|
||||
ex(room.s(0), "board.vote", json!({"action": "start", "votes": 2}));
|
||||
room.settle();
|
||||
room.check();
|
||||
assert!(matches!(room.s(0).doc().unwrap().timer, Some(BoardTimer::Paused { .. })), "a pause reaches everyone");
|
||||
// Both vote at once: each ballot is its own entry, so neither is lost.
|
||||
ex(room.s(0), "board.vote", json!({"action": "cast", "id": a}));
|
||||
ex(room.s(1), "board.vote", json!({"action": "cast", "id": a}));
|
||||
ex(room.s(1), "board.vote", json!({"action": "cast", "id": b}));
|
||||
room.settle();
|
||||
room.check();
|
||||
assert_eq!(room.server.document().votes.len(), 2, "two voters, apart though both are called the same");
|
||||
assert_eq!(ex(room.s(0), "board.voteResults", json!({}))["mine"], json!([{"id": a.0, "votes": 1}]));
|
||||
ex(room.s(1), "board.vote", json!({"action": "end"}));
|
||||
room.settle();
|
||||
let r = ex(room.s(0), "board.voteResults", json!({}));
|
||||
assert_eq!(r["results"], json!([{"id": a.0, "name": "A", "votes": 2, "rank": 1}, {"id": b.0, "name": "B", "votes": 1, "rank": 2}]));
|
||||
// Undo in a session takes back your own edits only, not the vote.
|
||||
room.s(0).undo().unwrap();
|
||||
room.settle();
|
||||
room.check();
|
||||
assert!(room.server.document().vote.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_vote_while_a_vote_runs() {
|
||||
let mut s = board();
|
||||
let a = sticky(&mut s, 0.0, "A");
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
let click = |s: &mut Session, x: f64, y: f64, alt: bool| {
|
||||
let mut down = PointerEvent::at(PointerKind::Down, x, y, 1.0);
|
||||
down.alt = alt;
|
||||
s.pointer(&down).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap();
|
||||
};
|
||||
// No vote: a click selects.
|
||||
click(&mut s, 100.0, 100.0, false);
|
||||
assert_eq!(s.selection(), vec![a]);
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
ex(&mut s, "board.vote", json!({"action": "start"}));
|
||||
click(&mut s, 100.0, 100.0, false);
|
||||
click(&mut s, 50.0, 150.0, false);
|
||||
assert!(s.selection().is_empty(), "votes, not selection");
|
||||
assert_eq!(crate::cmd::facilitate::mine(s.doc().unwrap(), "You"), vec![(a, 2)]);
|
||||
// Alt takes one back; so does a click on your badge (the sticky's top-right corner).
|
||||
click(&mut s, 100.0, 100.0, true);
|
||||
click(&mut s, 199.0, 1.0, false);
|
||||
assert!(s.doc().unwrap().votes.is_empty());
|
||||
// Empty canvas is the tool's as ever.
|
||||
click(&mut s, 900.0, 900.0, false);
|
||||
assert!(s.doc().unwrap().votes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facilitation_commands_survive_junk() {
|
||||
let junk = [
|
||||
json!({}),
|
||||
json!(null),
|
||||
json!([1]),
|
||||
json!({"action": 5, "minutes": "NaN", "votes": -1, "on": [1], "id": "x", "voter": {"a": 1}}),
|
||||
json!({"action": "start", "minutes": 1e308, "votes": 1e308, "on": "selection", "ids": ["x", -1]}),
|
||||
json!({"action": "add", "minutes": -1e308}),
|
||||
json!({"action": "cast", "id": 99_999, "voter": "\u{0}\u{7}"}),
|
||||
json!({"action": "retract", "id": -1, "voter": "x".repeat(10_000)}),
|
||||
json!({"action": "end"}),
|
||||
json!({"action": "clear", "ids": 7}),
|
||||
json!({"action": "resume", "minutes": [1]}),
|
||||
];
|
||||
for cmd in ["board.timer", "board.timerState", "board.vote", "board.voteResults"] {
|
||||
for j in &junk {
|
||||
let mut s = board();
|
||||
let a = sticky(&mut s, 0.0, "A");
|
||||
ex(&mut s, "board.timer", json!({"action": "start"}));
|
||||
ex(&mut s, "board.vote", json!({"action": "start"}));
|
||||
ex(&mut s, "board.vote", json!({"action": "cast", "id": a}));
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
|
||||
panic!("{cmd} panicked on {j}: {msg}");
|
||||
}
|
||||
// A voter name is kept short.
|
||||
assert!(s.doc().unwrap().votes.keys().all(|k| k.chars().count() <= 64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_share_a_rank() {
|
||||
let mut s = board();
|
||||
let ids: Vec<NodeId> = (0..4).map(|i| sticky(&mut s, f64::from(i) * 300.0, "x")).collect();
|
||||
ex(&mut s, "board.vote", json!({"action": "start", "votes": 2}));
|
||||
for (who, on) in [("a", 0), ("a", 1), ("b", 1), ("b", 0), ("c", 2), ("c", 3), ("d", 2)] {
|
||||
ex(&mut s, "board.vote", json!({"action": "cast", "id": ids[on], "voter": who}));
|
||||
}
|
||||
ex(&mut s, "board.vote", json!({"action": "end"}));
|
||||
let r = ex(&mut s, "board.voteResults", json!({}));
|
||||
let ranks: Vec<u64> = r["results"].as_array().unwrap().iter().map(|x| x["rank"].as_u64().unwrap()).collect();
|
||||
assert_eq!(ranks, vec![1, 1, 1, 4], "{r}");
|
||||
}
|
||||
@@ -115,6 +115,11 @@ pub(crate) fn within_app_folder(path: &Path, folder: &Path) -> bool {
|
||||
matches!((std::fs::canonicalize(path), std::fs::canonicalize(folder)), (Ok(p), Ok(f)) if p.starts_with(&f))
|
||||
}
|
||||
|
||||
pub fn same_file(a: impl AsRef<Path>, b: impl AsRef<Path>) -> bool {
|
||||
let (a, b) = (a.as_ref(), b.as_ref());
|
||||
a == b || same_file::is_same_file(a, b).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `p` without Windows' `\\?\` prefix, for messages.
|
||||
fn shown(p: &Path) -> String {
|
||||
let s = p.to_string_lossy();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::file::FileSnapshot;
|
||||
|
||||
fn rect(s: &mut Session, x: f64) {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
}
|
||||
|
||||
fn opened_with_images_left_in_file(name: &str) -> (std::path::PathBuf, String, Session) {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-images-left-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("big.figcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, 0.0);
|
||||
s.execute("file.save", &json!({"path": path})).unwrap();
|
||||
let mut t = Session::empty();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
let i = t.active;
|
||||
let tab = t.docs.remove(i).with_images_left_in_file(&path, 3);
|
||||
t.docs.insert(i, tab);
|
||||
(dir, path, t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_images_left_out_is_not_saved_over() {
|
||||
let (dir, path, mut s) = opened_with_images_left_in_file("save");
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
rect(&mut s, 20.0);
|
||||
let err = s.execute("file.save", &json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("3 of its images") && err.contains("another name"), "{err}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
assert!(s.ds().unwrap().is_dirty());
|
||||
|
||||
let copy = dir.join("copy.figcraft").to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", &json!({"path": copy})).unwrap();
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
rect(&mut s, 40.0);
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
assert!(s.execute("file.saveAs", &json!({"path": path})).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_encoded_in_the_background_does_not_save_over_it() {
|
||||
let (dir, path, mut s) = opened_with_images_left_in_file("snapshot");
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
rect(&mut s, 20.0);
|
||||
let file = FileSnapshot::of(s.ds().unwrap());
|
||||
let bytes = file.encode().unwrap();
|
||||
assert!(file.save(&mut s, &bytes).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
assert!(s.ds().unwrap().is_dirty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_without_images_left_out_saves_as_before() {
|
||||
let (dir, path, mut s) = opened_with_images_left_in_file("none");
|
||||
let i = s.active;
|
||||
let tab = s.docs.remove(i).with_images_left_in_file(&path, 0);
|
||||
s.docs.insert(i, tab);
|
||||
rect(&mut s, 20.0);
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_link_to_the_file_is_not_saved_over() {
|
||||
let (dir, path, mut s) = opened_with_images_left_in_file("hard-link");
|
||||
let link = dir.join("link.figcraft");
|
||||
let _ = std::fs::remove_file(&link);
|
||||
std::fs::hard_link(&path, &link).unwrap();
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
rect(&mut s, 20.0);
|
||||
assert!(s.execute("file.saveAs", &json!({"path": link.to_string_lossy()})).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -374,6 +374,7 @@ pub struct DocState {
|
||||
/// The last duplicate while its copies are the selection: Duplicate repeats its offset (see
|
||||
/// [`cmd::edit::Duplicated`]; not saved).
|
||||
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
|
||||
images_left_in_file: Option<(String, usize)>,
|
||||
}
|
||||
|
||||
/// A version shown on the canvas in place of the document: read-only until it's restored or the
|
||||
@@ -436,11 +437,21 @@ impl DocState {
|
||||
rotation_origin: None,
|
||||
view_from_prefs: false,
|
||||
last_duplicate: None,
|
||||
images_left_in_file: None,
|
||||
};
|
||||
st.zoom_to_fit();
|
||||
st
|
||||
}
|
||||
|
||||
pub fn with_images_left_in_file(mut self, path: &str, count: usize) -> Self {
|
||||
self.images_left_in_file = (count > 0).then(|| (path.to_string(), count));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn images_lost_saving_to(&self, path: &str) -> Option<usize> {
|
||||
self.images_left_in_file.as_ref().filter(|(file, _)| file_access::same_file(file, path)).map(|(_, count)| *count)
|
||||
}
|
||||
|
||||
/// The selected ruler guide (its owner and index): it counts while no layer is selected and it
|
||||
/// is still there on this page.
|
||||
pub fn selected_guide(&self) -> Option<(NodeId, usize)> {
|
||||
@@ -757,6 +768,12 @@ pub struct Clipboard {
|
||||
pub images: std::collections::BTreeMap<String, figcraft_doc::ImageData>,
|
||||
/// Copied properties (Copy properties / Paste properties).
|
||||
pub props: Option<Value>,
|
||||
/// A copied fill or stroke row (`fill.copy`, `stroke.copy`): whether it's a fill, and the paint.
|
||||
pub paint: Option<(bool, figcraft_color::Paint)>,
|
||||
/// The stroke settings (`weight`, alignment, sides…) copied with a stroke's paint.
|
||||
pub stroke: Option<figcraft_doc::StrokeProps>,
|
||||
/// A copied effect row (`effect.copy`).
|
||||
pub effect: Option<figcraft_doc::Effect>,
|
||||
/// The page-space bounds of the copied selection.
|
||||
pub bounds: Option<figcraft_geom::Rect>,
|
||||
pub source_doc: u64,
|
||||
@@ -1198,8 +1215,9 @@ impl Session {
|
||||
}
|
||||
let Some(e) = ds.history.undo.pop() else { return Ok(false) };
|
||||
let redo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page };
|
||||
ds.history.redo.push(redo);
|
||||
ds.doc = e.doc;
|
||||
cmd::facilitate::keep(&redo.doc, &mut ds.doc);
|
||||
ds.history.redo.push(redo);
|
||||
ds.selection = e.selection;
|
||||
if ds.doc.pages.contains(&e.page) {
|
||||
ds.page = e.page;
|
||||
@@ -1234,8 +1252,9 @@ impl Session {
|
||||
}
|
||||
let Some(e) = ds.history.redo.pop() else { return Ok(false) };
|
||||
let undo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page };
|
||||
ds.history.undo.push(undo);
|
||||
ds.doc = e.doc;
|
||||
cmd::facilitate::keep(&undo.doc, &mut ds.doc);
|
||||
ds.history.undo.push(undo);
|
||||
ds.selection = e.selection;
|
||||
if ds.doc.pages.contains(&e.page) {
|
||||
ds.page = e.page;
|
||||
@@ -1621,6 +1640,8 @@ mod batch_step_ref_tests;
|
||||
#[cfg(test)]
|
||||
mod binding_lists_tests;
|
||||
#[cfg(test)]
|
||||
mod board_connector_tests;
|
||||
#[cfg(test)]
|
||||
mod board_tables_tests;
|
||||
#[cfg(test)]
|
||||
mod board_tests;
|
||||
@@ -1697,6 +1718,8 @@ mod export_depth_tests;
|
||||
#[cfg(test)]
|
||||
mod extended_chain_tests;
|
||||
#[cfg(test)]
|
||||
mod facilitate_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_export_assets_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_export_tests;
|
||||
@@ -1731,6 +1754,8 @@ mod huge_stroke_tests;
|
||||
#[cfg(test)]
|
||||
mod id_params_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod images_left_in_file_tests;
|
||||
#[cfg(test)]
|
||||
mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod import_journal_tests;
|
||||
@@ -1817,6 +1842,8 @@ pub(crate) mod plugins_tests;
|
||||
#[cfg(test)]
|
||||
mod progressive_blur_tests;
|
||||
#[cfg(test)]
|
||||
mod property_copy_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_conditional_tests;
|
||||
@@ -1869,6 +1896,8 @@ mod shapes_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod shortcut_parity_tests;
|
||||
#[cfg(test)]
|
||||
mod sketch_import_tests;
|
||||
#[cfg(test)]
|
||||
mod slot_tests;
|
||||
#[cfg(test)]
|
||||
mod stroke_style_tests;
|
||||
|
||||
@@ -272,6 +272,23 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
|
||||
assert_eq!((&r["status"], &r["code"], &r["share"]), (&json!("off"), &Value::Null, &Value::Null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_online_uses_a_separate_key_param() {
|
||||
// The Share popover sends the server key as its own field when the address (from Preferences,
|
||||
// which never holds a key) needs one. It must reach `publish` and not leak into the summary.
|
||||
let (mut s, script) = session();
|
||||
ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"}));
|
||||
let r = ex(&mut s, "collab.share", json!({"key": "KeyKeyKeyKeyKeyKeyKey1"}));
|
||||
assert_eq!(r["status"], "connecting");
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let [p] = sc.published.as_slice() else { panic!("published {}", sc.published.len()) };
|
||||
assert_eq!(p.server, "wss://share.example/");
|
||||
assert_eq!(p.key.as_deref(), Some("KeyKeyKeyKeyKeyKeyKey1"));
|
||||
}
|
||||
assert!(!r.to_string().contains("KeyKey"), "the server's key is nowhere in the summary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_without_join_codes_leaves_the_links() {
|
||||
let (mut s, script) = session();
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"component.playground",
|
||||
"Component playground",
|
||||
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|swiftui|compose (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
|
||||
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "component.playground";
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
|
||||
};
|
||||
let lang = match string(p, "language") {
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose"))?,
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose|arkui"))?,
|
||||
None => CodeLang::React,
|
||||
};
|
||||
let (copy, inst) = build(s.doc()?, target, &values)?;
|
||||
|
||||
@@ -176,6 +176,39 @@ fn writes_widget_data(params: &Value, depth: usize) -> bool {
|
||||
struct EngineHost<'s> {
|
||||
s: &'s mut Session,
|
||||
manifest: &'s Manifest,
|
||||
/// Files offered for saving in this call.
|
||||
saves: u32,
|
||||
}
|
||||
|
||||
/// Longest file name a plugin may suggest, in characters.
|
||||
const MAX_SAVE_NAME: usize = 128;
|
||||
/// Longest MIME type a plugin may name, in bytes.
|
||||
const MAX_SAVE_MIME: usize = 100;
|
||||
/// Largest file a plugin may offer for saving, in bytes (the sandbox's message limit).
|
||||
const MAX_SAVE_BYTES: usize = 16 * 1024 * 1024;
|
||||
/// Files a plugin may offer per call.
|
||||
const MAX_SAVES: u32 = 1;
|
||||
|
||||
/// A file name a plugin suggested, cleaned up: a bare name without folders, drive letters, `..` or
|
||||
/// characters no file system takes. `None` when nothing usable is left.
|
||||
pub(crate) fn sanitize_save_name(name: &str) -> Option<String> {
|
||||
let name = name.trim();
|
||||
let bad = name.is_empty()
|
||||
|| name.chars().count() > MAX_SAVE_NAME
|
||||
|| name == "."
|
||||
|| name.contains("..")
|
||||
|| name.chars().any(|c| c.is_control() || crate::cmd::file::FORBIDDEN_FILE_NAME_CHARS.contains(c));
|
||||
if bad { None } else { Some(name.to_string()) }
|
||||
}
|
||||
|
||||
/// `mime` when it looks like `type/subtype` in ASCII, else the generic binary type.
|
||||
fn sanitize_mime(mime: &str) -> String {
|
||||
let mime = mime.trim();
|
||||
let token = |t: &str| !t.is_empty() && t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-' | '_'));
|
||||
match mime.split_once('/') {
|
||||
Some((a, b)) if mime.len() <= MAX_SAVE_MIME && token(a) && token(b) => mime.to_ascii_lowercase(),
|
||||
_ => "application/octet-stream".into(),
|
||||
}
|
||||
}
|
||||
|
||||
impl figcraft_plugin::Host for EngineHost<'_> {
|
||||
@@ -249,6 +282,27 @@ impl figcraft_plugin::Host for EngineHost<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_file(&mut self, name: &str, mime: &str, data: &[u8]) -> std::result::Result<(), String> {
|
||||
if !self.manifest.permissions.save_file {
|
||||
return Err(format!("{} didn't ask permission to save files (`permissions.saveFile`)", self.manifest.name));
|
||||
}
|
||||
let name = sanitize_save_name(name).ok_or_else(|| {
|
||||
format!("not a usable file name: use a plain name of 1–{MAX_SAVE_NAME} characters without folders or `/ \\ : .. < > \" | ? *`")
|
||||
})?;
|
||||
if data.len() > MAX_SAVE_BYTES {
|
||||
return Err(format!("a file to save is limited to {} MB", MAX_SAVE_BYTES / (1024 * 1024)));
|
||||
}
|
||||
if self.saves >= MAX_SAVES {
|
||||
return Err("a plugin can offer one file per run".into());
|
||||
}
|
||||
self.saves += 1;
|
||||
use base64::Engine as _;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(data);
|
||||
// The app asks the user where it goes; programmatic callers drop the request.
|
||||
self.s.request_ui("dialog.pluginSave", json!({"plugin": self.manifest.id, "name": name, "mime": sanitize_mime(mime), "data": data}));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed plugin `id`.
|
||||
@@ -459,14 +513,21 @@ pub(crate) fn call_plugin(
|
||||
input: &str,
|
||||
) -> std::result::Result<RunOutput, (EngineError, bool)> {
|
||||
let mut usable = false;
|
||||
s.grouped(label, |s| {
|
||||
let mut host = EngineHost { s, manifest: m };
|
||||
instance.call(input, &mut host).map_err(|e| {
|
||||
usable = matches!(e, PluginError::Failed(_));
|
||||
plugin_error(&m.name, e)
|
||||
let ui_before = s.ui_requests.len();
|
||||
let r = s
|
||||
.grouped(label, |s| {
|
||||
let mut host = EngineHost { s, manifest: m, saves: 0 };
|
||||
instance.call(input, &mut host).map_err(|e| {
|
||||
usable = matches!(e, PluginError::Failed(_));
|
||||
plugin_error(&m.name, e)
|
||||
})
|
||||
})
|
||||
})
|
||||
.map_err(|e| (e, usable))
|
||||
.map_err(|e| (e, usable));
|
||||
// A failed run opens no dialog (a file it offered to save is dropped with it).
|
||||
if r.is_err() {
|
||||
s.ui_requests.truncate(ui_before);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// What `plugin.run` and `plugin.event` return.
|
||||
|
||||
@@ -407,6 +407,132 @@ fn a_full_undo_history_keeps_its_steps_and_tampered_manifests_are_refused() {
|
||||
assert!(matches!(s.execute("plugin.run", &json!({"id": "org.test.many"})), Err(EngineError::BadParams { .. })));
|
||||
}
|
||||
|
||||
/// A module that offers `files` (name, mime, data) with `save_file` in order, outputs the last
|
||||
/// refusal and returns `exit`.
|
||||
fn saver(files: &[(&str, &str, &[u8])], exit: i32) -> Vec<u8> {
|
||||
let hex = |b: &[u8]| b.iter().map(|b| format!("\\{b:02x}")).collect::<String>();
|
||||
let mut data = String::new();
|
||||
let mut body = String::new();
|
||||
let mut at = 0usize;
|
||||
for (name, mime, bytes) in files {
|
||||
let (n, m) = (at, at + name.len());
|
||||
let d = m + mime.len();
|
||||
data += &format!(
|
||||
"(data (i32.const {n}) \"{}\") (data (i32.const {m}) \"{}\") (data (i32.const {d}) \"{}\")\n",
|
||||
hex(name.as_bytes()),
|
||||
hex(mime.as_bytes()),
|
||||
hex(bytes)
|
||||
);
|
||||
body += &format!(
|
||||
"(local.set $st (call $save_file (i32.const {n}) (i32.const {}) (i32.const {m}) (i32.const {}) (i32.const {d}) (i32.const {})))
|
||||
(if (local.get $st) (then (call $set_output (i32.const 30000) (call $reply_read (i32.const 30000) (i32.const 500)))))\n",
|
||||
name.len(),
|
||||
mime.len(),
|
||||
bytes.len()
|
||||
);
|
||||
at = d + bytes.len();
|
||||
}
|
||||
let wat = format!(
|
||||
r#"(module
|
||||
(import "figcraft" "save_file" (func $save_file (param i32 i32 i32 i32 i32 i32) (result i32)))
|
||||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||
(memory (export "memory") 1)
|
||||
{data}
|
||||
(func (export "figcraft_run") (param $n i32) (result i32) (local $st i32)
|
||||
{body}
|
||||
(i32.const {exit})))"#
|
||||
);
|
||||
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
|
||||
}
|
||||
|
||||
fn save_manifest(id: &str, save_file: bool) -> Value {
|
||||
json!({"id": id, "name": "Saver", "version": "1.0.0", "main": "plugin.wasm", "api": 4, "permissions": {"access": "read", "saveFile": save_file}})
|
||||
}
|
||||
|
||||
fn saves(s: &Session) -> Vec<&crate::UiRequest> {
|
||||
s.ui_requests.iter().filter(|r| r.kind == "dialog.pluginSave").collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_without_the_permission_cannot_save() {
|
||||
let mut s = Session::new();
|
||||
install(&mut s, save_manifest("org.test.nosave", false), &saver(&[("a.txt", "text/plain", b"hi")], 0));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.nosave"}));
|
||||
assert!(out["output"].as_str().unwrap_or_default().contains("permission"), "{out}");
|
||||
assert!(saves(&s).is_empty(), "no dialog without the permission");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_with_the_permission_requests_one_save_dialog_with_its_bytes() {
|
||||
let mut s = Session::new();
|
||||
install(&mut s, save_manifest("org.test.save", true), &saver(&[("out.jsx", "TEXT/Javascript", b"\x00hi\xff")], 0));
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.save"}));
|
||||
let reqs = saves(&s);
|
||||
let [r] = reqs.as_slice() else { panic!("one request, got {}", reqs.len()) };
|
||||
assert_eq!(r.data["plugin"], "org.test.save");
|
||||
assert_eq!(r.data["name"], "out.jsx");
|
||||
assert_eq!(r.data["mime"], "text/javascript");
|
||||
assert_eq!(r.data["data"], b64(b"\x00hi\xff"));
|
||||
// Nothing in the request is a path.
|
||||
assert!(r.data.get("path").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_file_in_one_run_and_bad_names_are_refused() {
|
||||
let mut s = Session::new();
|
||||
install(&mut s, save_manifest("org.test.two", true), &saver(&[("a.txt", "text/plain", b"1"), ("b.txt", "text/plain", b"2")], 0));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.two"}));
|
||||
assert_eq!(saves(&s).len(), 1, "only the first file");
|
||||
assert!(out["output"].as_str().unwrap_or_default().contains("one file"), "{out}");
|
||||
s.ui_requests.clear();
|
||||
install(&mut s, save_manifest("org.test.bad", true), &saver(&[("../etc/passwd", "text/plain", b"1")], 0));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.bad"}));
|
||||
assert!(saves(&s).is_empty());
|
||||
assert!(out["output"].as_str().unwrap_or_default().contains("file name"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_odd_mime_type_becomes_generic_binary() {
|
||||
let mut s = Session::new();
|
||||
install(&mut s, save_manifest("org.test.mime", true), &saver(&[("a.bin", "not a type", b"1")], 0));
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.mime"}));
|
||||
assert_eq!(saves(&s)[0].data["mime"], "application/octet-stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_fails_afterwards_leaves_no_dialog_behind() {
|
||||
let mut s = Session::new();
|
||||
install(&mut s, save_manifest("org.test.late", true), &saver(&[("a.txt", "text/plain", b"1")], 1));
|
||||
run_err(&mut s, "org.test.late");
|
||||
assert!(saves(&s).is_empty(), "a failed run opens no dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_commands_stay_closed_to_plugins() {
|
||||
let mut s = Session::new();
|
||||
let calls = [("file.saveAs", json!({"path": "/tmp/x.figcraft"}))];
|
||||
install(&mut s, manifest("org.test.files", "read", &[]), &script(&calls, OnRefusal::Report, ""));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.files"}));
|
||||
assert!(out["output"].as_str().unwrap_or_default().contains("isn't available to plugins"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_names_are_plain_file_names() {
|
||||
use crate::plugins::sanitize_save_name as clean;
|
||||
let long = "a".repeat(129);
|
||||
let max = "a".repeat(128);
|
||||
// 128 two-byte characters are within the limit even though they take 256 bytes.
|
||||
let wide = "é".repeat(128);
|
||||
for bad in ["", " ", ".", "..", "../x", "a/b", "a\\b", "C:x", "a..b", "a\nb", "a\u{0}b", "a\u{7f}b", "a*b", long.as_str()] {
|
||||
assert_eq!(clean(bad), None, "{bad:?}");
|
||||
}
|
||||
assert_eq!(clean(" out.jsx ").as_deref(), Some("out.jsx"));
|
||||
assert_eq!(clean(&max).as_deref(), Some(max.as_str()));
|
||||
assert_eq!(clean(&wide).as_deref(), Some(wide.as_str()));
|
||||
assert_eq!(clean("Entwurf – Büro.jsx").as_deref(), Some("Entwurf – Büro.jsx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinstalling_under_an_existing_id_drops_the_old_settings() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Copying one fill, stroke or effect row (`fill.copy`, `stroke.copy`, `effect.copy`) and pasting
|
||||
//! it onto other layers (`fill.paste`, `stroke.paste`, `effect.paste`): the Design panel's row
|
||||
//! menus.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Effect, EffectKind, NodeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session) -> NodeId {
|
||||
id(ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})))
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> figcraft_doc::Node {
|
||||
s.doc().unwrap().node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_fill_replaces_a_fill_row_or_adds_one() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s);
|
||||
let b = rect(&mut s);
|
||||
let red = Paint::solid(Color::from_hex("#FF0000").unwrap());
|
||||
ex(&mut s, "fill.add", json!({"ids": [a], "paint": red}));
|
||||
let copied = ex(&mut s, "fill.copy", json!({"id": a, "index": 1}));
|
||||
assert_eq!(copied["type"], "solid", "the copy reports the paint it took");
|
||||
// Onto b's fill row: it replaces that paint.
|
||||
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
|
||||
assert_eq!(node(&s, b).fills, vec![red.clone()]);
|
||||
// With no row: it adds one on top.
|
||||
ex(&mut s, "fill.paste", json!({"ids": [b]}));
|
||||
assert_eq!(node(&s, b).fills, [red.clone(), red]);
|
||||
// Each paste is one undo step.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, b).fills.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_stroke_pastes_as_a_stroke_only() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s);
|
||||
let b = rect(&mut s);
|
||||
let blue = Paint::solid(Color::from_hex("#0000FF").unwrap());
|
||||
ex(&mut s, "stroke.add", json!({"ids": [a], "paint": blue}));
|
||||
ex(&mut s, "node.set", json!({"ids": [a], "props": {"strokeWeight": 3.0, "strokeAlign": "outside"}}));
|
||||
ex(&mut s, "stroke.copy", json!({"id": a, "index": 0}));
|
||||
ex(&mut s, "stroke.add", json!({"ids": [b]}));
|
||||
ex(&mut s, "stroke.paste", json!({"ids": [b], "index": 0}));
|
||||
assert_eq!(node(&s, b).strokes, [blue]);
|
||||
assert_eq!(node(&s, b).stroke, node(&s, a).stroke, "the weight and alignment come with the paint");
|
||||
assert_eq!((node(&s, b).stroke.weight, node(&s, b).stroke.align), (3.0, figcraft_doc::StrokeAlign::Outside));
|
||||
// A copied fill doesn't paste as a stroke, and the target keeps its stroke.
|
||||
ex(&mut s, "fill.copy", json!({"id": a}));
|
||||
assert!(s.execute("stroke.paste", &json!({"ids": [b]})).is_err());
|
||||
assert_eq!(node(&s, b).strokes.len(), 1);
|
||||
// A layer with no stroke has nothing to copy.
|
||||
assert!(s.execute("stroke.copy", &json!({"id": b, "index": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_effect_replaces_an_effect_row_or_adds_one() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s);
|
||||
let b = rect(&mut s);
|
||||
ex(&mut s, "effect.add", json!({"ids": [a], "effect": {"type": "dropShadow", "radius": 7, "offset": [2, 3]}}));
|
||||
ex(&mut s, "effect.copy", json!({"id": a, "index": 0}));
|
||||
let e = node(&s, a).effects[0].clone();
|
||||
// Onto b's effect row: it replaces that effect.
|
||||
ex(&mut s, "effect.add", json!({"ids": [b], "type": "layerBlur"}));
|
||||
ex(&mut s, "effect.paste", json!({"ids": [b], "index": 0}));
|
||||
assert_eq!(node(&s, b).effects, vec![e.clone()]);
|
||||
// With no row: it adds one on top.
|
||||
ex(&mut s, "effect.paste", json!({"ids": [b]}));
|
||||
assert_eq!(node(&s, b).effects, [e.clone(), e]);
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, b).effects.len(), 1);
|
||||
let Effect { kind: EffectKind::DropShadow(shadow), visible } = node(&s, b).effects[0].clone() else {
|
||||
panic!("the pasted effect is a drop shadow")
|
||||
};
|
||||
assert_eq!((shadow.radius, shadow.offset, visible), (7.0, (2.0, 3.0), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_design_layers_keeps_a_copied_row() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s);
|
||||
ex(&mut s, "fill.copy", json!({"id": a}));
|
||||
// Layers pasted from another app replace the copied layers, not the copied fill row.
|
||||
let mut other = Session::new();
|
||||
other.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let exported = other.execute("file.export", &json!({"format": "fig"})).unwrap();
|
||||
ex(&mut s, "edit.pasteDesignLayers", json!({"data": exported["data"]}));
|
||||
let b = rect(&mut s);
|
||||
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_pattern_cannot_paste_into_a_layer_it_draws() {
|
||||
let mut s = Session::new();
|
||||
let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
let inner = id(ex(&mut s, "node.create", json!({"type": "rectangle", "x": 10, "y": 10, "width": 20, "height": 20, "parent": outer})));
|
||||
// A pattern taken from the frame's child, on a layer beside the frame.
|
||||
let source = rect(&mut s);
|
||||
ex(&mut s, "fill.pattern", json!({"ids": [source], "source": inner}));
|
||||
ex(&mut s, "fill.copy", json!({"id": source}));
|
||||
// Onto the frame that holds the pattern's source it would draw itself, as `fill.pattern` refuses.
|
||||
assert!(s.execute("fill.paste", &json!({"ids": [outer]})).is_err());
|
||||
// Onto a layer that doesn't hold the pattern's source it still pastes.
|
||||
let other = rect(&mut s);
|
||||
ex(&mut s, "fill.paste", json!({"ids": [other], "index": 0}));
|
||||
}
|
||||
@@ -175,6 +175,30 @@ fn double_click_enters_one_level() {
|
||||
assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(k.r), "double-click on a shape edits its points");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_click_anywhere_on_the_selected_shape_edits_its_points() {
|
||||
let mut s = Session::new();
|
||||
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}}));
|
||||
ex(&mut s, "stroke.add", json!({"ids": [r]}));
|
||||
// Selected by its outline; the empty middle is no hit at all (the shape has no fill).
|
||||
click(&mut s, 0.0, 50.0, NONE);
|
||||
assert_eq!(s.selection(), vec![r]);
|
||||
s.pointer(&ev(PointerKind::DoubleClick, 50.0, 50.0, NONE)).unwrap();
|
||||
assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(r), "a double-click on the shape edits its points");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_click_on_an_unselected_shape_edits_it_without_a_selecting_click() {
|
||||
let mut s = Session::new();
|
||||
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
// The press and release of the click that selects it, and the double-click, came in one frame:
|
||||
// only the double-click reached the tool, with nothing selected.
|
||||
s.pointer(&ev(PointerKind::DoubleClick, 50.0, 50.0, NONE)).unwrap();
|
||||
assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects_what_it_touches() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -137,6 +137,35 @@ fn a_stroked_shape_gives_its_ring_and_keeps_its_look() {
|
||||
assert!(kids.iter().position(|k| *k == o) > kids.iter().position(|k| *k == r), "the outline sits above the shape");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_centred_stroke_on_a_sharp_star_makes_three_regions() {
|
||||
let mut s = Session::new();
|
||||
// Five sharp tips: a centred stroke's outline folds over itself at each one and meets the
|
||||
// star's fill, which used to give overlapping regions and a spiked outline along them.
|
||||
let mut pts = Vec::new();
|
||||
for i in 0..10 {
|
||||
let a = -std::f64::consts::FRAC_PI_2 + i as f64 * std::f64::consts::PI / 5.0;
|
||||
let r = if i % 2 == 0 { 100.0 } else { 40.0 };
|
||||
pts.push([150.0 + r * a.cos(), 150.0 + r * a.sin()]);
|
||||
}
|
||||
let v = id(&ex(&mut s, "vector.create", json!({"points": pts, "closed": true})));
|
||||
ex(&mut s, "node.set", json!({"id": v, "props": {"fill": "#ff0000", "strokes": ["#0000ff"], "strokeWeight": 8, "strokeAlign": "center"}}));
|
||||
s.set_selection(vec![v]);
|
||||
let out = ex(&mut s, "shapeBuilder.regions", json!({}));
|
||||
assert_eq!(out["regions"].as_array().unwrap().len(), 3, "the inside and the stroke's two halves: {out}");
|
||||
let a = crate::shapebuilder::arrangement(&mut s, &[v]).unwrap();
|
||||
for (i, r) in a.regions.iter().enumerate() {
|
||||
for (j, o) in a.regions.iter().enumerate() {
|
||||
if let (false, Some(p)) = (i == j, r.point) {
|
||||
assert_eq!(o.path.winding(p), 0, "regions {i} and {j} overlap at {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut covers: Vec<(bool, bool)> = a.regions.iter().map(|r| (r.parts.iter().any(|p| p.stroke), r.parts.iter().any(|p| !p.stroke))).collect();
|
||||
covers.sort();
|
||||
assert_eq!(covers, vec![(false, true), (true, false), (true, true)], "fill only, stroke only, both");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_regions_survive_junk() {
|
||||
for j in [
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
//! `.sketch` files through the file commands. The importer's own tests (fixtures built in code)
|
||||
//! are in `figcraft_format::sketch`.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use base64::Engine;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A minimal `.sketch` zip with one page per `(id, name)`, saved by `app_version`.
|
||||
fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec<u8> {
|
||||
sketch_with(app_version, pages, &[])
|
||||
}
|
||||
|
||||
/// [`sketch`] with `layers` on the first page.
|
||||
fn sketch_with(app_version: &str, pages: &[(&str, &str)], layers: &[Value]) -> Vec<u8> {
|
||||
sketch_with_doc(app_version, pages, layers, json!({}))
|
||||
}
|
||||
|
||||
/// [`sketch_with`] with the fields of `extra` added to `document.json` (shared styles…).
|
||||
fn sketch_with_doc(app_version: &str, pages: &[(&str, &str)], layers: &[Value], extra: Value) -> Vec<u8> {
|
||||
let refs: Vec<Value> = pages.iter().map(|(id, _)| json!({"_class": "MSJSONFileReference", "_ref": format!("pages/{id}")})).collect();
|
||||
let mut document = json!({"_class": "document", "pages": refs});
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
document[k] = v.clone();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
z.start_file("document.json", o).unwrap();
|
||||
z.write_all(document.to_string().as_bytes()).unwrap();
|
||||
z.start_file("meta.json", o).unwrap();
|
||||
// The format version that Sketch writes (103 for 50; 110 and up from 52).
|
||||
let version = if app_version.split('.').next().and_then(|v| v.parse::<u32>().ok()).is_some_and(|v| v >= 52) { 125 } else { 103 };
|
||||
z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": version}).to_string().as_bytes()).unwrap();
|
||||
for (i, (id, name)) in pages.iter().enumerate() {
|
||||
let ls = if i == 0 { layers.to_vec() } else { Vec::new() };
|
||||
z.start_file(format!("pages/{id}.json"), o).unwrap();
|
||||
z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": ls}).to_string().as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn temp(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-sketch-test-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn page_names(s: &Session) -> Vec<String> {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_sketch_files_have_their_pages_and_no_path() {
|
||||
let (dir, path) = temp("design.sketch", &sketch("50.2", &[("A", "Screens"), ("B", "Symbols")]));
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(r.get("summary").is_some());
|
||||
assert_eq!(page_names(&s), ["Screens", "Symbols"]);
|
||||
// Never saved over: the document has no path, so Save asks for one.
|
||||
assert!(s.docs[s.active].path.is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_import_appends_pages() {
|
||||
let (dir, path) = temp("more.sketch", &sketch("83.2", &[("A", "Imported")]));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let before = page_names(&s).len();
|
||||
s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let names = page_names(&s);
|
||||
assert_eq!(names.len(), before + 1);
|
||||
assert_eq!(names.last().unwrap(), "Imported");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_data_opens_with_a_summary() {
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(sketch("50.2", &[("A", "Page")]));
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
|
||||
assert!(r.get("summary").is_some());
|
||||
assert_eq!(page_names(&s), ["Page"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_and_damaged_sketch_files_fail_cleanly() {
|
||||
let mut s = Session::new();
|
||||
let (dir, path) = temp("old.sketch", &sketch("47.1", &[("A", "P")]));
|
||||
let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(e.contains("older than 48"), "{e}");
|
||||
let mut broken = sketch("50.2", &[("A", "P")]);
|
||||
broken.truncate(broken.len() / 2);
|
||||
std::fs::write(&path, &broken).unwrap();
|
||||
assert!(s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn shape(class: &str, name: &str, frame: [f64; 4], extra: Value) -> Value {
|
||||
let mut l = json!({"_class": class, "do_objectID": name, "name": name, "isVisible": true, "resizingConstraint": 63,
|
||||
"frame": {"x": frame[0], "y": frame[1], "width": frame[2], "height": frame[3]}, "style": {"fills": []}});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
l[k] = v.clone();
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
fn fill(grey: f64) -> Value {
|
||||
json!({"style": {"fills": [{"isEnabled": true, "fillType": 0, "color": {"red": grey, "green": grey, "blue": grey, "alpha": 1}}]}})
|
||||
}
|
||||
|
||||
/// Sketch draws a mask's own fill under what it clips; the clipped layers show inside it.
|
||||
#[test]
|
||||
fn sketch_masks_show_their_content() {
|
||||
let oval = shape("oval", "Ring", [0.0, 0.0, 60.0, 60.0], json!({"booleanOperation": -1, "points": []}));
|
||||
let mut mask = shape("shapeGroup", "Circle", [0.0, 0.0, 60.0, 60.0], fill(0.8));
|
||||
mask["hasClippingMask"] = json!(true);
|
||||
mask["layers"] = json!([oval]);
|
||||
let pts = ["{0, 0}", "{1, 0}", "{1, 1}", "{0, 1}"].map(|p| json!({"point": p, "curveFrom": p, "curveTo": p, "curveMode": 1}));
|
||||
let body = shape("shapePath", "Square", [0.0, 0.0, 20.0, 20.0], json!({"points": pts, "isClosed": true, "booleanOperation": -1}));
|
||||
let mut content = shape("shapeGroup", "Body", [20.0, 20.0, 20.0, 20.0], fill(0.2));
|
||||
content["layers"] = json!([body]);
|
||||
let icon = shape("group", "Icon", [0.0, 0.0, 60.0, 60.0], json!({"layers": [mask, content]}));
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("50.2", &[("A", "Page")], &[icon]));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let icon = doc.nodes().find(|n| n.name == "Icon").unwrap().id;
|
||||
let pix = figcraft_render::render_node(doc, icon, 1.0).unwrap();
|
||||
let px = |x: u32, y: u32| pix.pixel(x, y).unwrap();
|
||||
// The square in the middle, dark grey.
|
||||
let c = px(30, 30);
|
||||
assert!(c.alpha() > 200 && c.red() < 100, "content: {c:?}");
|
||||
// The mask's own fill around it, light grey; outside the circle nothing.
|
||||
let m = px(30, 8);
|
||||
assert!(m.alpha() > 200 && m.red() > 150, "mask fill: {m:?}");
|
||||
assert!(px(2, 2).alpha() < 50);
|
||||
}
|
||||
|
||||
/// Family names guessed from PostScript names find the installed family whatever its spaces.
|
||||
#[test]
|
||||
fn sketch_font_families_match_installed_ones() {
|
||||
let installed = ["PingFang SC".to_string(), "Helvetica Neue".to_string(), "IBM Plex Sans".to_string()];
|
||||
let m = |f: &str| crate::cmd::file::installed_family(f, &installed);
|
||||
assert_eq!(m("Ping Fang SC").as_deref(), Some("PingFang SC"));
|
||||
assert_eq!(m("Helvetica Neue"), None, "already right");
|
||||
assert_eq!(m("IBMPlex Sans").as_deref(), Some("IBM Plex Sans"));
|
||||
assert_eq!(m("Comic Sans"), None);
|
||||
}
|
||||
|
||||
/// Editing a master changes its instances, and a resized instance's layout isn't an override.
|
||||
#[test]
|
||||
fn sketch_instances_stay_live() {
|
||||
let tag = shape("rectangle", "Tag", [80.0, 0.0, 20.0, 40.0], json!({"resizingConstraint": 60, "points": []}));
|
||||
let master = shape("symbolMaster", "Bar", [0.0, 0.0, 100.0, 40.0], json!({"symbolID": "S", "layers": [tag]}));
|
||||
let inst = shape("symbolInstance", "Wide", [0.0, 100.0, 200.0, 40.0], json!({"symbolID": "S", "overrideValues": []}));
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[master, inst]));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
|
||||
let find = |s: &Session, inst: bool| {
|
||||
let doc = s.doc().unwrap();
|
||||
let root = doc
|
||||
.nodes()
|
||||
.find(|n| if inst { matches!(n.kind, NodeKind::Instance { .. }) } else { matches!(n.kind, NodeKind::Component { .. }) })
|
||||
.unwrap()
|
||||
.id;
|
||||
doc.subtree(root).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Tag")).unwrap()
|
||||
};
|
||||
let (main_tag, inst_tag) = (find(&s, false), find(&s, true));
|
||||
assert!(s.doc().unwrap().node(inst_tag).unwrap().overrides.is_empty(), "{:?}", s.doc().unwrap().node(inst_tag).unwrap().overrides);
|
||||
s.execute("node.set", &json!({"id": main_tag, "props": {"fill": "#ff0000"}})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let fill = &doc.node(inst_tag).unwrap().fills;
|
||||
assert!(fill.iter().any(|p| matches!(p.kind, figcraft_doc::color::PaintKind::Solid { color } if color.r > 0.99 && color.g < 0.01)), "{fill:?}");
|
||||
let b = doc.world_bounds(inst_tag).unwrap();
|
||||
assert!((b.x0 - 180.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
/// Local only (`FIGCRAFT_SKETCH_SAMPLES` names a folder of `.sketch` files): each opens (Sketch 48
|
||||
/// and newer) with its instances linked and finite geometry, or is refused as too old.
|
||||
#[test]
|
||||
fn local_sketch_samples_open() {
|
||||
let Ok(dir) = std::env::var("FIGCRAFT_SKETCH_SAMPLES") else { return };
|
||||
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect();
|
||||
files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch")));
|
||||
files.sort();
|
||||
assert!(!files.is_empty(), "no .sketch files in {dir}");
|
||||
for path in files {
|
||||
let started = std::time::Instant::now();
|
||||
let mut s = Session::new();
|
||||
match s.execute("file.open", &json!({"path": path.to_string_lossy()})) {
|
||||
Err(e) => {
|
||||
let e = e.to_string();
|
||||
assert!(e.contains("older than 48"), "{}: {e}", path.display());
|
||||
eprintln!("{}: refused as too old", path.display());
|
||||
}
|
||||
Ok(r) => {
|
||||
let doc = s.doc().unwrap();
|
||||
let (mut layers, mut instances, mut unlinked) = (0, 0, 0);
|
||||
for n in doc.nodes() {
|
||||
layers += 1;
|
||||
assert!(
|
||||
n.width.is_finite() && n.height.is_finite() && n.transform.as_coeffs().iter().all(|c| c.is_finite()),
|
||||
"{}: {}",
|
||||
path.display(),
|
||||
n.name
|
||||
);
|
||||
if let NodeKind::Instance { component, .. } = n.kind {
|
||||
instances += 1;
|
||||
if !doc.contains(component) {
|
||||
unlinked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(unlinked, 0, "{}", path.display());
|
||||
let notes = r.get("warnings").and_then(|w| w.as_array()).map_or(0, Vec::len);
|
||||
eprintln!("{}: {layers} layers, {instances} instances, {notes} notes, {:?}", path.display(), started.elapsed());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Local only (`FIGCRAFT_SKETCH_SAMPLES` and `FIGCRAFT_SKETCHTOOL`, the path of Sketch's
|
||||
/// `sketchtool`): every artboard of each sample (Sketch 48 and newer) drawn by FigCraft is compared
|
||||
/// with Sketch's own export of it. Prints the share of pixels that differ per artboard (a channel
|
||||
/// off by more than 40, on white); fails past 10% (a broken artboard, not antialiasing or fonts).
|
||||
#[test]
|
||||
fn local_sketch_samples_match_sketch() {
|
||||
let (Ok(dir), Ok(tool)) = (std::env::var("FIGCRAFT_SKETCH_SAMPLES"), std::env::var("FIGCRAFT_SKETCHTOOL")) else { return };
|
||||
// Text draws in the fonts Sketch uses only once the system fonts are known.
|
||||
figcraft_text::fontdb::scan_system_fonts();
|
||||
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect();
|
||||
files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch")));
|
||||
files.sort();
|
||||
let mut worst: Vec<(f64, String)> = Vec::new();
|
||||
for path in files {
|
||||
let mut s = Session::new();
|
||||
if s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err() {
|
||||
continue;
|
||||
}
|
||||
let out = std::env::temp_dir().join(format!("figcraft-sketch-ref-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
let status = std::process::Command::new(&tool)
|
||||
.args(["export", "artboards"])
|
||||
.arg(&path)
|
||||
.arg(format!("--output={}", out.display()))
|
||||
.args(["--formats=png", "--scales=1", "--use-id-for-name=YES", "--include-symbols=NO"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(status.status.success(), "sketchtool failed on {}", path.display());
|
||||
// Artboards by page and order, from the file itself.
|
||||
let archive = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap();
|
||||
let mut archive = archive;
|
||||
let mut read = |name: &str| -> Value {
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut archive.by_name(name).unwrap(), &mut s).unwrap();
|
||||
serde_json::from_str(&s).unwrap()
|
||||
};
|
||||
let document = read("document.json");
|
||||
let doc = s.doc().unwrap();
|
||||
let refs: Vec<String> = document["pages"].as_array().unwrap().iter().map(|r| r["_ref"].as_str().unwrap().to_string()).collect();
|
||||
for (page_ref, page_id) in refs.iter().zip(doc.pages.iter()) {
|
||||
let page = read(&format!("{page_ref}.json"));
|
||||
let boards: Vec<String> = page["layers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|l| l["_class"] == "artboard")
|
||||
.map(|l| l["do_objectID"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
let frames: Vec<figcraft_doc::NodeId> =
|
||||
doc.node(*page_id).unwrap().children.iter().copied().filter(|c| matches!(doc.node(*c).unwrap().kind, NodeKind::Frame)).collect();
|
||||
assert_eq!(boards.len(), frames.len(), "{}: artboards of {page_ref}", path.display());
|
||||
for (id, frame) in boards.iter().zip(frames) {
|
||||
let Ok(reference) = image::open(out.join(format!("{id}.png"))) else { continue };
|
||||
let reference = reference.to_rgba8();
|
||||
let ours = figcraft_render::render_node(doc, frame, 1.0).unwrap();
|
||||
let (w, h) = (reference.width().min(ours.width()), reference.height().min(ours.height()));
|
||||
let ours_rgba = figcraft_render::to_rgba8(&ours);
|
||||
// Both on white, straight alpha.
|
||||
let on_white = |c: &[u8], i: usize| {
|
||||
let a = f64::from(c[3]) / 255.0;
|
||||
f64::from(c[i]) * a + 255.0 * (1.0 - a)
|
||||
};
|
||||
let mut bad = 0u64;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let r = reference.get_pixel(x, y).0;
|
||||
let at = ((y * ours.width() + x) * 4) as usize;
|
||||
let Some(o) = ours_rgba.get(at..at + 4) else { continue };
|
||||
if (0..3).any(|i| (on_white(&r, i) - on_white(o, i)).abs() > 40.0) {
|
||||
bad += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let share = bad as f64 * 100.0 / (f64::from(w) * f64::from(h)).max(1.0);
|
||||
let name = doc.node(frame).unwrap().name.clone();
|
||||
eprintln!("{share:6.2}% {} {name}", path.file_name().unwrap().to_string_lossy());
|
||||
worst.push((share, format!("{}: {name}", path.display())));
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
}
|
||||
worst.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
assert!(worst.first().is_none_or(|w| w.0 < 10.0), "{worst:?}");
|
||||
}
|
||||
|
||||
/// Sketch draws Apple's fonts with the tracking their `trak` table gives at each size (the
|
||||
/// platform does it on its own); the imported text carries it as letter spacing (macOS only).
|
||||
#[test]
|
||||
fn sketch_text_keeps_the_platform_tracking() {
|
||||
if !std::path::Path::new("/System/Library/Fonts/SFNS.ttf").exists() {
|
||||
return;
|
||||
}
|
||||
figcraft_text::fontdb::scan_system_fonts();
|
||||
let font = json!({"name": "SFUIText-Regular", "size": 13});
|
||||
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}});
|
||||
let t = shape("text", "Label", [0.0, 0.0, 100.0, 20.0], json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0}));
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[t]));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") };
|
||||
let ls = td.style.letter_spacing.resolve(13.0);
|
||||
assert!((ls - (-12.0 * 13.0 / 2048.0)).abs() < 1e-6, "{ls}");
|
||||
// A named style's optical size follows the size there: the text carries it.
|
||||
let f = figcraft_text::fontdb::find_face(&td.style.font_family, &td.style.font_style).unwrap();
|
||||
let named = f.axis("opsz").is_some() && f.instance(&td.style.font_style).is_some();
|
||||
assert_eq!(td.style.variations.get("opsz").copied(), named.then_some(13.0), "{:?}", td.style);
|
||||
}
|
||||
|
||||
/// A text an override changed is measured again, and the stack around it re-flows: a hugging chip
|
||||
/// grows to its new label.
|
||||
#[test]
|
||||
fn sketch_overridden_text_reflows_its_stack() {
|
||||
let font = json!({"name": "Helvetica", "size": 12});
|
||||
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}});
|
||||
let label = shape(
|
||||
"text",
|
||||
"Label",
|
||||
[20.0, 4.0, 14.0, 16.0],
|
||||
json!({"attributedString": {"string": "Go", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}),
|
||||
);
|
||||
let dot = shape("oval", "Dot", [8.0, 9.0, 6.0, 6.0], json!({"points": []}));
|
||||
let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 0, "alignItems": 1, "allGuttersGap": 6, "stackingOrder": 0});
|
||||
let master = shape(
|
||||
"symbolMaster",
|
||||
"Chip",
|
||||
[0.0, 0.0, 44.0, 24.0],
|
||||
json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1,
|
||||
"leftPadding": 8, "rightPadding": 10, "topPadding": 4, "bottomPadding": 4, "layers": [label, dot]}),
|
||||
);
|
||||
let ov = json!([{"_class": "overrideValue", "overrideName": "Label_stringValue", "value": "A much longer label here"}]);
|
||||
let inst = shape(
|
||||
"symbolInstance",
|
||||
"Long",
|
||||
[0.0, 100.0, 160.0, 24.0],
|
||||
json!({"symbolID": "S", "overrideValues": ov, "horizontalSizing": 1, "verticalSizing": 1}),
|
||||
);
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst]));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let inst = doc.nodes().find(|n| n.name == "Long").unwrap().id;
|
||||
let label = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Label")).unwrap();
|
||||
let dot = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Dot")).unwrap();
|
||||
let (i, l, d) = (doc.world_bounds(inst).unwrap(), doc.world_bounds(label).unwrap(), doc.world_bounds(dot).unwrap());
|
||||
assert!(l.x1 - l.x0 > 60.0, "the label was measured: {l:?}");
|
||||
assert!((d.x0 - i.x0 - 8.0).abs() < 0.5, "the dot after the padding: {d:?} in {i:?}");
|
||||
assert!((l.x0 - d.x1 - 6.0).abs() < 0.5, "the label after the gap: {l:?}");
|
||||
assert!((i.x1 - l.x1 - 10.0).abs() < 0.5, "the chip hugs: {i:?} {l:?}");
|
||||
}
|
||||
|
||||
/// An instance sized apart from its master lays its master's stack out at its own size: a wide
|
||||
/// button keeps its label and icon together in the middle.
|
||||
#[test]
|
||||
fn sketch_resized_stack_instances_reflow() {
|
||||
let a = shape("rectangle", "A", [22.0, 12.0, 40.0, 20.0], json!({"points": []}));
|
||||
let b = shape("rectangle", "B", [70.0, 12.0, 20.0, 20.0], json!({"points": []}));
|
||||
let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 1, "alignItems": 1, "allGuttersGap": 8, "stackingOrder": 0});
|
||||
let master = shape(
|
||||
"symbolMaster",
|
||||
"Button",
|
||||
[0.0, 0.0, 112.0, 44.0],
|
||||
json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1,
|
||||
"leftPadding": 22, "rightPadding": 22, "topPadding": 12, "bottomPadding": 12, "layers": [b, a]}),
|
||||
);
|
||||
let inst = shape(
|
||||
"symbolInstance",
|
||||
"Wide",
|
||||
[0.0, 100.0, 342.0, 44.0],
|
||||
json!({"symbolID": "S", "overrideValues": [], "horizontalSizing": 0, "verticalSizing": 1}),
|
||||
);
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst]));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let inst = doc.nodes().find(|n| n.name == "Wide").unwrap().id;
|
||||
let at = |name: &str| doc.world_bounds(doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == name)).unwrap()).unwrap();
|
||||
let (a, b) = (at("A"), at("B"));
|
||||
// 40 + 8 + 20 = 68 wide, centred in 342: from 137.
|
||||
assert!((a.x0 - 137.0).abs() < 0.5, "{a:?}");
|
||||
assert!((b.x0 - a.x1 - 8.0).abs() < 0.5, "{a:?} {b:?}");
|
||||
assert!((doc.world_bounds(inst).unwrap().x1 - 342.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// Sketch's natural line: the font's ascent and descent each rounded to whole pixels, without its
|
||||
/// line gap, the baseline at the rounded ascent; the classic Mac fonts get a fifth more, all of it
|
||||
/// above the text.
|
||||
#[test]
|
||||
fn sketch_natural_line_rule() {
|
||||
use crate::cmd::file::sketch_line;
|
||||
// Helvetica Neue Medium (975 + 217 of 1000, a gap of 29 left out) at 17 px: 16.575 → 17 and
|
||||
// 3.689 → 4, a line of 21 with the baseline at 17. FigCraft puts the baseline at
|
||||
// (21 − 20.264) / 2 + 16.575 = 16.943, so the text moves down 0.057.
|
||||
let (line, down) = sketch_line(975.0, 217.0, 1000.0, "Helvetica Neue", 17.0);
|
||||
assert!((line - 21.0).abs() < 1e-9 && (down - 0.057).abs() < 1e-9, "{line} {down}");
|
||||
// Helvetica at 100 px: 77 + 23 and a fifth more, 120, all of it above: the baseline at 97
|
||||
// where FigCraft centres it at 87.
|
||||
let (line, down) = sketch_line(1577.0, 471.0, 2048.0, "helvetica", 100.0);
|
||||
assert!((line - 120.0).abs() < 1e-9 && (down - 10.0).abs() < 0.05, "{line} {down}");
|
||||
assert_eq!(sketch_line(0.0, 0.0, 0.0, "X", 12.0), (0.0, 0.0), "no metrics, no change");
|
||||
assert_eq!(sketch_line(900.0, 200.0, 1000.0, "X", f64::NAN), (0.0, 0.0));
|
||||
}
|
||||
|
||||
/// Text without a fixed line height gets Sketch's natural line as a share of its size, in the
|
||||
/// layer and in shared text styles.
|
||||
#[test]
|
||||
fn sketch_text_gets_sketchs_natural_line() {
|
||||
if !std::path::Path::new("/System/Library/Fonts/HelveticaNeue.ttc").exists() {
|
||||
return;
|
||||
}
|
||||
figcraft_text::fontdb::scan_system_fonts();
|
||||
let font = json!({"name": "HelveticaNeue", "size": 100});
|
||||
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}});
|
||||
let t = shape("text", "Label", [0.0, 0.0, 200.0, 120.0], json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0}));
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[t]));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") };
|
||||
// 95.2 → 95 and 21.3 → 21: 116 px, as a share of the size.
|
||||
let figcraft_doc::LineHeight::Percent(p) = td.style.line_height else { panic!("{:?}", td.style.line_height) };
|
||||
assert!((p - 116.0).abs() < 1e-9, "{p}");
|
||||
}
|
||||
|
||||
/// A text an auto layout places isn't moved to Sketch's baseline (the next layout pass would take
|
||||
/// the move back): laying the stack out again leaves it where the import put it.
|
||||
#[test]
|
||||
fn sketch_text_in_a_stack_stays_put() {
|
||||
if !std::path::Path::new("/System/Library/Fonts/Helvetica.ttc").exists() {
|
||||
return;
|
||||
}
|
||||
figcraft_text::fontdb::scan_system_fonts();
|
||||
let font = json!({"name": "Helvetica", "size": 40});
|
||||
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}});
|
||||
let label = shape(
|
||||
"text",
|
||||
"Label",
|
||||
[10.0, 10.0, 50.0, 48.0],
|
||||
json!({"attributedString": {"string": "Hi", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}),
|
||||
);
|
||||
let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 1, "justifyContent": 0, "alignItems": 0, "allGuttersGap": 0, "stackingOrder": 0});
|
||||
let stack = shape(
|
||||
"group",
|
||||
"Stack",
|
||||
[0.0, 0.0, 200.0, 68.0],
|
||||
json!({"groupBehavior": 1, "groupLayout": layout, "leftPadding": 10, "topPadding": 10, "rightPadding": 10, "bottomPadding": 10, "layers": [label]}),
|
||||
);
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[stack]));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
let stack = doc.nodes().find(|n| n.name == "Stack").unwrap().id;
|
||||
let label = doc.nodes().find(|n| n.name == "Label").unwrap().id;
|
||||
let before = doc.world_bounds(label).unwrap();
|
||||
figcraft_layout::layout_frame(&mut doc, stack, &crate::measure::TextMeasure);
|
||||
let after = doc.world_bounds(label).unwrap();
|
||||
assert!((before.y0 - after.y0).abs() < 1e-6, "{before:?} → {after:?}");
|
||||
}
|
||||
|
||||
/// Shared text styles get what their linked layers get on import: the installed family's name and
|
||||
/// the platform's tracking (a layer and its style stay alike).
|
||||
#[test]
|
||||
fn sketch_shared_text_styles_match_their_layers() {
|
||||
if !std::path::Path::new("/System/Library/Fonts/SFNS.ttf").exists() {
|
||||
return;
|
||||
}
|
||||
figcraft_text::fontdb::scan_system_fonts();
|
||||
let attrs = json!({"MSAttributedStringFontAttribute": {"attributes": {"name": "SFUIText-Regular", "size": 13}}});
|
||||
let style = json!({"_class": "style", "textStyle": {"_class": "textStyle", "encodedAttributes": attrs.clone()}});
|
||||
let shared = json!({"_class": "sharedStyle", "do_objectID": "TS", "name": "Body", "value": style.clone()});
|
||||
let run = json!({"location": 0, "length": 2, "attributes": attrs});
|
||||
let mut linked = style;
|
||||
linked["sharedObjectID"] = json!("TS");
|
||||
let t = shape(
|
||||
"text",
|
||||
"Label",
|
||||
[0.0, 0.0, 100.0, 20.0],
|
||||
json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0, "style": linked}),
|
||||
);
|
||||
let extra = json!({"layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": [shared]}});
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with_doc("64", &[("A", "Page")], &[t], extra));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") };
|
||||
let st = doc
|
||||
.styles
|
||||
.iter()
|
||||
.find_map(|st| match &st.kind {
|
||||
figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(st.font_family, td.style.font_family);
|
||||
assert_eq!(st.letter_spacing.resolve(13.0), td.style.letter_spacing.resolve(13.0), "{st:?}");
|
||||
}
|
||||
@@ -434,6 +434,9 @@ fn every_command_survives_junk_params() {
|
||||
// Online files: ids, servers, refreshes, actions and names as junk.
|
||||
json!({"file": [1], "server": 7, "refresh": "maybe", "action": "share", "name": {"x": 1}}),
|
||||
json!({"file": "../x", "server": "wss://\u{0}", "refresh": true, "action": "unshare", "name": 5}),
|
||||
// Files as base64 data: a damaged `.sketch` (zip magic, then junk) and an old one (SQLite).
|
||||
json!({"data": "UEsDBBQAAAAIAP////8=", "name": "x.sketch"}),
|
||||
json!({"data": "U1FMaXRlIGZvcm1hdCAzAA==", "name": "x.sketch", "path": null}),
|
||||
// Selection color slots: malformed indexes, ranges and paints.
|
||||
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
|
||||
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
|
||||
@@ -658,3 +661,16 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() {
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft");
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft");
|
||||
}
|
||||
|
||||
/// Damaged and too-old `.sketch` data sent to the file commands (which the junk sweep skips) fail
|
||||
/// with an ordinary error, not a caught panic.
|
||||
#[test]
|
||||
fn damaged_sketch_data_fails_without_panicking() {
|
||||
for data in ["UEsDBBQAAAAIAP////8=", "U1FMaXRlIGZvcm1hdCAzAA=="] {
|
||||
for cmd in ["file.open", "file.import"] {
|
||||
let mut s = sess();
|
||||
let r = s.execute(cmd, &json!({"data": data, "name": "x.sketch"}));
|
||||
assert!(matches!(r, Err(ref e) if !matches!(e, EngineError::Internal { .. })), "{cmd}: {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +328,9 @@ pub struct Overlay {
|
||||
pub vector_hover: Option<crate::vectoredit::VHit>,
|
||||
/// The edited layer `vector_hover` is on (several layers can be in edit mode).
|
||||
pub vector_hover_on: Option<NodeId>,
|
||||
/// Vector edit mode: the point a press on the outline would add (the pen's ghost anchor where
|
||||
/// it is over a segment, or the midpoint of the segment under the Move tool).
|
||||
pub vector_add: Option<VectorAdd>,
|
||||
/// Vector edit mode, Variable width tool: the width point, handle or path under the pointer.
|
||||
pub width_hover: Option<crate::vectoredit::WHit>,
|
||||
pub lasso: Vec<[f64; 2]>,
|
||||
@@ -352,6 +355,16 @@ pub struct Overlay {
|
||||
pub shape: Option<ShapeHint>,
|
||||
}
|
||||
|
||||
/// A point a press would add to the edited vector's outline: page position, whether it is a
|
||||
/// segment's midpoint (the Move tool) rather than the pen's place on its segment, and whether the
|
||||
/// pointer is on it (a press adds it; otherwise it just shows where one would go).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct VectorAdd {
|
||||
pub at: [f64; 2],
|
||||
pub midpoint: bool,
|
||||
pub on: bool,
|
||||
}
|
||||
|
||||
/// The Scale tool's live readout while dragging a handle.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct ScaleHint {
|
||||
@@ -452,6 +465,13 @@ pub(crate) enum Gesture {
|
||||
from: figcraft_doc::End,
|
||||
start: Point,
|
||||
},
|
||||
/// Dragging the selected connector `id`'s end (`from`: its start, pressed at `start`) to
|
||||
/// attach it elsewhere.
|
||||
Reattach {
|
||||
id: NodeId,
|
||||
from: bool,
|
||||
start: Point,
|
||||
},
|
||||
/// The eraser: the strokes it took so far.
|
||||
Erase {
|
||||
gone: Vec<NodeId>,
|
||||
@@ -732,6 +752,10 @@ pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<
|
||||
if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) {
|
||||
return None;
|
||||
}
|
||||
// A connector's handles are its ends (see `board::end_handle_at`): no box to resize or turn.
|
||||
if crate::board::selected_connector_ends(doc, sel).is_some() {
|
||||
return None;
|
||||
}
|
||||
let corners = selection_box(doc, sel)?;
|
||||
// Lines (zero height) only get end handles.
|
||||
let is_line = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Line));
|
||||
@@ -1086,6 +1110,10 @@ impl Session {
|
||||
PointerKind::Hover => self.hover(ev),
|
||||
PointerKind::Down => match tool {
|
||||
ToolId::Move | ToolId::Scale => {
|
||||
// While a board's vote runs, a click on an object votes.
|
||||
if tool == ToolId::Move && !self.tool.dev_mode && self.vote_down(ev)? {
|
||||
return Ok(());
|
||||
}
|
||||
// A click on the canvas deselects a ruler guide (the UI selects guides itself).
|
||||
self.ds_mut()?.guide = None;
|
||||
self.move_down(ev)
|
||||
@@ -1202,6 +1230,9 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
if tool == ToolId::Move && !self.tool.dev_mode && crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom).is_some() {
|
||||
cursor = Cursor::Grab;
|
||||
}
|
||||
self.tool.overlay.measure_box = measure_box;
|
||||
self.tool.overlay.cursor = cursor;
|
||||
if tool == ToolId::Pen {
|
||||
@@ -1264,8 +1295,13 @@ impl Session {
|
||||
}
|
||||
textedit::end(self)?;
|
||||
}
|
||||
// Handles first: a selected ellipse's arc handles (drawn over the box), then the box's.
|
||||
// Handles first: a selected connector's ends, a selected ellipse's arc handles (drawn over
|
||||
// the box), then the box's.
|
||||
let edits = !self.tool.dev_mode;
|
||||
if edits && let Some((id, from)) = crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom) {
|
||||
self.tool.gesture = Gesture::Reattach { id, from, start: ev.p() };
|
||||
return Ok(());
|
||||
}
|
||||
if self.arc_handles_shown()
|
||||
&& let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom)
|
||||
&& let Some(arc) = crate::arc_handles::arc_of(&doc, id)
|
||||
@@ -1624,6 +1660,11 @@ impl Session {
|
||||
self.tool.gesture = Gesture::Erase { gone: gone.clone() };
|
||||
return self.erase_at(ev);
|
||||
}
|
||||
Gesture::Reattach { id, from, start } => {
|
||||
let (id, from, start) = (*id, *from, *start);
|
||||
self.reattach_drag(ev, id, from, start);
|
||||
return Ok(());
|
||||
}
|
||||
Gesture::PenDrag { anchor } => {
|
||||
// A click's wobble pulls out no handle.
|
||||
let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor };
|
||||
@@ -2049,6 +2090,7 @@ impl Session {
|
||||
}
|
||||
Gesture::Connect { from, start } => self.connect_up(ev, from, start)?,
|
||||
Gesture::Erase { .. } => self.erase_up()?,
|
||||
Gesture::Reattach { id, from, start } => self.reattach_up(ev, id, from, start)?,
|
||||
Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {}
|
||||
Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {}
|
||||
Gesture::Vector(_) => self.commit_interaction()?,
|
||||
@@ -2102,7 +2144,19 @@ impl Session {
|
||||
self.tool.overlay.marquee = None;
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
|
||||
let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0);
|
||||
// A double-click anywhere on the one selected shape edits its points, like Enter: also
|
||||
// inside a hollow (unfilled) closed shape, where the hit test finds nothing under the
|
||||
// pointer (an open path or a line has no inside: only its stroke counts).
|
||||
if edits
|
||||
&& let [id] = sel[..]
|
||||
&& doc.node(id).is_some_and(|n| n.kind.is_shape() && figcraft_doc::geometry::is_closed(n))
|
||||
&& doc.world_bounds(id).is_some_and(|b| b.contains(ev.p()))
|
||||
&& hit_here.is_none_or(|h| h == id || doc.is_ancestor(id, h))
|
||||
{
|
||||
return crate::vectoredit::begin_many(self, &[id]);
|
||||
}
|
||||
let Some(leaf) = hit_here else { return Ok(()) };
|
||||
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(&doc, leaf, &sel)? {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -2126,8 +2180,12 @@ impl Session {
|
||||
return Ok(());
|
||||
}
|
||||
// Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active).
|
||||
if edits && doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) {
|
||||
let mut ids = crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default();
|
||||
// A shape clicked on its own edits even when the click that selects it and the double-click
|
||||
// arrived in one frame (a press and a release the canvas took together), so no selection was
|
||||
// made yet; another layer's shapes are reached one level at a time, as below.
|
||||
if edits && doc.node(target).is_some_and(|n| n.kind.is_shape()) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target)
|
||||
{
|
||||
let mut ids = if sel.contains(&target) { crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default() } else { vec![target] };
|
||||
ids.retain(|i| *i != target);
|
||||
ids.insert(0, target);
|
||||
return crate::vectoredit::begin_many(self, &ids);
|
||||
|
||||
@@ -94,7 +94,8 @@ fn entering_and_leaving_vector_edit_mode() {
|
||||
("Enter", false, "Enter leaves edit mode"),
|
||||
("Escape", false, "Escape leaves edit mode"),
|
||||
("Escape with an open pen path", true, "Escape first ends the pen's path and stays in edit mode"),
|
||||
("click outside", false, "silent: a click away from the shape leaves edit mode (pinned)"),
|
||||
("click outside", true, "silent: a click away from the shape only clears the points (pinned)"),
|
||||
("double-click outside", false, "a double-click away from the shape leaves edit mode"),
|
||||
];
|
||||
for (how, after, why) in rows {
|
||||
let mut s = Session::new();
|
||||
@@ -113,6 +114,7 @@ fn entering_and_leaving_vector_edit_mode() {
|
||||
key(&mut s, "Escape", false);
|
||||
assert!(editing(&s).is_some_and(|e| e.anchor.is_none()), "{why}: the path ended");
|
||||
}
|
||||
"double-click outside" => s.pointer(&ev(PointerKind::DoubleClick, (600.0, 600.0), NONE)).unwrap(),
|
||||
_ => click(&mut s, (600.0, 600.0), NONE),
|
||||
}
|
||||
assert_eq!(editing(&s).is_some(), after, "{why}");
|
||||
@@ -236,7 +238,7 @@ fn selecting_points_and_segments() {
|
||||
match what {
|
||||
"click tl" => click(&mut s, (0.0, 0.0), m),
|
||||
"click tr" => click(&mut s, (100.0, 0.0), m),
|
||||
"click top" => click(&mut s, (50.0, 0.0), m),
|
||||
"click top" => click(&mut s, (30.0, 0.0), m),
|
||||
"marquee top" => drag(&mut s, (-20.0, -20.0), (110.0, 10.0), m),
|
||||
_ => {
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "lasso"}));
|
||||
@@ -310,7 +312,7 @@ fn deleting_points_vs_delete_and_heal() {
|
||||
// Deleting a selected segment keeps its points when others use them.
|
||||
let mut s = Session::new();
|
||||
let id = square(&mut s, true);
|
||||
click(&mut s, (50.0, 0.0), NONE);
|
||||
click(&mut s, (30.0, 0.0), NONE);
|
||||
key(&mut s, "Delete", false);
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.vertices.len(), n.segments.len()), (4, 3), "deleting a segment leaves its points");
|
||||
|
||||
@@ -209,13 +209,16 @@ fn editing_several_vectors_at_once() {
|
||||
assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (5, 4), "silent: the pen adds to the active layer (pinned)");
|
||||
ex(&mut s, "vector.edit.penEnd", json!({}));
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "move"}));
|
||||
// A click on empty canvas inside an edited layer's area keeps edit mode; far away it ends.
|
||||
// A click on empty canvas inside an edited layer's area keeps edit mode; so does one far
|
||||
// away, which only clears the points (a double-click or Escape leaves).
|
||||
click(&mut s, (250.0, 50.0), NONE);
|
||||
assert!(editing(&s).is_some(), "a click inside an edited layer clears the points and stays");
|
||||
let st = ex(&mut s, "vector.edit.state", json!({}));
|
||||
assert_eq!(st["layers"].as_array().map(Vec::len), Some(2), "the state lists every edited layer");
|
||||
click(&mut s, (900.0, 900.0), NONE);
|
||||
assert!(editing(&s).is_none(), "a click away from every edited layer leaves edit mode");
|
||||
assert!(editing(&s).is_some(), "a click away from every edited layer keeps edit mode");
|
||||
s.pointer(&ev(PointerKind::DoubleClick, (900.0, 900.0), NONE)).unwrap();
|
||||
assert!(editing(&s).is_none(), "a double-click away from every edited layer leaves edit mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -539,9 +539,28 @@ pub fn begin_many(s: &mut Session, ids: &[NodeId]) -> Result<()> {
|
||||
/// At most this many layers are edited at once.
|
||||
const MAX_EDITED: usize = 500;
|
||||
|
||||
/// The edited layers left without a single segment: they draw nothing (isolated points included),
|
||||
/// so their layer would only leave an empty box behind.
|
||||
fn empty_layers(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
ids.iter().copied().filter(|id| network(doc, *id).is_some_and(|n| n.segments.is_empty())).collect()
|
||||
}
|
||||
|
||||
/// Leave edit mode. Layers whose every point was deleted go with it (the undo step that deleted
|
||||
/// the last points brings them back).
|
||||
pub fn end(s: &mut Session) -> Result<()> {
|
||||
let empty = {
|
||||
let ds = s.ds()?;
|
||||
ds.vector_edit.as_ref().map(|ve| empty_layers(&ds.doc, &ve.parts().iter().map(|p| p.id).collect::<Vec<_>>())).unwrap_or_default()
|
||||
};
|
||||
if !empty.is_empty() {
|
||||
let doc = s.doc_mut()?;
|
||||
for id in &empty {
|
||||
let _ = doc.remove(*id);
|
||||
}
|
||||
}
|
||||
let ds = s.ds_mut()?;
|
||||
ds.vector_edit = None;
|
||||
ds.selection.retain(|id| !empty.contains(id));
|
||||
ds.revision += 1;
|
||||
s.tool.overlay = Default::default();
|
||||
Ok(())
|
||||
|
||||
@@ -114,6 +114,16 @@ pub(crate) struct PointSnap {
|
||||
/// Eraser brush radius in screen pixels.
|
||||
const ERASER_PX: f64 = 8.0;
|
||||
|
||||
/// A segment's midpoint is a point a press adds when the pointer is this close (screen pixels).
|
||||
const MIDPOINT_PX: f64 = 6.0;
|
||||
|
||||
/// The page point at `t` (0..1) along segment `seg` of vector `id`.
|
||||
fn segment_point(doc: &Document, id: NodeId, seg: usize, t: f64) -> Option<Point> {
|
||||
use figcraft_geom::kurbo::ParamCurve;
|
||||
let c = network(doc, id)?.cubic(seg)?;
|
||||
Some(doc.world_transform(id) * c.eval(t.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
fn erase_all(net: &mut VectorNetwork, stamps: &[Point], radius: f64) -> bool {
|
||||
stamps.iter().fold(false, |acc, c| ops::erase(net, *c, radius) | acc)
|
||||
}
|
||||
@@ -337,9 +347,22 @@ impl Session {
|
||||
let shown = if width_tool { None } else { h.map(|h| (ve.id, h)).or_else(|| hit_any(&doc, ve, ev.p(), tol)) };
|
||||
// A pen click on empty canvas lands where it snaps.
|
||||
let pen_at = if ve.tool == VTool::Pen && h.is_none() && !ev.shift { Some(self.snap_pen(ev, Some(ve.id), &[])?) } else { None };
|
||||
// A point a press on the outline would add: the pen's place under its pointer on a segment
|
||||
// (its ghost anchor), or the midpoint of the segment under the Move tool.
|
||||
let add = match (ve.tool, h) {
|
||||
(VTool::Pen, Some(VHit::Segment { seg, t })) => {
|
||||
segment_point(&doc, ve.id, seg, t).map(|at| crate::tools::VectorAdd { at: [at.x, at.y], midpoint: false, on: true })
|
||||
}
|
||||
(VTool::Move, Some(VHit::Segment { seg, .. })) if !ev.cmd && !ev.alt => segment_point(&doc, ve.id, seg, 0.5).map(|at| {
|
||||
let on = (at - ev.p()).hypot() * ev.zoom.max(1e-6) <= MIDPOINT_PX;
|
||||
crate::tools::VectorAdd { at: [at.x, at.y], midpoint: true, on }
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let ov = &mut self.tool.overlay;
|
||||
ov.vector_hover = shown.map(|x| x.1);
|
||||
ov.vector_hover_on = shown.map(|x| x.0);
|
||||
ov.vector_add = add;
|
||||
ov.width_hover = None;
|
||||
ov.face.clear();
|
||||
ov.rubber = None;
|
||||
@@ -351,6 +374,8 @@ impl Session {
|
||||
(VTool::Move, Some(_)) if ev.cmd => Cursor::Pen(PenCursor::Bend),
|
||||
(VTool::Move, _) if ev.cmd => Cursor::Crosshair,
|
||||
(VTool::Move, Some(VHit::Segment { .. })) if ev.alt => Cursor::Pen(PenCursor::Add),
|
||||
// On a segment's midpoint the arrow turns into the pen: a press adds a point there.
|
||||
(VTool::Move, Some(VHit::Segment { .. })) if add.is_some_and(|a| a.on) => Cursor::Pen(PenCursor::Add),
|
||||
(VTool::Move, _) => Cursor::Default,
|
||||
(VTool::Pen, Some(VHit::Vertex { .. })) => Cursor::Pen(PenCursor::Close),
|
||||
(VTool::Pen, Some(VHit::Segment { .. })) => Cursor::Pen(PenCursor::Add),
|
||||
@@ -370,11 +395,13 @@ impl Session {
|
||||
{
|
||||
self.tool.overlay.cursor = crate::tools::handle_cursor(&b.corners(), handle);
|
||||
self.tool.overlay.vector_hover = None;
|
||||
self.tool.overlay.vector_add = None;
|
||||
} else if shown.is_none_or(|(on, h)| on == ve.id && !matches!(h, VHit::Handle { .. })) {
|
||||
region = Self::region_under(&doc, ve, h, ev);
|
||||
if region.is_some() {
|
||||
self.tool.overlay.vector_hover = None;
|
||||
self.tool.overlay.vector_hover_on = None;
|
||||
self.tool.overlay.vector_add = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -510,6 +537,7 @@ impl Session {
|
||||
};
|
||||
let h = hit(&doc, ve, ev.p(), tol);
|
||||
self.tool.overlay.vector_hover = None;
|
||||
self.tool.overlay.vector_add = None;
|
||||
self.tool.overlay.face.clear();
|
||||
self.tool.overlay.guides.clear();
|
||||
let tool = if ev.cmd && ve.tool == VTool::Move { VTool::Bend } else { ve.tool };
|
||||
@@ -626,8 +654,14 @@ impl Session {
|
||||
self.vgesture(g);
|
||||
}
|
||||
Some(VHit::Segment { seg, t }) => {
|
||||
if ev.alt {
|
||||
// A press on the midpoint the hover showed adds a point exactly there; Alt adds
|
||||
// one anywhere else on the segment.
|
||||
let midpoint = !ev.alt
|
||||
&& !ev.shift
|
||||
&& segment_point(&doc, id, seg, 0.5).is_some_and(|m| (m - ev.p()).hypot() * ev.zoom.max(1e-6) <= MIDPOINT_PX);
|
||||
if ev.alt || midpoint {
|
||||
// Add a point on the segment and drag it.
|
||||
let t = if midpoint { 0.5 } else { t };
|
||||
self.begin_interaction("Add point")?;
|
||||
let mut v = None;
|
||||
self.preview(|doc, _| {
|
||||
@@ -661,10 +695,14 @@ impl Session {
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Outside every edited layer (and not shift): leave edit mode.
|
||||
// A press on another layer (and not shift) leaves edit mode, so the normal tool
|
||||
// takes it; empty canvas only clears the points and starts a marquee (a
|
||||
// double-click or Escape leaves edit mode).
|
||||
let inside =
|
||||
ve.parts().iter().any(|p| doc.world_render_bounds(p.id).is_some_and(|b| b.inflate(tol * 4.0, tol * 4.0).contains(ev.p())));
|
||||
if !inside && !ev.shift {
|
||||
let page = self.ds()?.page;
|
||||
let other = !inside && self.hit(&doc, page, ev.p(), tol * 2.0).is_some();
|
||||
if other && !ev.shift {
|
||||
super::end(self)?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ fn lasso_marquee_and_segment_selection() {
|
||||
assert!(s.tool.overlay.lasso.is_empty());
|
||||
// Move tool: clicking a segment selects it; shift adds another.
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "move"}));
|
||||
click(&mut s, 50.0, 100.0);
|
||||
click(&mut s, 30.0, 100.0);
|
||||
assert_eq!(ve(&s).segments.len(), 1);
|
||||
assert!(ve(&s).vertices.is_empty());
|
||||
let mut sh = ev(PointerKind::Down, 0.0, 50.0);
|
||||
@@ -310,6 +310,41 @@ fn a_point_dropped_on_another_merges_into_it() {
|
||||
assert_eq!((n.vertices.len(), n.segments.len()), (2, 1), "still a line: {n:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_every_point_takes_the_layer_with_the_edit_mode() {
|
||||
// Delete: no point is left at all.
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.selectAll", json!({}));
|
||||
ex(&mut s, "vector.edit.delete", json!({}));
|
||||
assert!(net(&s, id).vertices.is_empty() && net(&s, id).segments.is_empty(), "no point left");
|
||||
assert!(s.doc().unwrap().node(id).is_some(), "the layer stays while editing");
|
||||
ex(&mut s, "vector.edit.end", json!({}));
|
||||
assert!(s.doc().unwrap().node(id).is_none(), "it goes with edit mode instead of leaving an empty box");
|
||||
assert!(s.selection().is_empty());
|
||||
// The step that deleted the last points brings the shape back.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(net(&s, id).vertices.len(), 4);
|
||||
// Shift+Delete (heal): the last point is left on its own with no segment, drawing nothing either.
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.selectAll", json!({}));
|
||||
ex(&mut s, "vector.deleteHeal", json!({}));
|
||||
assert!(net(&s, id).segments.is_empty(), "nothing draws: {:?}", net(&s, id));
|
||||
ex(&mut s, "vector.edit.end", json!({}));
|
||||
assert!(s.doc().unwrap().node(id).is_none(), "an unconnected point left behind doesn't keep the layer");
|
||||
// Escape ends edit mode the same way, for every layer edited along that is left empty.
|
||||
let mut s = Session::new();
|
||||
let a = crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
|
||||
let b = crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": 200, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
|
||||
ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]}));
|
||||
ex(&mut s, "vector.edit.selectAll", json!({}));
|
||||
ex(&mut s, "vector.edit.delete", json!({}));
|
||||
s.escape().unwrap();
|
||||
assert!(s.ds().unwrap().vector_edit.is_none());
|
||||
assert!(s.doc().unwrap().node(a).is_none() && s.doc().unwrap().node(b).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_back_past_the_conversion_leaves_edit_mode() {
|
||||
let mut s = Session::new();
|
||||
@@ -371,3 +406,67 @@ fn eraser_strokes_cut_paths_in_one_step() {
|
||||
assert!(key(&mut s, "E", true, false, false).unwrap());
|
||||
assert_eq!(ve(&s).tool, VTool::Eraser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_shows_its_midpoint_and_a_press_adds_a_point_there() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
// Near the top edge but off its midpoint: the hint shows where the point would go, but the
|
||||
// pointer is not on it, so the arrow stays.
|
||||
s.pointer(&ev(PointerKind::Hover, 30.0, 0.0)).unwrap();
|
||||
let add = s.tool.overlay.vector_add.expect("the segment's midpoint");
|
||||
assert!(add.midpoint && !add.on, "{add:?}");
|
||||
assert!((add.at[0] - 50.0).abs() < 1e-9 && add.at[1].abs() < 1e-9, "{add:?}");
|
||||
assert_eq!(s.tool.overlay.cursor, crate::tools::Cursor::Default);
|
||||
// On the midpoint: the pen cursor, and a press adds a point there and selects it.
|
||||
s.pointer(&ev(PointerKind::Hover, 50.0, 0.0)).unwrap();
|
||||
assert!(s.tool.overlay.vector_add.is_some_and(|a| a.on), "the pointer is on the midpoint");
|
||||
assert_eq!(s.tool.overlay.cursor, crate::tools::Cursor::Pen(crate::tools::PenCursor::Add));
|
||||
click(&mut s, 50.0, 0.0);
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.vertices.len(), 5, "a point was added: {n:?}");
|
||||
assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 50.0, 0.0)], "the new point is selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_a_midpoint_keeps_new_midpoints_coming_on_each_half() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
click(&mut s, 50.0, 0.0);
|
||||
assert_eq!(net(&s, id).vertices.len(), 5);
|
||||
// The left half has its own midpoint, shown and added the same way.
|
||||
s.pointer(&ev(PointerKind::Hover, 25.0, 0.0)).unwrap();
|
||||
let add = s.tool.overlay.vector_add.expect("the new half's midpoint");
|
||||
assert!(add.midpoint && add.on && (add.at[0] - 25.0).abs() < 1e-9, "{add:?}");
|
||||
click(&mut s, 25.0, 0.0);
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.vertices.len(), 6, "{n:?}");
|
||||
assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 25.0, 0.0)]);
|
||||
// And on it goes: a quarter of the way along.
|
||||
s.pointer(&ev(PointerKind::Hover, 12.5, 0.0)).unwrap();
|
||||
let add = s.tool.overlay.vector_add.expect("another midpoint");
|
||||
assert!((add.at[0] - 12.5).abs() < 1e-9, "{add:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_away_from_the_midpoint_selects_the_segment() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
click(&mut s, 30.0, 0.0);
|
||||
assert_eq!(net(&s, id).vertices.len(), 4, "no point is added off the midpoint");
|
||||
assert_eq!(ve(&s).segments.len(), 1, "the segment is selected instead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pen_shows_its_ghost_anchor_on_the_segment_under_it() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
|
||||
s.pointer(&ev(PointerKind::Hover, 80.0, 0.0)).unwrap();
|
||||
let add = s.tool.overlay.vector_add.expect("the ghost anchor");
|
||||
assert!(!add.midpoint && add.on, "{add:?}");
|
||||
assert!((add.at[0] - 80.0).abs() < 1e-3 && add.at[1].abs() < 1e-3, "{add:?}");
|
||||
click(&mut s, 80.0, 0.0);
|
||||
assert_eq!(net(&s, id).vertices.len(), 5, "the press added the point");
|
||||
assert_eq!(ve(&s).anchor, None, "the click ended the pen's path");
|
||||
}
|
||||
|
||||
@@ -112,14 +112,16 @@ pub enum BoxHit {
|
||||
}
|
||||
|
||||
/// The box's part under page point `p` when it wins over `h`, the network's part there: Bezier
|
||||
/// handles come first, then the box's corners, points, the box's edges, segments, the rotation
|
||||
/// zones, and the inside of the box last. Edges along an axis without extent are left out.
|
||||
/// handles come first, then points, the box's corners, its edges, segments and the rotation zones,
|
||||
/// and the inside of the box last. A point under a corner or an edge is dragged with the others
|
||||
/// (the box draws no handles of its own), so it keeps the box off. Edges along an axis without
|
||||
/// extent are left out.
|
||||
pub fn box_hit(doc: &Document, ve: &VectorEdit, h: Option<VHit>, p: Point, zoom: f64) -> Option<BoxHit> {
|
||||
let b = shown_box(doc, ve)?;
|
||||
match handle_at_corners(&b.corners(), p, zoom, false) {
|
||||
Some(handle) => {
|
||||
let wins = match (handle, h) {
|
||||
(_, Some(VHit::Handle { .. })) => false,
|
||||
(_, Some(VHit::Handle { .. } | VHit::Vertex { .. })) => false,
|
||||
(Handle::Corner(_), _) => true,
|
||||
(Handle::Edge(e), None | Some(VHit::Segment { .. })) => {
|
||||
if e % 2 == 0 {
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::tests::{click, drag, ev, ex, net, pt, rect, ve, vertex_at};
|
||||
use super::transform::{BoxHit, box_hit, shown_box};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{Handle, PointerKind, ToolId};
|
||||
|
||||
/// Page position of point `v` of vector `id`.
|
||||
@@ -47,13 +48,15 @@ fn the_box_shows_for_two_or_more_points_with_the_move_tool() {
|
||||
let b = shown_box(s.doc().unwrap(), &ve(&s)).unwrap();
|
||||
assert_eq!((b.width, b.height, b.points.len()), (100.0, 100.0, 4));
|
||||
assert!(near(b.corners()[2], pt(100.0, 100.0)));
|
||||
// Corners win over the points under them, points over edges, edges over segments.
|
||||
// A point under a corner is dragged with the others, not scaled (the box draws no handles of
|
||||
// its own), so it keeps the box off; corners and edges clear of points still scale, and edges
|
||||
// win over segments.
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let at = |x, y| {
|
||||
let h = hit(&doc, &ve(&s), pt(x, y), 6.0);
|
||||
box_hit(&doc, &ve(&s), h, pt(x, y), 1.0)
|
||||
};
|
||||
assert_eq!(at(100.0, 100.0), Some(BoxHit::Handle(Handle::Corner(2))));
|
||||
assert_eq!(at(100.0, 100.0), None, "a point under the corner: a drag moves the points");
|
||||
assert_eq!(at(100.0, 50.0), Some(BoxHit::Handle(Handle::Edge(1))));
|
||||
assert_eq!(at(110.0, -10.0), Some(BoxHit::Handle(Handle::Rotate(1))));
|
||||
assert_eq!(at(40.0, 60.0), Some(BoxHit::Inside));
|
||||
@@ -64,27 +67,47 @@ fn the_box_shows_for_two_or_more_points_with_the_move_tool() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_scale_points_in_one_undo_step() {
|
||||
fn corners_clear_of_points_scale_them_in_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
// An L of three points: the box's bottom-right corner (50, 50) has no point under it.
|
||||
let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 0], [0, 50]], "closed": false}))["id"]).unwrap();
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
select_all(&mut s);
|
||||
drag(&mut s, (100.0, 100.0), (150.0, 120.0));
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 0.0, 150.0, 120.0));
|
||||
assert_eq!(ve(&s).vertices.len(), 4, "the points stay selected");
|
||||
drag(&mut s, (50.0, 50.0), (150.0, 120.0));
|
||||
assert!(near(page_pt(&s, id, 1), pt(150.0, 0.0)) && near(page_pt(&s, id, 2), pt(0.0, 120.0)), "{:?}", page_pt(&s, id, 1));
|
||||
assert_eq!(ve(&s).vertices.len(), 3, "the points stay selected");
|
||||
s.undo().unwrap();
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
assert_eq!((b.width(), b.height()), (100.0, 100.0), "one undo step");
|
||||
assert!(near(page_pt(&s, id, 1), pt(50.0, 0.0)), "one undo step");
|
||||
// Shift keeps proportions; Alt scales from the centre.
|
||||
select_all(&mut s);
|
||||
drag_with(&mut s, (100.0, 100.0), (150.0, 110.0), true, false);
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 0.0, 150.0, 150.0));
|
||||
drag_with(&mut s, (50.0, 50.0), (150.0, 110.0), true, false);
|
||||
assert!(near(page_pt(&s, id, 1), pt(150.0, 0.0)) && near(page_pt(&s, id, 2), pt(0.0, 150.0)), "{:?}", page_pt(&s, id, 2));
|
||||
s.undo().unwrap();
|
||||
select_all(&mut s);
|
||||
drag_with(&mut s, (100.0, 100.0), (110.0, 120.0), false, true);
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
assert_eq!((b.x0, b.y0, b.x1, b.y1), (-10.0, -20.0, 110.0, 120.0));
|
||||
drag_with(&mut s, (50.0, 50.0), (60.0, 70.0), false, true);
|
||||
assert!(
|
||||
near(page_pt(&s, id, 0), pt(-10.0, -20.0)) && near(page_pt(&s, id, 1), pt(60.0, -20.0)) && near(page_pt(&s, id, 2), pt(-10.0, 70.0)),
|
||||
"{:?}",
|
||||
page_pt(&s, id, 2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_one_of_several_selected_points_moves_them_all_freely() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
let n = net(&s, id);
|
||||
let (a, b) = (vertex_at(&n, 0.0, 0.0), vertex_at(&n, 100.0, 0.0));
|
||||
// Two points on one side: their box is a flat line, and the press is on a corner of it.
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [a, b]}));
|
||||
drag(&mut s, (100.0, 0.0), (130.0, 40.0));
|
||||
assert!(
|
||||
near(page_pt(&s, id, a), pt(30.0, 40.0)) && near(page_pt(&s, id, b), pt(130.0, 40.0)),
|
||||
"{:?} {:?}",
|
||||
page_pt(&s, id, a),
|
||||
page_pt(&s, id, b)
|
||||
);
|
||||
assert_eq!(ve(&s).vertices.len(), 2, "both stay selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -126,7 +126,7 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
|
||||
/// A font style under the name the format uses: the standard weight names as words ("SemiBold"
|
||||
/// → "Semi Bold", "ExtraBoldItalic" → "Extra Bold Italic", "Regular Italic" → "Italic"). Other
|
||||
/// names stay as they are.
|
||||
fn style_name(style: &str) -> String {
|
||||
pub(crate) fn style_name(style: &str) -> String {
|
||||
const WEIGHTS: [&str; 12] =
|
||||
["Thin", "Extra Light", "Ultra Light", "Light", "Regular", "Medium", "Semi Bold", "Demi Bold", "Bold", "Extra Bold", "Ultra Bold", "Black"];
|
||||
let compact = |s: &str| s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect::<String>();
|
||||
|
||||
@@ -66,6 +66,19 @@ fn images_past_the_byte_budget_are_left_out_and_reported() {
|
||||
assert!(loaded.issues.iter().any(|i| i.contains(lost[0].as_str()) && i.contains("together")), "{:?}", loaded.issues);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_past_the_budget_are_counted_and_stay_in_the_file() {
|
||||
let (bytes, _, total) = file_with_images(3);
|
||||
let loaded = load_within(&bytes, ImageBudget { images: 100, bytes: total - 1 }).unwrap();
|
||||
assert_eq!(loaded.images_past_budget, 1);
|
||||
assert!(loaded.issues.iter().any(|i| i.contains("stays in the file")), "{:?}", loaded.issues);
|
||||
assert!(!loaded.issues.iter().any(|i| i.contains("drops it")), "{:?}", loaded.issues);
|
||||
let counted = load_within(&bytes, ImageBudget { images: 2, bytes: u64::MAX }).unwrap();
|
||||
assert_eq!(counted.images_past_budget, 1);
|
||||
let within = load_within(&bytes, ImageBudget { images: 3, bytes: total }).unwrap();
|
||||
assert_eq!(within.images_past_budget, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_past_the_count_budget_are_left_out_and_reported() {
|
||||
let (bytes, _, _) = file_with_images(4);
|
||||
|
||||
+24
-14
@@ -20,6 +20,7 @@
|
||||
pub mod fig;
|
||||
pub mod library;
|
||||
pub mod rest;
|
||||
pub mod sketch;
|
||||
pub mod thumbnail;
|
||||
|
||||
pub use library::{is_valid_library_id, read_library, read_library_within};
|
||||
@@ -190,11 +191,12 @@ pub struct Loaded {
|
||||
pub issues: Vec<String>,
|
||||
pub versions: Vec<Version>,
|
||||
pub branches: Vec<Branch>,
|
||||
pub images_past_budget: usize,
|
||||
}
|
||||
|
||||
impl From<Document> for Loaded {
|
||||
fn from(doc: Document) -> Self {
|
||||
Self { doc, issues: Vec::new(), versions: Vec::new(), branches: Vec::new() }
|
||||
Self { doc, issues: Vec::new(), versions: Vec::new(), branches: Vec::new(), images_past_budget: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,8 +510,9 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F
|
||||
let limit = document_limit(packed, bytes.len() as u64);
|
||||
let text = String::from_utf8(read_member(&mut z, "document.json", limit)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let (mut doc, mut issues, own_images) = parse_json(&text)?;
|
||||
let (images, unread) = read_images(&mut z, own_images.as_ref(), budget);
|
||||
report_lost_images(&mut issues, &unread, own_images.as_ref(), &images);
|
||||
let ImagesRead { images, unread, past_budget } = read_images(&mut z, own_images.as_ref(), budget);
|
||||
report_lost_images(&mut issues, &unread, &past_budget, own_images.as_ref(), &images);
|
||||
let images_past_budget = past_budget.len();
|
||||
match library::read(&mut z, &images, u64::MAX) {
|
||||
Ok(lib) => doc.published = lib.map(Arc::new),
|
||||
Err(e) => issues.push(format!("the published library was skipped: {e}")),
|
||||
@@ -527,17 +530,13 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F
|
||||
.filter(|h| own_images.as_ref().is_some_and(|o| !o.contains(*h)))
|
||||
.collect();
|
||||
doc.images = images.into_iter().filter(|(h, _)| !history_only.contains(h)).collect();
|
||||
Ok(Loaded { doc, issues, versions, branches })
|
||||
Ok(Loaded { doc, issues, versions, branches, images_past_budget })
|
||||
}
|
||||
|
||||
/// The archive's `images/` members by hash, within `budget`, and the ones that couldn't be read
|
||||
/// with why. The document's own images (`own`) are read first, so a file past the budget loses
|
||||
/// the images only its history uses before its own.
|
||||
fn read_images<R: Read + Seek>(
|
||||
z: &mut zip::ZipArchive<R>,
|
||||
own: Option<&BTreeSet<String>>,
|
||||
budget: ImageBudget,
|
||||
) -> (BTreeMap<String, ImageData>, Vec<(String, FormatError)>) {
|
||||
fn read_images<R: Read + Seek>(z: &mut zip::ZipArchive<R>, own: Option<&BTreeSet<String>>, budget: ImageBudget) -> ImagesRead {
|
||||
let mut hashes: Vec<String> = z.file_names().filter_map(|n| n.strip_prefix("images/")).filter(|h| !h.is_empty()).map(str::to_string).collect();
|
||||
hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h)));
|
||||
let mut images = BTreeMap::new();
|
||||
@@ -551,14 +550,17 @@ fn read_images<R: Read + Seek>(
|
||||
ok
|
||||
});
|
||||
let mut left = budget.bytes;
|
||||
let mut past_budget = BTreeSet::new();
|
||||
for (i, hash) in hashes.into_iter().enumerate() {
|
||||
let name = format!("images/{hash}");
|
||||
if i >= budget.images {
|
||||
past_budget.insert(hash.clone());
|
||||
unread.push((hash, FormatError::Corrupt(format!("the file has more than {} images", budget.images))));
|
||||
continue;
|
||||
}
|
||||
if z.by_name(&name).is_ok_and(|f| f.size() > left) {
|
||||
let mb = budget.bytes / (1024 * 1024);
|
||||
past_budget.insert(hash.clone());
|
||||
unread.push((hash, FormatError::Corrupt(format!("the file's images together pass {mb} MB"))));
|
||||
continue;
|
||||
}
|
||||
@@ -570,18 +572,22 @@ fn read_images<R: Read + Seek>(
|
||||
Err(e) => unread.push((hash, e)),
|
||||
}
|
||||
}
|
||||
(images, unread)
|
||||
ImagesRead { images, unread, past_budget }
|
||||
}
|
||||
|
||||
struct ImagesRead {
|
||||
images: BTreeMap<String, ImageData>,
|
||||
unread: Vec<(String, FormatError)>,
|
||||
past_budget: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// Most lost images reported one by one (the rest as a count).
|
||||
const MAX_IMAGE_ISSUES: usize = 20;
|
||||
|
||||
/// Report the images a file lost on loading: members that couldn't be read (damaged, too large)
|
||||
/// and images its document lists but the archive doesn't hold. They are left out, so saving the
|
||||
/// file again would drop them for good.
|
||||
fn report_lost_images(
|
||||
issues: &mut Vec<String>,
|
||||
unread: &[(String, FormatError)],
|
||||
past_budget: &BTreeSet<String>,
|
||||
listed: Option<&BTreeSet<String>>,
|
||||
images: &BTreeMap<String, ImageData>,
|
||||
) {
|
||||
@@ -592,7 +598,11 @@ fn report_lost_images(
|
||||
FormatError::Corrupt(m) | FormatError::Io(m) => m.clone(),
|
||||
FormatError::Newer(v) => format!("format version {v}"),
|
||||
};
|
||||
format!("image {hash} couldn't be read ({why}): it is left out, and saving this file again drops it")
|
||||
if past_budget.contains(hash) {
|
||||
format!("image {hash} couldn't be read ({why}): it is left out and stays in the file, which can't be saved over without losing it")
|
||||
} else {
|
||||
format!("image {hash} couldn't be read ({why}): it is left out, and saving this file again drops it")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let unread_hashes: BTreeSet<&str> = unread.iter().map(|(h, _)| h.as_str()).collect();
|
||||
|
||||
@@ -187,6 +187,17 @@ pub(crate) fn import_with_networks(
|
||||
import_parts(v, None, images, networks, max_nodes, true)
|
||||
}
|
||||
|
||||
/// [`import_with_networks`] for imports other than `.fig` (no `.fig` source identities): the
|
||||
/// `.sketch` import.
|
||||
pub(crate) fn import_with_networks_plain(
|
||||
v: &Value,
|
||||
images: ImageResolver,
|
||||
networks: Vec<Option<VectorNetwork>>,
|
||||
max_nodes: usize,
|
||||
) -> Result<(Document, Vec<String>), FormatError> {
|
||||
import_parts(v, None, images, networks, max_nodes, false)
|
||||
}
|
||||
|
||||
fn import_parts(
|
||||
v: &Value,
|
||||
variables: Option<&Value>,
|
||||
|
||||
@@ -102,6 +102,15 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle {
|
||||
let weight = num(s, "fontWeight").map_or_else(|| t.weight(), |w| w.clamp(1.0, 1000.0) as u16);
|
||||
t.font_style = style_name(weight, flag(s, "italic").unwrap_or_else(|| t.italic()));
|
||||
}
|
||||
// Variable font axes, `{tag: value}` (FigCraft's own key; the Sketch importer writes it).
|
||||
if let Some(axes) = s.get("fontVariations").and_then(Value::as_object) {
|
||||
t.variations = axes
|
||||
.iter()
|
||||
.take(64)
|
||||
.filter(|(k, _)| k.len() == 4 && k.is_ascii())
|
||||
.filter_map(|(k, v)| Some((k.clone(), v.as_f64().filter(|x| x.is_finite())?.clamp(-1e5, 1e5))))
|
||||
.collect();
|
||||
}
|
||||
if let Some(size) = num(s, "fontSize").filter(|x| *x > 0.0) {
|
||||
t.font_size = size.clamp(0.5, 10_000.0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Synthetic `.sketch` files for the tests: the JSON of a document, its pages and `meta.json`,
|
||||
//! zipped the way Sketch writes them. Layers are built with small helpers and adjusted with
|
||||
//! [`with`].
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A `.sketch` zip: `document.json`, `meta.json` saying `app_version` / `version`, one
|
||||
/// `pages/<id>.json` per page and any other `files` (images, fonts).
|
||||
pub(crate) fn sketch_zip(app_version: &str, version: u64, document: Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let meta = json!({
|
||||
"app": "com.bohemiancoding.sketch3",
|
||||
"appVersion": app_version,
|
||||
"version": version,
|
||||
"compatibilityVersion": 99,
|
||||
"pagesAndArtboards": {},
|
||||
});
|
||||
raw_zip(&document, &meta, pages, files)
|
||||
}
|
||||
|
||||
/// A `.sketch` zip with the given `meta.json` as is.
|
||||
pub(crate) fn raw_zip(document: &Value, meta: &Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
z.start_file("document.json", opts).unwrap();
|
||||
z.write_all(document.to_string().as_bytes()).unwrap();
|
||||
z.start_file("meta.json", opts).unwrap();
|
||||
z.write_all(meta.to_string().as_bytes()).unwrap();
|
||||
for p in pages {
|
||||
let id = p["do_objectID"].as_str().unwrap_or("page");
|
||||
z.start_file(format!("pages/{id}.json"), opts).unwrap();
|
||||
z.write_all(p.to_string().as_bytes()).unwrap();
|
||||
}
|
||||
for (name, bytes) in files {
|
||||
z.start_file(*name, opts).unwrap();
|
||||
z.write_all(bytes).unwrap();
|
||||
}
|
||||
z.finish().unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `document.json` listing `pages` (ids) in order, with empty shared-style containers.
|
||||
pub(crate) fn document(pages: &[&str]) -> Value {
|
||||
json!({
|
||||
"_class": "document",
|
||||
"do_objectID": "DOC",
|
||||
"colorSpace": 1,
|
||||
"currentPageIndex": 0,
|
||||
"foreignLayerStyles": [],
|
||||
"foreignSymbols": [],
|
||||
"foreignTextStyles": [],
|
||||
"layerStyles": {"_class": "sharedStyleContainer", "objects": []},
|
||||
"layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": []},
|
||||
"layerSymbols": {"_class": "symbolContainer", "objects": []},
|
||||
"pages": pages.iter().map(|p| json!({
|
||||
"_class": "MSJSONFileReference",
|
||||
"_ref_class": "MSImmutablePage",
|
||||
"_ref": format!("pages/{p}"),
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn frame(x: f64, y: f64, w: f64, h: f64) -> Value {
|
||||
json!({"_class": "rect", "constrainProportions": false, "x": x, "y": y, "width": w, "height": h})
|
||||
}
|
||||
|
||||
fn layer(class: &str, id: &str, name: &str, x: f64, y: f64, w: f64, h: f64) -> Value {
|
||||
json!({
|
||||
"_class": class,
|
||||
"do_objectID": id,
|
||||
"name": name,
|
||||
"frame": frame(x, y, w, h),
|
||||
"isVisible": true,
|
||||
"isLocked": false,
|
||||
"rotation": 0,
|
||||
"isFlippedHorizontal": false,
|
||||
"isFlippedVertical": false,
|
||||
"resizingConstraint": 63,
|
||||
"style": {"_class": "style", "fills": [], "borders": [], "shadows": [], "innerShadows": []},
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn page(id: &str, name: &str, layers: Vec<Value>) -> Value {
|
||||
with(layer("page", id, name, 0.0, 0.0, 0.0, 0.0), "layers", Value::Array(layers))
|
||||
}
|
||||
|
||||
pub(crate) fn artboard(id: &str, name: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec<Value>) -> Value {
|
||||
with(layer("artboard", id, name, x, y, w, h), "layers", Value::Array(layers))
|
||||
}
|
||||
|
||||
pub(crate) fn group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec<Value>) -> Value {
|
||||
with(layer("group", id, id, x, y, w, h), "layers", Value::Array(layers))
|
||||
}
|
||||
|
||||
/// A rectangle with its four generated corner points.
|
||||
pub(crate) fn rect(id: &str, x: f64, y: f64, w: f64, h: f64) -> Value {
|
||||
let pts = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].map(|(px, py)| point(px, py));
|
||||
let mut l = with(layer("rectangle", id, id, x, y, w, h), "points", Value::Array(pts.to_vec()));
|
||||
l["isClosed"] = json!(true);
|
||||
l["fixedRadius"] = json!(0);
|
||||
l
|
||||
}
|
||||
|
||||
/// A straight curve point at `(x, y)` in 0–1 of the layer's frame.
|
||||
pub(crate) fn point(x: f64, y: f64) -> Value {
|
||||
let p = format!("{{{x}, {y}}}");
|
||||
json!({"_class": "curvePoint", "cornerRadius": 0, "curveFrom": p, "curveTo": p, "curveMode": 1, "hasCurveFrom": false, "hasCurveTo": false, "point": p})
|
||||
}
|
||||
|
||||
/// `l` with `key` set to `value`.
|
||||
pub(crate) fn with(mut l: Value, key: &str, value: Value) -> Value {
|
||||
if let Some(m) = l.as_object_mut() {
|
||||
m.insert(key.to_string(), value);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// A `shapePath` through `points` (`(x, y)` in 0–1 of its frame), straight segments.
|
||||
pub(crate) fn shape_path(id: &str, x: f64, y: f64, w: f64, h: f64, points: &[(f64, f64)], closed: bool) -> Value {
|
||||
let pts: Vec<Value> = points.iter().map(|(px, py)| point(*px, *py)).collect();
|
||||
let mut l = with(layer("shapePath", id, id, x, y, w, h), "points", Value::Array(pts));
|
||||
l["isClosed"] = json!(closed);
|
||||
l["booleanOperation"] = json!(-1);
|
||||
l
|
||||
}
|
||||
|
||||
/// A `shapeGroup` (Sketch 51 and older wrap every shape in one) holding `layers`.
|
||||
pub(crate) fn shape_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec<Value>) -> Value {
|
||||
with(layer("shapeGroup", id, id, x, y, w, h), "layers", Value::Array(layers))
|
||||
}
|
||||
|
||||
/// A Sketch colour.
|
||||
pub(crate) fn color(r: f64, g: f64, b: f64, a: f64) -> Value {
|
||||
json!({"_class": "color", "red": r, "green": g, "blue": b, "alpha": a})
|
||||
}
|
||||
|
||||
/// An enabled solid fill.
|
||||
pub(crate) fn solid_fill(r: f64, g: f64, b: f64, a: f64) -> Value {
|
||||
json!({"_class": "fill", "isEnabled": true, "fillType": 0, "color": color(r, g, b, a),
|
||||
"contextSettings": {"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1}})
|
||||
}
|
||||
|
||||
/// `l` with `style.<key>` set to `value`.
|
||||
pub(crate) fn styled(mut l: Value, key: &str, value: Value) -> Value {
|
||||
l["style"][key] = value;
|
||||
l
|
||||
}
|
||||
|
||||
/// A text layer showing `string`, one run per `(location, length, font, size, rgba)`; locations and
|
||||
/// lengths count UTF-16 code units, as Sketch does.
|
||||
pub(crate) fn text(id: &str, x: f64, y: f64, w: f64, h: f64, string: &str, runs: &[(usize, usize, &str, f64, [f64; 4])]) -> Value {
|
||||
let attrs: Vec<Value> = runs
|
||||
.iter()
|
||||
.map(|(loc, len, font, size, [r, g, b, a])| {
|
||||
json!({"_class": "stringAttribute", "location": loc, "length": len, "attributes": {
|
||||
"MSAttributedStringFontAttribute": {"_class": "fontDescriptor", "attributes": {"name": font, "size": size}},
|
||||
"MSAttributedStringColorAttribute": color(*r, *g, *b, *a),
|
||||
"paragraphStyle": {"_class": "paragraphStyle", "alignment": 0},
|
||||
}})
|
||||
})
|
||||
.collect();
|
||||
let mut l = layer("text", id, id, x, y, w, h);
|
||||
l["attributedString"] = json!({"_class": "attributedString", "string": string, "attributes": attrs});
|
||||
l["textBehaviour"] = json!(1);
|
||||
l
|
||||
}
|
||||
|
||||
/// A symbol master `symbol_id` holding `layers`.
|
||||
pub(crate) fn master(id: &str, symbol_id: &str, name: &str, w: f64, h: f64, layers: Vec<Value>) -> Value {
|
||||
let mut l = artboard(id, name, 0.0, 0.0, w, h, layers);
|
||||
l["_class"] = json!("symbolMaster");
|
||||
l["symbolID"] = json!(symbol_id);
|
||||
l
|
||||
}
|
||||
|
||||
/// An instance of symbol `symbol_id` with `overrides` (`overrideName`, value).
|
||||
pub(crate) fn instance(id: &str, symbol_id: &str, x: f64, y: f64, w: f64, h: f64, overrides: &[(&str, Value)]) -> Value {
|
||||
let mut l = layer("symbolInstance", id, id, x, y, w, h);
|
||||
l["symbolID"] = json!(symbol_id);
|
||||
l["scale"] = json!(1);
|
||||
l["overrideValues"] =
|
||||
Value::Array(overrides.iter().map(|(name, value)| json!({"_class": "overrideValue", "overrideName": name, "value": value})).collect());
|
||||
l
|
||||
}
|
||||
|
||||
/// A Sketch 2025+ frame (a group that behaves as a frame) holding `layers`.
|
||||
pub(crate) fn frame_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec<Value>) -> Value {
|
||||
let mut l = group(id, x, y, w, h, layers);
|
||||
l["groupBehavior"] = json!(1);
|
||||
l["clippingBehavior"] = json!(0);
|
||||
l
|
||||
}
|
||||
|
||||
/// `l` as a stack (Sketch 2025+): `direction` 0 row, 1 column.
|
||||
pub(crate) fn stack(mut l: Value, direction: i64, justify: i64, align: i64, gap: f64, padding: [f64; 4]) -> Value {
|
||||
l["groupLayout"] = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": direction, "justifyContent": justify, "alignItems": align,
|
||||
"allGuttersGap": gap, "crossAxisGutterGap": 0, "wrappingEnabled": false, "alignContent": 0, "stackingOrder": 0, "bordersAffectLayout": false});
|
||||
for (k, v) in ["leftPadding", "topPadding", "rightPadding", "bottomPadding"].iter().zip(padding) {
|
||||
l[*k] = json!(v);
|
||||
}
|
||||
l
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
//! Sketch 2025 and newer: frames and graphics (groups with a `groupBehavior`), stacks, corners in
|
||||
//! the style, pins and sizing, border sides.
|
||||
|
||||
use figcraft_doc::{Document, LayoutMode, Node, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fixture::*;
|
||||
use super::import;
|
||||
|
||||
fn open(layers: Vec<Value>) -> (Document, Vec<String>) {
|
||||
let z = sketch_zip("2026.3.1", 196, document(&["P"]), &[page("P", "Page", layers)], &[]);
|
||||
import(&z, "f").unwrap()
|
||||
}
|
||||
|
||||
fn node<'d>(doc: &'d Document, name: &str) -> &'d Node {
|
||||
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_graphics_and_clipping() {
|
||||
let mut clip_none = frame_group("Loose", 0.0, 0.0, 50.0, 50.0, vec![]);
|
||||
clip_none["clippingBehavior"] = json!(2);
|
||||
let mut graphic = frame_group("Icon", 100.0, 0.0, 24.0, 24.0, vec![]);
|
||||
graphic["groupBehavior"] = json!(2);
|
||||
let mut plain = group("Plain", 200.0, 0.0, 10.0, 10.0, vec![rect("Inside", 0.0, 0.0, 10.0, 10.0)]);
|
||||
plain["groupBehavior"] = json!(0);
|
||||
let mut filled = frame_group("Filled", 300.0, 0.0, 50.0, 50.0, vec![]);
|
||||
filled["style"]["fills"] = json!([solid_fill(1.0, 0.0, 0.0, 1.0)]);
|
||||
let (doc, _) = open(vec![frame_group("Screen", 0.0, 100.0, 390.0, 844.0, vec![]), clip_none, graphic, plain, filled]);
|
||||
assert!(matches!(node(&doc, "Screen").kind, NodeKind::Frame) && node(&doc, "Screen").clip);
|
||||
assert!(!node(&doc, "Loose").clip);
|
||||
assert!(matches!(node(&doc, "Icon").kind, NodeKind::Frame) && node(&doc, "Icon").clip);
|
||||
assert!(matches!(node(&doc, "Plain").kind, NodeKind::Group));
|
||||
assert_eq!(node(&doc, "Filled").fills.len(), 1);
|
||||
// Frames show what their style says: no white background of their own.
|
||||
assert!(node(&doc, "Screen").fills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_from_the_style() {
|
||||
let mut a = rect("Each", 0.0, 0.0, 40.0, 40.0);
|
||||
a["style"]["corners"] =
|
||||
json!({"_class": "MSImmutableStyleCorners", "radii": [1, 2, 3, 4], "style": 0, "smoothing": 0.6, "prefersConcentric": false});
|
||||
let mut b = rect("Smooth", 50.0, 0.0, 40.0, 40.0);
|
||||
b["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [12], "style": 1, "smoothing": 0.6, "prefersConcentric": false});
|
||||
let mut f = frame_group("Card", 100.0, 0.0, 100.0, 100.0, vec![]);
|
||||
f["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [16, 16, 16, 16], "style": 0, "smoothing": 0.6});
|
||||
let (doc, _) = open(vec![a, b, f]);
|
||||
assert_eq!(node(&doc, "Each").corners.radii, [1.0, 2.0, 3.0, 4.0]);
|
||||
assert_eq!(node(&doc, "Smooth").corners.radii, [12.0; 4]);
|
||||
assert!((node(&doc, "Smooth").corners.smoothing - 0.6).abs() < 1e-9);
|
||||
assert_eq!(node(&doc, "Each").corners.smoothing, 0.0);
|
||||
assert_eq!(node(&doc, "Card").corners.radii, [16.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacks_become_auto_layout() {
|
||||
// A row: Sketch lists the first item last (on top); justify evenly, centred, gap 8.
|
||||
let a = rect("First", 0.0, 0.0, 20.0, 20.0);
|
||||
let b = with(rect("Second", 0.0, 0.0, 30.0, 10.0), "horizontalSizing", json!(2));
|
||||
let mut free = rect("Free", 0.0, 0.0, 5.0, 5.0);
|
||||
free["flexItem"] = json!({"_class": "MSImmutableFlexItem", "ignoreLayout": true, "alignSelf": 5, "preserveSpaceWhenHidden": false});
|
||||
let mut row = stack(frame_group("Row", 0.0, 0.0, 200.0, 40.0, vec![free, b, a]), 0, 5, 1, 8.0, [4.0, 6.0, 10.0, 2.0]);
|
||||
row["horizontalSizing"] = json!(0);
|
||||
row["verticalSizing"] = json!(1);
|
||||
let col = stack(frame_group("Column", 0.0, 100.0, 100.0, 100.0, vec![rect("One", 0.0, 0.0, 10.0, 10.0)]), 1, 3, 3, 0.0, [0.0; 4]);
|
||||
let (doc, _) = open(vec![row, col]);
|
||||
let r = node(&doc, "Row");
|
||||
let l = r.layout.as_ref().expect("auto layout");
|
||||
assert_eq!(l.mode, LayoutMode::Horizontal);
|
||||
assert_eq!(l.gap, 8.0);
|
||||
assert_eq!(l.padding, [6.0, 10.0, 2.0, 4.0], "top, right, bottom, left");
|
||||
assert!(l.reverse_z, "the first item is on top, as in Sketch");
|
||||
let text = format!("{l:?}");
|
||||
assert!(text.contains("SpaceEvenly") || text.contains("Evenly"), "{text}");
|
||||
let order: Vec<&str> = r.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
|
||||
assert_eq!(order, ["First", "Second", "Free"], "flow order is the reverse of Sketch's list");
|
||||
assert!(node(&doc, "Free").child_layout.absolute);
|
||||
assert_eq!(node(&doc, "Second").child_layout.sizing_h, figcraft_doc::Sizing::Fill);
|
||||
assert_eq!(r.child_layout.sizing_v, figcraft_doc::Sizing::Hug);
|
||||
let c = node(&doc, "Column").layout.as_ref().unwrap();
|
||||
assert_eq!(c.mode, LayoutMode::Vertical);
|
||||
assert!(format!("{c:?}").contains("SpaceBetween"), "{c:?}");
|
||||
}
|
||||
|
||||
/// A frame resized as an instance's layer follows its pins and sizing (Sketch 2025+ keep them in
|
||||
/// `horizontalPins`/`horizontalSizing`, `resizingConstraint` stays 63).
|
||||
#[test]
|
||||
fn pins_and_sizing_resize_instances() {
|
||||
let mut r = rect("R", 20.0, 30.0, 100.0, 40.0);
|
||||
r["horizontalPins"] = json!(1); // left
|
||||
r["horizontalSizing"] = json!(0); // fixed
|
||||
r["verticalPins"] = json!(0);
|
||||
r["verticalSizing"] = json!(3); // relative
|
||||
let mut m = master("M", "S", "Box", 200.0, 100.0, vec![r]);
|
||||
m["groupBehavior"] = json!(1);
|
||||
let (doc, _) = {
|
||||
let z = sketch_zip(
|
||||
"2026.3.1",
|
||||
196,
|
||||
document(&["P", "S"]),
|
||||
&[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 320.0, 60.0, &[])]), page("S", "Symbols", vec![m])],
|
||||
&[],
|
||||
);
|
||||
import(&z, "f").unwrap()
|
||||
};
|
||||
let inst = doc.nodes().find(|n| n.name == "I").unwrap();
|
||||
let r = doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "R").unwrap();
|
||||
let b = doc.world_bounds(r.id).unwrap();
|
||||
assert!((b.x0 - 20.0).abs() < 0.5 && (b.width() - 100.0).abs() < 0.5, "{b:?}");
|
||||
assert!((b.y0 - 18.0).abs() < 0.5 && (b.height() - 24.0).abs() < 0.5, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_scale_their_layers() {
|
||||
let mut r = rect("R", 20.0, 30.0, 100.0, 40.0);
|
||||
r["horizontalPins"] = json!(5);
|
||||
r["horizontalSizing"] = json!(0);
|
||||
let mut m = master("M", "S", "Glyph", 200.0, 100.0, vec![r]);
|
||||
m["groupBehavior"] = json!(2);
|
||||
let z = sketch_zip(
|
||||
"2026.3.1",
|
||||
196,
|
||||
document(&["P", "S"]),
|
||||
&[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 400.0, 200.0, &[])]), page("S", "Symbols", vec![m])],
|
||||
&[],
|
||||
);
|
||||
let (doc, _) = import(&z, "f").unwrap();
|
||||
let inst = doc.nodes().find(|n| n.name == "I").unwrap();
|
||||
let r = doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "R").unwrap();
|
||||
let b = doc.world_bounds(r.id).unwrap();
|
||||
assert!((b.x0 - 40.0).abs() < 0.5 && (b.width() - 200.0).abs() < 0.5, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_sides() {
|
||||
let r = styled(
|
||||
rect("Sided", 0.0, 0.0, 40.0, 40.0),
|
||||
"borders",
|
||||
json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": 1, "thickness": 8,
|
||||
"sides": {"_class": "MSImmutableStyleBorderSides", "leftInset": 7, "rightInset": 7, "topInset": 0, "bottomInset": 5}}]),
|
||||
);
|
||||
// A side is as thick as the border less its inset.
|
||||
let (doc, _) = open(vec![r]);
|
||||
assert_eq!(node(&doc, "Sided").stroke.sides, Some([8.0, 1.0, 3.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatches_bind_solid_fills_only() {
|
||||
let mut d = document(&["P"]);
|
||||
d["sharedSwatches"] = json!({"_class": "swatchContainer", "objects": [{"_class": "swatch", "do_objectID": "SW", "name": "Brand", "value": color(0.0, 0.0, 1.0, 1.0)}]});
|
||||
let mut g = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 1.0, 1.0),
|
||||
"gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [
|
||||
{"_class": "gradientStop", "position": 0, "color": color(1.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 1.0, 1.0)}]}});
|
||||
g["color"]["swatchID"] = json!("SW");
|
||||
let z = sketch_zip("2026.3.1", 196, d, &[page("P", "Page", vec![styled(rect("G", 0.0, 0.0, 10.0, 10.0), "fills", json!([g]))])], &[]);
|
||||
let (_, warnings) = import(&z, "f").unwrap();
|
||||
assert!(!warnings.iter().any(|w| w.contains("not supported")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// Sketch 2025+ masters keep their background, corners and stack in their style and layout, and
|
||||
/// their instances show the same.
|
||||
#[test]
|
||||
fn masters_and_instances_keep_frame_styles_and_stacks() {
|
||||
let mut m = master("M", "S", "Button", 120.0, 44.0, vec![rect("Icon", 0.0, 0.0, 20.0, 20.0), rect("Label", 0.0, 0.0, 60.0, 20.0)]);
|
||||
m["groupBehavior"] = json!(1);
|
||||
m["clippingBehavior"] = json!(2);
|
||||
m["style"]["fills"] = json!([solid_fill(0.0, 0.0, 1.0, 1.0)]);
|
||||
m["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [8], "style": 1, "smoothing": 0.6});
|
||||
let m = stack(m, 0, 1, 1, 10.0, [16.0, 12.0, 16.0, 12.0]);
|
||||
let z = sketch_zip(
|
||||
"2026.3.1",
|
||||
196,
|
||||
document(&["P", "S"]),
|
||||
&[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 120.0, 44.0, &[])]), page("S", "Symbols", vec![m])],
|
||||
&[],
|
||||
);
|
||||
let (doc, _) = import(&z, "f").unwrap();
|
||||
let main = doc.nodes().find(|n| matches!(n.kind, NodeKind::Component { .. })).unwrap();
|
||||
let inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap();
|
||||
for n in [main, inst] {
|
||||
assert_eq!(n.fills.len(), 1, "{}", n.name);
|
||||
assert_eq!(n.corners.radii, [8.0; 4], "{}", n.name);
|
||||
assert!(n.layout.is_some(), "{}", n.name);
|
||||
assert!(!n.clip, "{}", n.name);
|
||||
let order: Vec<&str> = n.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
|
||||
assert_eq!(order, ["Label", "Icon"], "{}", n.name);
|
||||
}
|
||||
}
|
||||
|
||||
fn solid_of(n: &Node) -> Option<[f32; 3]> {
|
||||
n.fills.iter().find_map(|p| match &p.kind {
|
||||
figcraft_doc::color::PaintKind::Solid { color } => Some([color.r, color.g, color.b]),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn open_symbols(layers: Vec<Value>, masters: Vec<Value>) -> Document {
|
||||
let z = sketch_zip("2026.3.1", 196, document(&["P", "S"]), &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]);
|
||||
import(&z, "f").unwrap().0
|
||||
}
|
||||
|
||||
fn in_instance<'d>(doc: &'d Document, inst: &str, name: &str) -> &'d Node {
|
||||
let i = doc.nodes().find(|n| n.name == inst && matches!(n.kind, NodeKind::Instance { .. })).unwrap_or_else(|| panic!("no {inst}"));
|
||||
doc.subtree(i.id).into_iter().filter_map(|id| doc.node(id)).find(|n| n.name == name).unwrap_or_else(|| panic!("no {name} in {inst}"))
|
||||
}
|
||||
|
||||
/// A graphic's fill tints its layers (it's no background); an instance's fill, or a `fillColor`
|
||||
/// override, tints the instance's layers.
|
||||
#[test]
|
||||
fn fills_of_graphics_and_instances_are_tints() {
|
||||
let glyph = styled(rect("Glyph", 0.0, 0.0, 24.0, 24.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)]));
|
||||
let mut icon = master("MI", "SI", "Icon", 24.0, 24.0, vec![glyph]);
|
||||
icon["groupBehavior"] = json!(2);
|
||||
let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0);
|
||||
tint["layeringType"] = json!(1);
|
||||
icon["style"]["fills"] = json!([tint]);
|
||||
let nested = instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[]);
|
||||
let mut button = master("MB", "SB", "Button", 100.0, 40.0, vec![nested]);
|
||||
button["groupBehavior"] = json!(1);
|
||||
let own = instance("Own", "SI", 0.0, 0.0, 24.0, 24.0, &[("Own_fillColor", color(0.0, 0.0, 1.0, 1.0))]);
|
||||
let via = instance("Via", "SB", 0.0, 100.0, 100.0, 40.0, &[("N_fillColor", color(0.0, 1.0, 0.0, 1.0))]);
|
||||
let doc = open_symbols(vec![own, via], vec![icon, button]);
|
||||
let main = doc.nodes().find(|n| n.name == "Icon" && matches!(n.kind, NodeKind::Component { .. })).unwrap();
|
||||
assert!(main.fills.is_empty(), "a graphic's fill isn't its background");
|
||||
let in_main = doc.subtree(main.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "Glyph").unwrap();
|
||||
assert_eq!(solid_of(in_main), Some([1.0, 0.0, 0.0]));
|
||||
assert_eq!(solid_of(in_instance(&doc, "Own", "Glyph")), Some([0.0, 0.0, 1.0]));
|
||||
assert!(doc.nodes().find(|n| n.name == "Own").unwrap().fills.is_empty());
|
||||
assert_eq!(solid_of(in_instance(&doc, "Via", "Glyph")), Some([0.0, 1.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_color_overrides() {
|
||||
let label = text("Label", 0.0, 0.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]);
|
||||
let m = master("M", "S", "Button", 100.0, 40.0, vec![label]);
|
||||
let doc = open_symbols(vec![instance("I", "S", 0.0, 0.0, 100.0, 40.0, &[("Label_textColor", color(1.0, 0.0, 0.0, 1.0))])], vec![m]);
|
||||
let n = in_instance(&doc, "I", "Label");
|
||||
let red = |p: &figcraft_doc::color::Paint| matches!(p.kind, figcraft_doc::color::PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0);
|
||||
let NodeKind::Text(td) = &n.kind else { panic!("not text") };
|
||||
assert!(n.fills.iter().any(red) || td.style.fills.as_ref().is_some_and(|f| f.iter().any(red)), "{:?} {:?}", n.fills, td.style.fills);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blurs_list() {
|
||||
let blur = |ty: i64, r: f64| json!({"_class": "blur", "isEnabled": true, "type": ty, "radius": r, "center": "{0.5, 0.5}", "motionAngle": 25});
|
||||
let g = styled(rect("Glow", 0.0, 0.0, 40.0, 40.0), "blurs", json!([blur(0, 90.0)]));
|
||||
let m = styled(rect("Comet", 50.0, 0.0, 40.0, 4.0), "blurs", json!([blur(1, 6.0)]));
|
||||
let b = styled(rect("Glass", 100.0, 0.0, 40.0, 40.0), "blurs", json!([blur(3, 10.0)]));
|
||||
let (doc, warnings) = open(vec![g, m, b]);
|
||||
assert!(matches!(node(&doc, "Glow").effects[0].kind, figcraft_doc::EffectKind::LayerBlur(_)));
|
||||
assert!(matches!(node(&doc, "Glass").effects[0].kind, figcraft_doc::EffectKind::BackgroundBlur(_)));
|
||||
assert!(matches!(node(&doc, "Comet").effects[0].kind, figcraft_doc::EffectKind::LayerBlur(_)), "motion blur drawn as a layer blur");
|
||||
assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_sizing_2025() {
|
||||
let mut fixed = text("Fixed", 0.0, 0.0, 100.0, 40.0, "Wraps here", &[(0, 10, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]);
|
||||
fixed.as_object_mut().unwrap().remove("textBehaviour");
|
||||
fixed["horizontalSizing"] = json!(0);
|
||||
fixed["verticalSizing"] = json!(1);
|
||||
let mut fit = text("Fit", 0.0, 50.0, 100.0, 20.0, "Grows", &[(0, 5, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]);
|
||||
fit.as_object_mut().unwrap().remove("textBehaviour");
|
||||
fit["horizontalSizing"] = json!(1);
|
||||
fit["verticalSizing"] = json!(1);
|
||||
let (doc, _) = open(vec![fixed, fit]);
|
||||
let auto = |n: &str| match &node(&doc, n).kind {
|
||||
NodeKind::Text(td) => td.auto_resize,
|
||||
_ => panic!("not text"),
|
||||
};
|
||||
assert_eq!(auto("Fixed"), figcraft_doc::TextAutoResize::Height);
|
||||
assert_eq!(auto("Fit"), figcraft_doc::TextAutoResize::WidthAndHeight);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacks_are_no_smart_layout_note() {
|
||||
let (_, warnings) = open(vec![stack(frame_group("Row", 0.0, 0.0, 100.0, 40.0, vec![rect("A", 0.0, 0.0, 10.0, 10.0)]), 0, 0, 0, 0.0, [0.0; 4])]);
|
||||
assert!(!warnings.iter().any(|w| w.contains("Smart Layout")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// Sketch 2025+: a fill with `layeringType` 1 is a tint (of a frame or master too), 0 a background.
|
||||
#[test]
|
||||
fn layering_type_decides_tint_or_background() {
|
||||
let dot = styled(rect("Dot", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)]));
|
||||
let mut tint = solid_fill(0.0, 0.0, 1.0, 1.0);
|
||||
tint["layeringType"] = json!(1);
|
||||
let mut bar = master("MB", "SB", "Bar", 100.0, 20.0, vec![dot]);
|
||||
bar["groupBehavior"] = json!(1);
|
||||
bar["style"]["fills"] = json!([tint]);
|
||||
let mut bg = solid_fill(1.0, 0.0, 0.0, 1.0);
|
||||
bg["layeringType"] = json!(0);
|
||||
let mut btn = master("MC", "SC", "Btn", 100.0, 20.0, vec![]);
|
||||
btn["groupBehavior"] = json!(1);
|
||||
btn["style"]["fills"] = json!([bg]);
|
||||
let doc = open_symbols(vec![instance("B", "SB", 0.0, 0.0, 100.0, 20.0, &[]), instance("C", "SC", 0.0, 50.0, 100.0, 20.0, &[])], vec![bar, btn]);
|
||||
let b = doc.nodes().find(|n| n.name == "B").unwrap();
|
||||
assert!(b.fills.is_empty(), "a tint isn't a background");
|
||||
assert_eq!(solid_of(in_instance(&doc, "B", "Dot")), Some([0.0, 0.0, 1.0]));
|
||||
assert_eq!(solid_of(doc.nodes().find(|n| n.name == "C").unwrap()), Some([1.0, 0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_shadows_in_the_shadow_list() {
|
||||
let mut sh =
|
||||
json!({"_class": "shadow", "isEnabled": true, "blurRadius": 4, "offsetX": 0, "offsetY": 2, "spread": 0, "color": color(0.0, 0.0, 0.0, 0.5)});
|
||||
sh["isInnerShadow"] = json!(true);
|
||||
let (doc, _) = open(vec![styled(rect("Inset", 0.0, 0.0, 40.0, 40.0), "shadows", json!([sh]))]);
|
||||
assert!(matches!(node(&doc, "Inset").effects[0].kind, figcraft_doc::EffectKind::InnerShadow(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_overrides() {
|
||||
let m = master("M", "S", "Input", 100.0, 40.0, vec![rect("Label", 0.0, 0.0, 50.0, 10.0)]);
|
||||
let doc = open_symbols(vec![instance("I", "S", 0.0, 0.0, 100.0, 40.0, &[("Label_isVisible", json!("0"))])], vec![m]);
|
||||
assert!(!in_instance(&doc, "I", "Label").visible);
|
||||
}
|
||||
|
||||
/// An instance that fits its content hugs it even when its master has a fixed size.
|
||||
#[test]
|
||||
fn instance_sizing_is_its_own() {
|
||||
let m = stack(master("M", "S", "Chip", 60.0, 24.0, vec![rect("A", 0.0, 0.0, 20.0, 20.0)]), 0, 0, 1, 0.0, [0.0; 4]);
|
||||
let mut i = instance("I", "S", 0.0, 0.0, 60.0, 24.0, &[]);
|
||||
i["horizontalSizing"] = json!(1);
|
||||
let doc = open_symbols(vec![i], vec![m]);
|
||||
let n = doc.nodes().find(|n| n.name == "I").unwrap();
|
||||
assert_eq!(n.layout.as_ref().map(|_| format!("{:?}", n.child_layout.sizing_h)), Some("Hug".to_string()));
|
||||
}
|
||||
|
||||
/// A tint keeps each layer's own opacity.
|
||||
#[test]
|
||||
fn tints_keep_opacity() {
|
||||
let faint = styled(rect("Faint", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 0.25)]));
|
||||
let mut g = group("Tinted", 0.0, 0.0, 10.0, 10.0, vec![faint]);
|
||||
let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0);
|
||||
tint["layeringType"] = json!(1);
|
||||
g["groupBehavior"] = json!(0);
|
||||
g["style"] = json!({"_class": "style", "fills": [tint]});
|
||||
let (doc, _) = open(vec![g]);
|
||||
let f = &node(&doc, "Faint").fills[0];
|
||||
let figcraft_doc::color::PaintKind::Solid { color } = &f.kind else { panic!("not solid") };
|
||||
assert_eq!((color.r, color.g), (1.0, 0.0));
|
||||
assert!((color.a * f.opacity - 0.25).abs() < 1e-3, "{color:?} {}", f.opacity);
|
||||
assert!(node(&doc, "Tinted").fills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_and_progressive_blurs() {
|
||||
let glass = json!({"_class": "blur", "isEnabled": true, "type": 4, "radius": 10, "distortion": 0.6, "depth": 0.7, "center": "{0.5, 0.5}"});
|
||||
let prog = json!({"_class": "blur", "isEnabled": true, "type": 3, "radius": 24, "isProgressive": true, "center": "{0.5, 0.5}",
|
||||
"gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [
|
||||
{"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}});
|
||||
let (doc, warnings) = open(vec![
|
||||
styled(rect("Glass", 0.0, 0.0, 40.0, 40.0), "blurs", json!([glass])),
|
||||
styled(rect("Prog", 50.0, 0.0, 40.0, 40.0), "blurs", json!([prog])),
|
||||
]);
|
||||
assert!(matches!(node(&doc, "Glass").effects[0].kind, figcraft_doc::EffectKind::Glass(_)), "{warnings:?}");
|
||||
let figcraft_doc::EffectKind::BackgroundBlur(b) = &node(&doc, "Prog").effects[0].kind else { panic!("not a background blur") };
|
||||
assert_eq!(b.blur_type, figcraft_doc::BlurType::Progressive);
|
||||
assert_eq!((b.start_radius, b.radius), (24.0, 0.0));
|
||||
}
|
||||
|
||||
/// Angled (2), inside-square (3) and inside-arc (4) corners make the rectangle a path of that outline.
|
||||
#[test]
|
||||
fn cut_corners_become_paths() {
|
||||
let cut = |name: &str, style: i64| {
|
||||
let mut r = rect(name, 0.0, 0.0, 100.0, 80.0);
|
||||
r["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [10], "style": style, "smoothing": 0.6});
|
||||
r
|
||||
};
|
||||
let (doc, _) = open(vec![cut("Angled", 2), cut("Square", 3), cut("Arc", 4)]);
|
||||
let net = |n: &str| match &node(&doc, n).kind {
|
||||
NodeKind::Vector { network } => network.clone(),
|
||||
other => panic!("{n}: {other:?}"),
|
||||
};
|
||||
let a = net("Angled");
|
||||
assert_eq!(a.vertices.len(), 8);
|
||||
assert!(a.vertices.iter().any(|v| (v.x, v.y) == (0.0, 10.0)) && a.vertices.iter().any(|v| (v.x, v.y) == (10.0, 0.0)));
|
||||
let s = net("Square");
|
||||
assert_eq!(s.vertices.len(), 12);
|
||||
assert!(s.vertices.iter().any(|v| (v.x, v.y) == (10.0, 10.0)) && s.vertices.iter().any(|v| (v.x, v.y) == (90.0, 70.0)));
|
||||
let arc = net("Arc");
|
||||
assert_eq!(arc.vertices.len(), 8);
|
||||
assert!(arc.segments.iter().any(|g| g.ta != (0.0, 0.0)), "the corners are arcs");
|
||||
}
|
||||
|
||||
/// Tints and colour overrides that aren't colours, and fills that aren't objects, are junk: left
|
||||
/// alone, never a panic.
|
||||
#[test]
|
||||
fn junk_tints_and_colour_overrides() {
|
||||
let child = styled(rect("Child", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)]));
|
||||
let mut g = group("G", 0.0, 0.0, 10.0, 10.0, vec![child.clone()]);
|
||||
g["style"] = json!({"_class": "style", "fills": [{"_class": "fill", "isEnabled": true, "fillType": 0, "layeringType": 1, "color": 5}]});
|
||||
let (_, _) = open(vec![g]);
|
||||
let mut odd = rect("L", 0.0, 0.0, 10.0, 10.0);
|
||||
odd["style"]["fills"] = json!([true]);
|
||||
let m = master("M", "S", "Odd", 10.0, 10.0, vec![odd]);
|
||||
let _ = open_symbols(vec![instance("I", "S", 0.0, 0.0, 10.0, 10.0, &[("L_fillColor", color(1.0, 0.0, 0.0, 1.0))])], vec![m]);
|
||||
}
|
||||
|
||||
/// Tinting copies a group's layers: the copies count against the import's work budget, so nested
|
||||
/// tinted groups of large layers can't make the import copy without end; past the budget, tints
|
||||
/// are skipped with a note.
|
||||
#[test]
|
||||
fn nested_tints_are_budgeted() {
|
||||
// Text that doesn't compress, so the page stays within the archive's size checks.
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let big: String = (0..1_000_000)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
char::from(b'a' + (seed % 26) as u8)
|
||||
})
|
||||
.collect();
|
||||
let mut l = styled(rect("Leaf", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)]));
|
||||
for i in 0..35 {
|
||||
let mut pad = rect(&big, 0.0, 0.0, 1.0, 1.0);
|
||||
pad["do_objectID"] = json!(format!("pad{i}"));
|
||||
let mut g = group(&format!("G{i}"), 0.0, 0.0, 10.0, 10.0, vec![l, pad]);
|
||||
let mut t = solid_fill(1.0, 0.0, 0.0, 1.0);
|
||||
t["layeringType"] = json!(1);
|
||||
g["style"] = json!({"_class": "style", "fills": [t]});
|
||||
l = g;
|
||||
}
|
||||
let (_, warnings) = open(vec![l]);
|
||||
assert!(warnings.iter().any(|w| w.contains("tint")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A faded layer in a stack keeps its place in the stack: the group the fade makes takes the
|
||||
/// layer's id, and with it its sizing.
|
||||
#[test]
|
||||
fn faded_stack_items_keep_their_sizing() {
|
||||
let fade = json!({"_class": "graphicsContextSettings", "opacity": 1, "isProgressive": true,
|
||||
"gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [
|
||||
{"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}});
|
||||
let mut item = styled(rect("Item", 0.0, 0.0, 50.0, 20.0), "contextSettings", fade);
|
||||
item["horizontalSizing"] = json!(2);
|
||||
let (doc, _) = open(vec![stack(frame_group("Row", 0.0, 0.0, 200.0, 20.0, vec![item]), 0, 0, 0, 0.0, [0.0; 4])]);
|
||||
let row = node(&doc, "Row");
|
||||
let wrap = doc.node(row.children[0]).unwrap();
|
||||
assert_eq!(format!("{:?}", wrap.child_layout.sizing_h), "Fill", "{:?}", wrap.child_layout);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Import of `.sketch` files saved by Sketch 48 or newer (best effort), written from the public
|
||||
//! description of the format only.
|
||||
//!
|
||||
//! A `.sketch` file is a zip archive holding `document.json` (the document: its pages in order,
|
||||
//! shared styles, swatches and library symbols copied into it), `meta.json` (the app version and
|
||||
//! format version that saved it), one `pages/<id>.json` per page (the layer tree), `images/` and,
|
||||
//! in newer files, `fonts/`. Every object carries its kind in `_class`. [`tree`] maps the layers
|
||||
//! onto the REST API file JSON shape and the REST importer builds the document, so this import
|
||||
//! shares its mapping, budgets and import report with `.fig` and file JSON imports. Files older
|
||||
//! than Sketch 48 (a SQLite database before 43, text as binary property lists until 47) are
|
||||
//! refused with a message saying how to convert them. Anything not understood is skipped with a
|
||||
//! warning; malformed input never panics.
|
||||
|
||||
#[cfg(test)]
|
||||
mod fixture;
|
||||
#[cfg(test)]
|
||||
mod frames_tests;
|
||||
mod path;
|
||||
#[cfg(test)]
|
||||
mod path_tests;
|
||||
mod proto;
|
||||
#[cfg(test)]
|
||||
mod proto_tests;
|
||||
mod shared;
|
||||
#[cfg(test)]
|
||||
mod shared_tests;
|
||||
mod stack;
|
||||
mod style;
|
||||
#[cfg(test)]
|
||||
mod style_tests;
|
||||
mod symbols;
|
||||
#[cfg(test)]
|
||||
mod symbols_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod text;
|
||||
#[cfg(test)]
|
||||
mod text_tests;
|
||||
mod tree;
|
||||
#[cfg(test)]
|
||||
mod tree_tests;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use figcraft_doc::Document;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FormatError;
|
||||
|
||||
/// The oldest Sketch whose files open: from 48 on, text is plain JSON too.
|
||||
pub const MIN_SKETCH: u32 = 48;
|
||||
/// The newest `meta.json` format version checked against real files (Sketch 83); newer files
|
||||
/// open with a warning.
|
||||
pub const NEWEST_TESTED_VERSION: u64 = 139;
|
||||
/// What opening a file saved by an older Sketch says.
|
||||
pub const TOO_OLD: &str = "saved by a Sketch older than 48; open it in a newer Sketch and save it again";
|
||||
/// Largest JSON member read (a real page of a few thousand layers takes 33 MB).
|
||||
const MAX_JSON_MEMBER: u64 = 512 * 1024 * 1024;
|
||||
/// Most bytes JSON members may unpack to, per compressed byte (real files pack 5–20 times; a
|
||||
/// zero-filled array a thousand times), with a floor for small members.
|
||||
const MAX_JSON_RATIO: u64 = 100;
|
||||
const MIN_JSON_ALLOWANCE: u64 = 16 * 1024 * 1024;
|
||||
/// Most JSON read from one file in all.
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024;
|
||||
/// Largest image member read.
|
||||
const MAX_IMAGE: u64 = 256 * 1024 * 1024;
|
||||
/// Most pages read from one file.
|
||||
const MAX_PAGES: usize = 1000;
|
||||
|
||||
/// Whether `bytes` look like a `.sketch` file: a zip holding `document.json` and `meta.json`, or
|
||||
/// the SQLite database Sketch wrote before version 43 (so that it gets the "too old" message).
|
||||
pub fn is_sketch(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"SQLite format 3\0")
|
||||
|| (bytes.starts_with(b"PK")
|
||||
&& zip::ZipArchive::new(std::io::Cursor::new(bytes)).is_ok_and(|z| {
|
||||
let names: BTreeSet<&str> = z.file_names().collect();
|
||||
names.contains("document.json") && names.contains("meta.json")
|
||||
}))
|
||||
}
|
||||
|
||||
/// Finds the installed face a PostScript name belongs to: its family and style.
|
||||
pub type FontLookup = Box<dyn Fn(&str) -> Option<(String, String)>>;
|
||||
|
||||
/// Import `bytes` when they are a `.sketch` file; `None` when they are something else.
|
||||
pub fn try_import(bytes: &[u8], name: &str) -> Option<Result<(Document, Vec<String>), FormatError>> {
|
||||
try_import_with_fonts(bytes, name, None)
|
||||
}
|
||||
|
||||
/// [`try_import`] with `fonts` finding the installed faces the file's fonts (PostScript names)
|
||||
/// belong to; families of fonts it doesn't find are read from their names.
|
||||
pub fn try_import_with_fonts(bytes: &[u8], name: &str, fonts: Option<FontLookup>) -> Option<Result<(Document, Vec<String>), FormatError>> {
|
||||
is_sketch(bytes).then(|| import_with_fonts(bytes, name, fonts))
|
||||
}
|
||||
|
||||
/// Build a document from a `.sketch` file. Returns it with the warnings about what was skipped or
|
||||
/// approximated. `name`: the document name.
|
||||
pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), FormatError> {
|
||||
import_with_fonts(bytes, name, None)
|
||||
}
|
||||
|
||||
/// [`import`] with a font lookup (see [`try_import_with_fonts`]).
|
||||
pub fn import_with_fonts(bytes: &[u8], name: &str, fonts: Option<FontLookup>) -> Result<(Document, Vec<String>), FormatError> {
|
||||
import_parts(bytes, name, MAX_JSON_MEMBER, crate::fig::layer_budget(bytes.len()), fonts)
|
||||
}
|
||||
|
||||
/// [`import`] reading pages of at most `max_member` bytes of JSON each.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result<(Document, Vec<String>), FormatError> {
|
||||
import_parts(bytes, name, max_member, crate::fig::layer_budget(bytes.len()), None)
|
||||
}
|
||||
|
||||
/// [`import`] with at most `max_nodes` layers.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn import_within_nodes(bytes: &[u8], name: &str, max_nodes: usize) -> Result<(Document, Vec<String>), FormatError> {
|
||||
import_parts(bytes, name, MAX_JSON_MEMBER, max_nodes, None)
|
||||
}
|
||||
|
||||
fn import_parts(
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
max_member: u64,
|
||||
max_nodes: usize,
|
||||
fonts: Option<FontLookup>,
|
||||
) -> Result<(Document, Vec<String>), FormatError> {
|
||||
if bytes.starts_with(b"SQLite format 3\0") {
|
||||
return Err(FormatError::Io(TOO_OLD.into()));
|
||||
}
|
||||
let archive = Archive::open(bytes, max_member)?;
|
||||
let map = tree::to_rest(&archive, name, max_nodes, fonts);
|
||||
if map.too_old {
|
||||
return Err(FormatError::Io(TOO_OLD.into()));
|
||||
}
|
||||
let mut warnings = archive.warnings.clone();
|
||||
warnings.extend(map.warnings);
|
||||
if let Some(note) = embedded_fonts(&archive.document) {
|
||||
warnings.push(note);
|
||||
}
|
||||
let images = |r: &str| archive.image(r);
|
||||
let (mut doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?;
|
||||
if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) {
|
||||
doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3;
|
||||
}
|
||||
warnings.extend(more);
|
||||
Ok((doc, warnings))
|
||||
}
|
||||
|
||||
/// The note about fonts the file carries (Sketch 7x and newer can embed them, as `fontReferences`
|
||||
/// with `fontData`): FigCraft doesn't use fonts from inside documents, so their text shows in
|
||||
/// installed fonts. To do once `.figcraft` documents can carry fonts (loading them only for the
|
||||
/// session would lose them on the first save and reopen).
|
||||
fn embedded_fonts(document: &Value) -> Option<String> {
|
||||
let refs = document.get("fontReferences").and_then(Value::as_array)?;
|
||||
// A reference without font data only names a font the document uses.
|
||||
let families: BTreeSet<String> = refs
|
||||
.iter()
|
||||
.filter(|r| r.get("fontData").is_some())
|
||||
.filter_map(|r| r.get("fontFamilyName").and_then(Value::as_str))
|
||||
.map(|f| f.chars().take(64).collect())
|
||||
.take(20)
|
||||
.collect();
|
||||
if families.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let list: Vec<&str> = families.iter().map(String::as_str).collect();
|
||||
Some(format!("the file has embedded fonts FigCraft doesn't use ({}); install them to see the text as designed", list.join(", ")))
|
||||
}
|
||||
|
||||
fn corrupt(msg: impl Into<String>) -> FormatError {
|
||||
FormatError::Corrupt(format!("Sketch file: {}", msg.into()))
|
||||
}
|
||||
|
||||
/// The parts of a `.sketch` archive: the document, the pages in document order and the images.
|
||||
pub(crate) struct Archive<'b> {
|
||||
pub document: Value,
|
||||
/// `meta.json`'s format version (0 when missing).
|
||||
pub version: u64,
|
||||
/// `(id, page JSON)` in the document's order.
|
||||
pub pages: Vec<(String, Value)>,
|
||||
/// Problems met while reading (pages skipped).
|
||||
pub warnings: Vec<String>,
|
||||
zip: zip::ZipArchive<std::io::Cursor<&'b [u8]>>,
|
||||
}
|
||||
|
||||
impl<'b> Archive<'b> {
|
||||
fn open(bytes: &'b [u8], max_member: u64) -> Result<Self, FormatError> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?;
|
||||
let mut total = 0u64;
|
||||
let meta = json_member(&mut z, "meta.json", MAX_JSON_MEMBER, &mut total)?;
|
||||
if !meta.is_object() {
|
||||
return Err(corrupt("meta.json is not an object"));
|
||||
}
|
||||
if app_major(&meta).is_some_and(|v| v < MIN_SKETCH) {
|
||||
return Err(FormatError::Io(TOO_OLD.into()));
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
if meta.get("version").and_then(Value::as_u64).is_some_and(|v| v > NEWEST_TESTED_VERSION) {
|
||||
warnings.push("the file was saved by a newer Sketch than the import was checked with; some layers may differ".to_string());
|
||||
}
|
||||
// A document.json that can't be read (nested too deep, damaged) loses its shared styles and
|
||||
// library copies, not the pages: they are then taken in archive order.
|
||||
let document = match json_member(&mut z, "document.json", MAX_JSON_MEMBER, &mut total) {
|
||||
Ok(d) if d.is_object() => d,
|
||||
other => {
|
||||
let why = other.err().map_or_else(|| "not an object".to_string(), |e| e.to_string());
|
||||
warnings.push(format!(
|
||||
"the document's shared styles and library symbols were skipped: {}",
|
||||
why.trim_start_matches("not a valid FigCraft file: ")
|
||||
));
|
||||
let pages: Vec<Value> = z
|
||||
.file_names()
|
||||
.filter_map(|n| n.strip_prefix("pages/")?.strip_suffix(".json"))
|
||||
.map(|id| json!({"_ref": format!("pages/{id}")}))
|
||||
.collect();
|
||||
json!({"pages": pages})
|
||||
}
|
||||
};
|
||||
let refs = document.get("pages").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
let mut pages = Vec::new();
|
||||
for r in refs.iter().take(MAX_PAGES) {
|
||||
let Some(id) = r.get("_ref").and_then(Value::as_str).and_then(|s| s.strip_prefix("pages/")).filter(|id| valid_id(id)) else {
|
||||
warnings.push("pages the document lists without a valid reference were skipped".into());
|
||||
continue;
|
||||
};
|
||||
match json_member(&mut z, &format!("pages/{id}.json"), max_member, &mut total) {
|
||||
Ok(page) if page.is_object() => pages.push((id.to_string(), page)),
|
||||
Ok(_) => warnings.push("pages that aren't JSON objects were skipped".into()),
|
||||
Err(e) => warnings.push(format!("a page was skipped: {}", e.to_string().trim_start_matches("not a valid FigCraft file: "))),
|
||||
}
|
||||
}
|
||||
if refs.len() > MAX_PAGES {
|
||||
warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped"));
|
||||
}
|
||||
let version = meta.get("version").and_then(Value::as_u64).unwrap_or(0);
|
||||
Ok(Archive { document, version, pages, warnings, zip: z })
|
||||
}
|
||||
|
||||
/// The bytes of the image a layer references (`images/<name>`).
|
||||
pub(crate) fn image(&self, r: &str) -> Option<Vec<u8>> {
|
||||
let name = r.strip_prefix("images/").unwrap_or(r);
|
||||
if !valid_id(name.split('.').next().unwrap_or("")) || name.split('.').count() > 2 {
|
||||
return None;
|
||||
}
|
||||
// A clone shares the parsed directory and the bytes; the resolver only has shared access.
|
||||
let mut z = self.zip.clone();
|
||||
crate::read_member(&mut z, &format!("images/{name}"), MAX_IMAGE).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON member `name`, refused before unpacking when it would be larger than `limit`, than
|
||||
/// [`MAX_JSON_RATIO`] times what it takes in the archive (past a floor), or than what is left of
|
||||
/// the file's [`MAX_JSON_TOTAL`] (`total`: read so far).
|
||||
fn json_member(z: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str, limit: u64, total: &mut u64) -> Result<Value, FormatError> {
|
||||
let (size, packed) = {
|
||||
let f = z.by_name(name).map_err(|e| corrupt(format!("{name}: {e}")))?;
|
||||
(f.size(), f.compressed_size())
|
||||
};
|
||||
let allowed = limit.min(packed.saturating_mul(MAX_JSON_RATIO).max(MIN_JSON_ALLOWANCE)).min(MAX_JSON_TOTAL.saturating_sub(*total));
|
||||
if size > allowed {
|
||||
return Err(corrupt(format!("{name} is too large")));
|
||||
}
|
||||
let data = crate::read_member(z, name, allowed)?;
|
||||
*total += data.len() as u64;
|
||||
serde_json::from_slice(&data).map_err(|e| corrupt(format!("{name}: {e}")))
|
||||
}
|
||||
|
||||
/// The major version of the Sketch that saved the file (`meta.json` `appVersion`, "50.2" → 50).
|
||||
fn app_major(meta: &Value) -> Option<u32> {
|
||||
meta.get("appVersion").and_then(Value::as_str)?.split('.').next()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// Ids name archive members: letters, digits, `-` and `_` only, never a path.
|
||||
fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() <= 128 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Sketch curve points → vector networks; shape groups → shapes and boolean operations.
|
||||
//!
|
||||
//! A path's `points` are `curvePoint`s whose `point`, `curveFrom` (the handle leaving the point)
|
||||
//! and `curveTo` (the handle arriving at it) are `"{x, y}"` strings in 0–1 of the layer's frame;
|
||||
//! values outside that range are allowed. Sketch 51 and older wrap every shape in a `shapeGroup`
|
||||
//! that carries the style; its children combine left to right, each with its own
|
||||
//! `booleanOperation` (0 union, 1 subtract, 2 intersect, 3 difference). Children with −1 (none)
|
||||
//! don't combine: they join the path before them into one compound path, filled by the winding
|
||||
//! rule (`windingRule` on the layer, 1 even-odd: inner paths are holes). Sketch fills an open path
|
||||
//! as if it were closed.
|
||||
|
||||
use figcraft_doc::geom::{Affine, polygon_points, star_points};
|
||||
use figcraft_doc::{Region, Segment, VectorNetwork, Vertex, WindingRule};
|
||||
use serde_json::{Map as JsonMap, Value, json};
|
||||
|
||||
use super::tree::{Cx, base, layer, layers, local, size};
|
||||
|
||||
/// Farthest a point or handle may be from its layer (the REST importer's own range).
|
||||
const MAX_COORD: f64 = 1e7;
|
||||
/// Most points read from one path.
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
|
||||
/// What a path layer becomes.
|
||||
pub(crate) enum Shape {
|
||||
Vector(VectorNetwork),
|
||||
Star { count: u32, ratio: f64 },
|
||||
Polygon { count: u32 },
|
||||
}
|
||||
|
||||
/// `"{x, y}"` → `(x, y)`, both finite.
|
||||
pub(crate) fn parse_point(s: &str) -> Option<(f64, f64)> {
|
||||
let inner = s.trim().strip_prefix('{')?.strip_suffix('}')?;
|
||||
let (x, y) = inner.split_once(',')?;
|
||||
let n = |t: &str| t.trim().parse::<f64>().ok().filter(|v| v.is_finite());
|
||||
Some((n(x)?, n(y)?))
|
||||
}
|
||||
|
||||
/// A layer's winding rule: on the layer (Sketch 51 and older), else in its style.
|
||||
pub(crate) fn winding(l: &Value) -> WindingRule {
|
||||
let rule = l.get("windingRule").or_else(|| l.get("style").and_then(|s| s.get("windingRule")));
|
||||
match rule.and_then(Value::as_i64) {
|
||||
Some(1) => WindingRule::EvenOdd,
|
||||
_ => WindingRule::NonZero,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a style shows any fill or border.
|
||||
fn shows(style: Option<&Value>, key: &str) -> bool {
|
||||
style
|
||||
.and_then(|s| s.get(key))
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|l| l.iter().any(|f| f.get("isEnabled").and_then(Value::as_bool) != Some(false)))
|
||||
}
|
||||
|
||||
/// The network of a path layer of `w`×`h` (`None` when it has no usable points). `fill_open`: an
|
||||
/// open path gets a closing segment and a fill region, as Sketch fills it.
|
||||
pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) -> Option<VectorNetwork> {
|
||||
let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
if pts.len() > MAX_POINTS {
|
||||
cx.warn(format!("paths with more than {MAX_POINTS} points were cut"));
|
||||
}
|
||||
// Far points stay within the importer's coordinate range (a product could overflow).
|
||||
let fit = |v: f64| if v.is_finite() { v.clamp(-MAX_COORD, MAX_COORD) } else { 0.0 };
|
||||
let at = |p: &Value, key: &str| p.get(key).and_then(Value::as_str).and_then(parse_point).map(|(x, y)| (fit(x * w), fit(y * h)));
|
||||
let mut vertices = Vec::new();
|
||||
// Per vertex: its handles (relative to it) when it has them.
|
||||
let mut handles: Vec<((f64, f64), (f64, f64))> = Vec::new();
|
||||
for p in pts.iter().take(MAX_POINTS) {
|
||||
let Some((x, y)) = at(p, "point") else {
|
||||
cx.warn("path points that couldn't be read were skipped");
|
||||
continue;
|
||||
};
|
||||
let curved = p.get("curveMode").and_then(Value::as_i64).is_some_and(|m| m != 1);
|
||||
let has = |flag: &str| p.get(flag).and_then(Value::as_bool).unwrap_or(curved);
|
||||
let handle = |flag: &str, key: &str| if has(flag) { at(p, key).map_or((0.0, 0.0), |(hx, hy)| (hx - x, hy - y)) } else { (0.0, 0.0) };
|
||||
handles.push((handle("hasCurveFrom", "curveFrom"), handle("hasCurveTo", "curveTo")));
|
||||
let mut v = Vertex::new(x, y);
|
||||
v.radius = p.get("cornerRadius").and_then(Value::as_f64).filter(|r| r.is_finite()).map_or(0.0, |r| r.clamp(0.0, 1e6));
|
||||
vertices.push(v);
|
||||
}
|
||||
let n = vertices.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let open = !l.get("isClosed").and_then(Value::as_bool).unwrap_or(false);
|
||||
let closed = n > 1 && (!open || (fill_open && n > 2));
|
||||
let count = if closed { n } else { n - 1 };
|
||||
let segments: Vec<Segment> = (0..count)
|
||||
.map(|i| {
|
||||
let j = (i + 1) % n;
|
||||
// The edge that closes an open path for its fill is straight.
|
||||
let closing = open && j == 0;
|
||||
let ta = if closing { (0.0, 0.0) } else { handles.get(i).map_or((0.0, 0.0), |h| h.0) };
|
||||
let tb = if closing { (0.0, 0.0) } else { handles.get(j).map_or((0.0, 0.0), |h| h.1) };
|
||||
Segment { a: i, b: j, ta, tb }
|
||||
})
|
||||
.collect();
|
||||
let winding = winding(l);
|
||||
let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() };
|
||||
Some(VectorNetwork { vertices, segments, regions })
|
||||
}
|
||||
|
||||
/// Rectangle `l` with angled (corner style 2), inside-square (3) or inside-arc (4) corners in its
|
||||
/// style (Sketch 2025 and newer) as an edited rectangle whose points draw that outline, the
|
||||
/// corners' `radii` (top left, top right, bottom right, bottom left; repeating) being how far
|
||||
/// each cut reaches along both edges. `None` for any other layer.
|
||||
pub(crate) fn cut_corners(l: &Value) -> Option<Value> {
|
||||
if l.get("_class").and_then(Value::as_str) != Some("rectangle") || l.get("edited").and_then(Value::as_bool) == Some(true) {
|
||||
return None;
|
||||
}
|
||||
let c = l.get("style")?.get("corners")?;
|
||||
let style = c.get("style").and_then(Value::as_i64).filter(|s| (2..=4).contains(s))?;
|
||||
let radii: Vec<f64> = c.get("radii")?.as_array()?.iter().filter_map(Value::as_f64).filter(|r| r.is_finite()).take(4).collect();
|
||||
if radii.iter().all(|r| *r <= 0.0) {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = size(l);
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// Arc handles: a quarter circle's.
|
||||
const K: f64 = 0.552_284_749_8;
|
||||
let fmt = |(x, y): (f64, f64)| format!("{{{}, {}}}", x / w, y / h);
|
||||
let curve = |p: (f64, f64), from: Option<(f64, f64)>, to: Option<(f64, f64)>| {
|
||||
json!({"_class": "curvePoint", "cornerRadius": 0, "point": fmt(p), "curveMode": if from.or(to).is_some() { 4 } else { 1 },
|
||||
"hasCurveFrom": from.is_some(), "hasCurveTo": to.is_some(), "curveFrom": fmt(from.unwrap_or(p)), "curveTo": fmt(to.unwrap_or(p))})
|
||||
};
|
||||
// Corners clockwise from the top left, each with the directions to the edge before and after.
|
||||
let corners = [
|
||||
((0.0, 0.0), (0.0, 1.0), (1.0, 0.0)),
|
||||
((w, 0.0), (-1.0, 0.0), (0.0, 1.0)),
|
||||
((w, h), (0.0, -1.0), (-1.0, 0.0)),
|
||||
((0.0, h), (1.0, 0.0), (0.0, -1.0)),
|
||||
];
|
||||
let mut points = Vec::new();
|
||||
for (i, ((cx, cy), (px, py), (nx, ny))) in corners.into_iter().enumerate() {
|
||||
let r = radii.get(i % radii.len()).copied().unwrap_or(0.0).clamp(0.0, w.min(h) / 2.0);
|
||||
if r <= 0.0 {
|
||||
points.push(curve((cx, cy), None, None));
|
||||
continue;
|
||||
}
|
||||
let a = (cx + r * px, cy + r * py);
|
||||
let b = (cx + r * nx, cy + r * ny);
|
||||
match style {
|
||||
2 => points.extend([curve(a, None, None), curve(b, None, None)]),
|
||||
3 => points.extend([curve(a, None, None), curve((cx + r * (px + nx), cy + r * (py + ny)), None, None), curve(b, None, None)]),
|
||||
// A quarter circle about the corner, bulging into the shape.
|
||||
_ => points
|
||||
.extend([curve(a, Some((a.0 + K * r * nx, a.1 + K * r * ny)), None), curve(b, None, Some((b.0 + K * r * px, b.1 + K * r * py)))]),
|
||||
}
|
||||
}
|
||||
let mut out = l.clone();
|
||||
out["edited"] = json!(true);
|
||||
out["isClosed"] = json!(true);
|
||||
out["points"] = Value::Array(points);
|
||||
if let Some(st) = out.get_mut("style").and_then(Value::as_object_mut) {
|
||||
st.remove("corners");
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A path, star, polygon or triangle layer: a star or polygon while its points are still the
|
||||
/// generated ones (same as FigCraft's, within half a percent), else an editable vector.
|
||||
pub(crate) fn shape(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option<Shape> {
|
||||
// Inside a shape group the group's fill decides; a shape of its own, its style.
|
||||
let style = l.get("style");
|
||||
let filled = cx.operand_fill.unwrap_or_else(|| shows(style, "fills"));
|
||||
let open = !l.get("isClosed").and_then(Value::as_bool).unwrap_or(false);
|
||||
if open && filled && (shows(style, "borders") || cx.operand_border) {
|
||||
cx.warn("open paths with both a fill and a border were closed; the border runs along the closing edge too");
|
||||
}
|
||||
let net = network(cx, l, w, h, filled)?;
|
||||
let class = l.get("_class").and_then(Value::as_str).unwrap_or("");
|
||||
let count = l.get("numberOfPoints").and_then(Value::as_u64).filter(|c| (3..=120).contains(c)).and_then(|c| u32::try_from(c).ok());
|
||||
let plain = net.vertices.iter().all(|v| v.radius == 0.0) && net.segments.iter().all(|s| s.ta == (0.0, 0.0) && s.tb == (0.0, 0.0));
|
||||
let matches = |pts: Vec<figcraft_doc::geom::Point>| {
|
||||
let tol = 0.005 * w.max(h) + 1e-6;
|
||||
pts.len() == net.vertices.len() && pts.iter().zip(&net.vertices).all(|(p, v)| (p.x - v.x).abs() <= tol && (p.y - v.y).abs() <= tol)
|
||||
};
|
||||
match (class, count) {
|
||||
("star", Some(count)) if plain => {
|
||||
let ratio = l.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r > 0.0).map_or(0.5, |r| r.min(1.0));
|
||||
if matches(star_points(w, h, count, ratio)) {
|
||||
return Some(Shape::Star { count, ratio });
|
||||
}
|
||||
}
|
||||
("polygon", Some(count)) if plain && matches(polygon_points(w, h, count)) => return Some(Shape::Polygon { count }),
|
||||
_ => {}
|
||||
}
|
||||
Some(Shape::Vector(net))
|
||||
}
|
||||
|
||||
/// Write `shape` into `node` (its type and geometry).
|
||||
pub(crate) fn put_shape(cx: &mut Cx, shape: Shape, node: &mut JsonMap<String, Value>) {
|
||||
match shape {
|
||||
Shape::Vector(net) => {
|
||||
node.insert("type".into(), json!("VECTOR"));
|
||||
node.insert("vectorNetworkRef".into(), json!(cx.networks.len()));
|
||||
cx.networks.push(Some(net));
|
||||
}
|
||||
Shape::Star { count, ratio } => {
|
||||
node.insert("type".into(), json!("STAR"));
|
||||
node.insert("pointCount".into(), json!(count));
|
||||
node.insert("innerRadius".into(), json!(ratio));
|
||||
}
|
||||
Shape::Polygon { count } => {
|
||||
node.insert("type".into(), json!("REGULAR_POLYGON"));
|
||||
node.insert("pointCount".into(), json!(count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `shapeGroup`: its only child carrying the group's place, name and style; its children joined
|
||||
/// into one compound path when none of them combines; else boolean operations over runs of
|
||||
/// children (a run: a child with an operation and the ones without that follow it), mixed
|
||||
/// operations nesting from the left.
|
||||
pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option<Value> {
|
||||
let (w, h) = size(l);
|
||||
let world = parent * local(l, w, h);
|
||||
let kids = layers(l);
|
||||
let saved = (cx.operand_fill, cx.operand_border);
|
||||
cx.operand_border = shows(l.get("style"), "borders");
|
||||
let out = if let [only] = kids {
|
||||
cx.operand_fill = Some(shows(l.get("style"), "fills"));
|
||||
single(cx, l, only, parent, world, depth, root)
|
||||
} else {
|
||||
// Operands of a boolean operation (or a compound path) give area: open ones close.
|
||||
cx.operand_fill = Some(true);
|
||||
combined(cx, l, kids, parent, world, (w, h), depth, root)
|
||||
};
|
||||
(cx.operand_fill, cx.operand_border) = saved;
|
||||
out
|
||||
}
|
||||
|
||||
/// A shape group of one shape: that shape in the group's place, with its name and style.
|
||||
fn single(cx: &mut Cx, l: &Value, only: &Value, parent: Affine, world: Affine, depth: usize, root: bool) -> Option<Value> {
|
||||
let mut node = layer(cx, only, world, depth + 1, false)?;
|
||||
let (cw, ch) = size(only);
|
||||
let child_world = world * local(only, cw, ch);
|
||||
let group = base(l, "GROUP", parent, child_world, cw, ch, root);
|
||||
if let Some(m) = node.as_object_mut() {
|
||||
for key in ["id", "name", "relativeTransform", "absoluteBoundingBox", "constraints", "locked"] {
|
||||
match group.get(key) {
|
||||
Some(v) => m.insert(key.into(), v.clone()),
|
||||
None => m.remove(key),
|
||||
};
|
||||
}
|
||||
let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true);
|
||||
m.insert("visible".into(), json!(visible));
|
||||
if l.get("style").is_some() {
|
||||
super::style::clear(m);
|
||||
m.remove("styles");
|
||||
super::tree::styles(cx, l, cw, ch, m);
|
||||
super::tree::extras(cx, l, ch, m);
|
||||
}
|
||||
super::tree::mask(l, m);
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// A shape group of several shapes: one compound path, or boolean operations over runs.
|
||||
fn combined(cx: &mut Cx, l: &Value, kids: &[Value], parent: Affine, world: Affine, (w, h): (f64, f64), depth: usize, root: bool) -> Option<Value> {
|
||||
// Runs: a child with an operation starts one, children without (−1) join it.
|
||||
let mut runs: Vec<Vec<&Value>> = Vec::new();
|
||||
for (i, c) in kids.iter().enumerate() {
|
||||
match runs.last_mut() {
|
||||
Some(run) if i > 0 && c.get("booleanOperation").and_then(Value::as_i64).unwrap_or(-1) < 0 => run.push(c),
|
||||
_ => runs.push(vec![c]),
|
||||
}
|
||||
}
|
||||
let winding = winding(l);
|
||||
if let [run] = runs.as_slice()
|
||||
&& let Some(net) = compound(cx, run, winding)
|
||||
{
|
||||
// No operations at all: the group is one path.
|
||||
let mut node = base(l, "VECTOR", parent, world, w, h, root);
|
||||
node.insert("vectorNetworkRef".into(), json!(cx.networks.len()));
|
||||
cx.networks.push(Some(net));
|
||||
super::tree::mask(l, &mut node);
|
||||
super::tree::styles(cx, l, w, h, &mut node);
|
||||
super::tree::extras(cx, l, h, &mut node);
|
||||
return Some(Value::Object(node));
|
||||
}
|
||||
let mut chain: Vec<Value> = Vec::new();
|
||||
let mut op = "UNION";
|
||||
for (i, run) in runs.iter().enumerate() {
|
||||
let operands: Vec<Value> = match run.as_slice() {
|
||||
[one] => layer(cx, one, world, depth + 2, false).into_iter().collect(),
|
||||
more => match compound(cx, more, winding) {
|
||||
Some(net) => {
|
||||
let mut node = base(more.first().copied().unwrap_or(l), "VECTOR", world, world, w, h, false);
|
||||
node.remove("id");
|
||||
node.insert("vectorNetworkRef".into(), json!(cx.networks.len()));
|
||||
cx.networks.push(Some(net));
|
||||
vec![Value::Object(node)]
|
||||
}
|
||||
// Not all paths: each one an operand of its own.
|
||||
None => more.iter().filter_map(|c| layer(cx, c, world, depth + 2, false)).collect(),
|
||||
},
|
||||
};
|
||||
if operands.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let this = run.first().map_or("UNION", |c| boolean_op(c));
|
||||
if i > 0 && this != op && chain.len() > 1 {
|
||||
let inner = synthetic(l, op, parent, world, w, h, std::mem::take(&mut chain));
|
||||
chain.push(inner);
|
||||
}
|
||||
if i > 0 {
|
||||
op = this;
|
||||
}
|
||||
chain.extend(operands);
|
||||
}
|
||||
if chain.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, root);
|
||||
node.insert("booleanOperation".into(), json!(op));
|
||||
node.insert("children".into(), Value::Array(chain));
|
||||
super::tree::mask(l, &mut node);
|
||||
super::tree::styles(cx, l, w, h, &mut node);
|
||||
super::tree::extras(cx, l, h, &mut node);
|
||||
Some(Value::Object(node))
|
||||
}
|
||||
|
||||
/// The paths `run` as one network in their group's space, filled by `winding`; `None` when one of
|
||||
/// them isn't a path (a group, text, …).
|
||||
fn compound(cx: &mut Cx, run: &[&Value], winding: WindingRule) -> Option<VectorNetwork> {
|
||||
const PATHS: [&str; 6] = ["shapePath", "rectangle", "oval", "star", "polygon", "triangle"];
|
||||
if run.iter().any(|c| !c.get("_class").and_then(Value::as_str).is_some_and(|k| PATHS.contains(&k))) {
|
||||
return None;
|
||||
}
|
||||
let mut out = VectorNetwork::default();
|
||||
let mut loops = Vec::new();
|
||||
for c in run {
|
||||
let (cw, ch) = size(c);
|
||||
let Some(net) = network(cx, c, cw, ch, true) else { continue };
|
||||
let xf = local(c, cw, ch);
|
||||
let [a, b, cc, d, _, _] = xf.as_coeffs();
|
||||
let lin = |(x, y): (f64, f64)| (a * x + cc * y, b * x + d * y);
|
||||
let (v0, s0) = (out.vertices.len(), out.segments.len());
|
||||
for v in net.vertices {
|
||||
let p = xf * figcraft_doc::geom::Point::new(v.x, v.y);
|
||||
out.vertices.push(Vertex { x: p.x, y: p.y, ..v });
|
||||
}
|
||||
for sg in net.segments {
|
||||
out.segments.push(Segment { a: sg.a + v0, b: sg.b + v0, ta: lin(sg.ta), tb: lin(sg.tb) });
|
||||
}
|
||||
for r in net.regions {
|
||||
loops.extend(r.loops.into_iter().map(|lp| lp.into_iter().map(|i| i + s0).collect::<Vec<_>>()));
|
||||
}
|
||||
}
|
||||
if out.vertices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !loops.is_empty() {
|
||||
out.regions.push(Region { loops, winding });
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A boolean operation the shape group doesn't have as a layer of its own: placed like the group.
|
||||
fn synthetic(l: &Value, op: &str, parent: Affine, world: Affine, w: f64, h: f64, children: Vec<Value>) -> Value {
|
||||
let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, false);
|
||||
node.remove("id");
|
||||
let name = match op {
|
||||
"SUBTRACT" => "Subtract",
|
||||
"INTERSECT" => "Intersect",
|
||||
"EXCLUDE" => "Exclude",
|
||||
_ => "Union",
|
||||
};
|
||||
node.insert("name".into(), json!(name));
|
||||
// Placed where the group is: inside it, that's no move at all.
|
||||
node.insert("relativeTransform".into(), json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]));
|
||||
node.insert("booleanOperation".into(), json!(op));
|
||||
node.insert("children".into(), Value::Array(children));
|
||||
Value::Object(node)
|
||||
}
|
||||
|
||||
fn boolean_op(l: &Value) -> &'static str {
|
||||
match l.get("booleanOperation").and_then(Value::as_i64) {
|
||||
Some(1) => "SUBTRACT",
|
||||
Some(2) => "INTERSECT",
|
||||
Some(3) => "EXCLUDE",
|
||||
_ => "UNION",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Sketch paths → editable vector networks; shape groups → shapes and boolean operations; stars
|
||||
//! and polygons.
|
||||
|
||||
use figcraft_doc::{BoolOp, Document, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fixture::*;
|
||||
use super::import;
|
||||
use super::path::parse_point;
|
||||
|
||||
fn open(layers: Vec<Value>) -> Document {
|
||||
let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]);
|
||||
import(&z, "f").unwrap().0
|
||||
}
|
||||
|
||||
fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node {
|
||||
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}"))
|
||||
}
|
||||
|
||||
fn with_op(mut l: Value, op: i64) -> Value {
|
||||
l["booleanOperation"] = json!(op);
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_point_accepts_sketch_strings() {
|
||||
assert_eq!(parse_point("{0.5, 1}"), Some((0.5, 1.0)));
|
||||
assert_eq!(parse_point("{-0.25,2e-1}"), Some((-0.25, 0.2)));
|
||||
assert_eq!(parse_point("{1e400, 0}"), None);
|
||||
assert_eq!(parse_point("garbage"), None);
|
||||
assert_eq!(parse_point("{0.1}"), None);
|
||||
assert_eq!(parse_point(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_path_is_editable_vector() {
|
||||
let tri = shape_path("Tri", 10.0, 10.0, 100.0, 100.0, &[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)], true);
|
||||
let open_line = shape_path("Line", 10.0, 200.0, 100.0, 50.0, &[(0.0, 0.0), (1.0, 1.0)], false);
|
||||
let doc = open(vec![tri, open_line]);
|
||||
let NodeKind::Vector { network } = &node(&doc, "Tri").kind else { panic!("not a vector") };
|
||||
let pts: Vec<(f64, f64)> = network.vertices.iter().map(|v| (v.x, v.y)).collect();
|
||||
assert_eq!(pts, [(50.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
assert_eq!(network.segments.len(), 3);
|
||||
assert_eq!(network.regions.len(), 1);
|
||||
let NodeKind::Vector { network } = &node(&doc, "Line").kind else { panic!("not a vector") };
|
||||
assert_eq!(network.segments.len(), 1);
|
||||
assert!(network.regions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_handles_and_corner_radius_are_kept() {
|
||||
let mut p = shape_path("Curve", 0.0, 0.0, 100.0, 100.0, &[(0.0, 0.0), (1.0, 0.0)], false);
|
||||
p["points"][0]["curveMode"] = json!(2);
|
||||
p["points"][0]["hasCurveFrom"] = json!(true);
|
||||
p["points"][0]["curveFrom"] = json!("{0.25, 0.5}");
|
||||
p["points"][1]["curveMode"] = json!(2);
|
||||
p["points"][1]["hasCurveTo"] = json!(true);
|
||||
p["points"][1]["curveTo"] = json!("{0.75, 0.5}");
|
||||
p["points"][1]["cornerRadius"] = json!(6);
|
||||
let doc = open(vec![p]);
|
||||
let NodeKind::Vector { network } = &node(&doc, "Curve").kind else { panic!("not a vector") };
|
||||
let s = network.segments[0];
|
||||
assert_eq!((s.ta, s.tb), ((25.0, 50.0), (-25.0, 50.0)));
|
||||
assert_eq!(network.vertices[1].radius, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_child_shape_group_collapses() {
|
||||
let inner = with_op(rect("Inner", 0.0, 0.0, 50.0, 30.0), -1);
|
||||
let doc = open(vec![shape_group("Button", 20.0, 40.0, 50.0, 30.0, vec![inner])]);
|
||||
let b = node(&doc, "Button");
|
||||
assert!(matches!(b.kind, NodeKind::Rectangle));
|
||||
assert!(doc.nodes().all(|n| n.name != "Inner"));
|
||||
let bb = doc.world_bounds(b.id).unwrap();
|
||||
assert_eq!((bb.x0, bb.y0, bb.width(), bb.height()), (20.0, 40.0, 50.0, 30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_boolean_ops_chain_left() {
|
||||
let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1);
|
||||
let b = with_op(rect("B", 10.0, 10.0, 20.0, 20.0), 1);
|
||||
let c = with_op(rect("C", 60.0, 0.0, 20.0, 20.0), 0);
|
||||
let doc = open(vec![shape_group("Combined", 0.0, 0.0, 80.0, 50.0, vec![a, b, c])]);
|
||||
let outer = node(&doc, "Combined");
|
||||
assert!(matches!(outer.kind, NodeKind::BooleanOp { op: BoolOp::Union }));
|
||||
let kids: Vec<&figcraft_doc::Node> = outer.children.iter().map(|c| doc.node(*c).unwrap()).collect();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert!(matches!(kids[0].kind, NodeKind::BooleanOp { op: BoolOp::Subtract }));
|
||||
assert_eq!(kids[1].name, "C");
|
||||
let inner: Vec<&str> = kids[0].children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
|
||||
assert_eq!(inner, ["A", "B"]);
|
||||
let bb = doc.world_bounds(node(&doc, "B").id).unwrap();
|
||||
assert_eq!((bb.x0, bb.y0), (10.0, 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_and_difference_ops() {
|
||||
let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1);
|
||||
let b = with_op(rect("B", 10.0, 10.0, 50.0, 50.0), 2);
|
||||
let doc = open(vec![shape_group("Both", 0.0, 0.0, 60.0, 60.0, vec![a.clone(), b])]);
|
||||
assert!(matches!(node(&doc, "Both").kind, NodeKind::BooleanOp { op: BoolOp::Intersect }));
|
||||
let b = with_op(rect("B", 10.0, 10.0, 50.0, 50.0), 3);
|
||||
let doc = open(vec![shape_group("Either", 0.0, 0.0, 60.0, 60.0, vec![a, b])]);
|
||||
assert!(matches!(node(&doc, "Either").kind, NodeKind::BooleanOp { op: BoolOp::Exclude }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_shapes_52_plus() {
|
||||
let doc = open(vec![shape_path("Free", 5.0, 5.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true)]);
|
||||
assert!(matches!(node(&doc, "Free").kind, NodeKind::Vector { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn star_and_polygon() {
|
||||
let unit = |pts: Vec<figcraft_doc::geom::Point>, w: f64, h: f64| -> Vec<(f64, f64)> { pts.iter().map(|p| (p.x / w, p.y / h)).collect() };
|
||||
let mut star = shape_path("Star", 0.0, 0.0, 100.0, 80.0, &unit(figcraft_doc::geom::star_points(100.0, 80.0, 5, 0.5), 100.0, 80.0), true);
|
||||
star["_class"] = json!("star");
|
||||
star["numberOfPoints"] = json!(5);
|
||||
star["radius"] = json!(0.5);
|
||||
let mut poly = shape_path("Hexagon", 0.0, 100.0, 60.0, 60.0, &unit(figcraft_doc::geom::polygon_points(60.0, 60.0, 6), 60.0, 60.0), true);
|
||||
poly["_class"] = json!("polygon");
|
||||
poly["numberOfPoints"] = json!(6);
|
||||
let mut edited = star.clone();
|
||||
edited["do_objectID"] = json!("Edited");
|
||||
edited["name"] = json!("Edited");
|
||||
edited["points"][2]["point"] = json!("{0.9, 0.9}");
|
||||
let mut tri = shape_path("Triangle", 200.0, 0.0, 50.0, 50.0, &[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)], true);
|
||||
tri["_class"] = json!("triangle");
|
||||
let doc = open(vec![star, poly, edited, tri]);
|
||||
assert!(matches!(node(&doc, "Star").kind, NodeKind::Star { count: 5, .. }));
|
||||
if let NodeKind::Star { inner_ratio, .. } = node(&doc, "Star").kind {
|
||||
assert!((inner_ratio - 0.5).abs() < 1e-9);
|
||||
}
|
||||
assert!(matches!(node(&doc, "Hexagon").kind, NodeKind::Polygon { count: 6 }));
|
||||
assert!(matches!(node(&doc, "Edited").kind, NodeKind::Vector { .. }));
|
||||
assert!(matches!(node(&doc, "Triangle").kind, NodeKind::Vector { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_outside_unit_are_kept_finite() {
|
||||
let doc = open(vec![shape_path("Over", 0.0, 0.0, 10.0, 10.0, &[(-0.5, 2.0), (1.0, 0.0), (0.0, 0.0)], true)]);
|
||||
let NodeKind::Vector { network } = &node(&doc, "Over").kind else { panic!("not a vector") };
|
||||
assert!(network.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
|
||||
assert_eq!((network.vertices[0].x, network.vertices[0].y), (-5.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn even_odd_winding() {
|
||||
let mut p = shape_path("Holes", 0.0, 0.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true);
|
||||
p["style"]["windingRule"] = json!(1);
|
||||
let doc = open(vec![p]);
|
||||
let NodeKind::Vector { network } = &node(&doc, "Holes").kind else { panic!("not a vector") };
|
||||
assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_points_stay_finite() {
|
||||
let mut p = shape_path("Huge", 0.0, 0.0, 1e6, 1e6, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true);
|
||||
p["points"][1]["point"] = json!("{1e308, 0}");
|
||||
p["points"][1]["curveMode"] = json!(2);
|
||||
p["points"][1]["curveFrom"] = json!("{-1e308, 1e308}");
|
||||
let doc = open(vec![p]);
|
||||
if let NodeKind::Vector { network } = &node(&doc, "Huge").kind {
|
||||
assert!(network.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
|
||||
assert!(network.segments.iter().all(|s| [s.ta.0, s.ta.1, s.tb.0, s.tb.1].iter().all(|c| c.is_finite())));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch 51 and older: a shape group whose children have no operation (−1) is one compound path,
|
||||
/// filled by the group's winding rule (stored on the layer itself).
|
||||
#[test]
|
||||
fn compound_shape_group_has_holes() {
|
||||
let outer = with_op(rect("Outer", 0.0, 0.0, 100.0, 100.0), -1);
|
||||
let inner = with_op(rect("Inner", 25.0, 25.0, 50.0, 50.0), -1);
|
||||
let mut g = shape_group("Ring", 10.0, 10.0, 100.0, 100.0, vec![outer, inner]);
|
||||
g["windingRule"] = json!(1);
|
||||
let doc = open(vec![g]);
|
||||
let n = node(&doc, "Ring");
|
||||
let NodeKind::Vector { network } = &n.kind else { panic!("not one vector: {:?}", n.kind) };
|
||||
assert_eq!(network.regions.len(), 1);
|
||||
assert_eq!(network.regions[0].loops.len(), 2);
|
||||
assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd);
|
||||
let b = doc.world_bounds(n.id).unwrap();
|
||||
assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.0, 10.0, 100.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_runs_are_boolean_operands() {
|
||||
let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1);
|
||||
let b = with_op(rect("B", 60.0, 0.0, 20.0, 20.0), -1);
|
||||
let c = with_op(rect("C", 10.0, 10.0, 10.0, 10.0), 1);
|
||||
let doc = open(vec![shape_group("Mixed", 0.0, 0.0, 80.0, 50.0, vec![a, b, c])]);
|
||||
let n = node(&doc, "Mixed");
|
||||
assert!(matches!(n.kind, NodeKind::BooleanOp { op: BoolOp::Subtract }), "{:?}", n.kind);
|
||||
let first = doc.node(n.children[0]).unwrap();
|
||||
let NodeKind::Vector { network } = &first.kind else { panic!("first operand isn't the compound path") };
|
||||
assert_eq!(network.regions[0].loops.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_winding_rule_counts() {
|
||||
let mut p = shape_path("Wound", 0.0, 0.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true);
|
||||
p["windingRule"] = json!(1);
|
||||
let doc = open(vec![p]);
|
||||
let NodeKind::Vector { network } = &node(&doc, "Wound").kind else { panic!("not a vector") };
|
||||
assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd);
|
||||
}
|
||||
|
||||
/// Sketch fills an open path as if it were closed.
|
||||
#[test]
|
||||
fn filled_open_paths_are_closed() {
|
||||
let open_path = shape_path("Lid", 0.0, 0.0, 40.0, 20.0, &[(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)], false);
|
||||
let filled = styled(shape_group("Filled", 0.0, 0.0, 40.0, 20.0, vec![open_path.clone()]), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)]));
|
||||
let stroked = styled(
|
||||
shape_group("Stroked", 0.0, 50.0, 40.0, 20.0, vec![open_path]),
|
||||
"borders",
|
||||
json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": 0, "thickness": 1}]),
|
||||
);
|
||||
let doc = open(vec![filled, stroked]);
|
||||
let NodeKind::Vector { network } = &node(&doc, "Filled").kind else { panic!("not a vector") };
|
||||
assert_eq!(network.regions.len(), 1);
|
||||
assert_eq!(network.segments.len(), 3);
|
||||
let NodeKind::Vector { network } = &node(&doc, "Stroked").kind else { panic!("not a vector") };
|
||||
assert!(network.regions.is_empty());
|
||||
assert_eq!(network.segments.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_operands_of_booleans_are_closed() {
|
||||
let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1);
|
||||
let b = with_op(shape_path("Snout", 40.0, 10.0, 30.0, 20.0, &[(0.0, 0.0), (1.0, 0.5), (0.0, 1.0)], false), 0);
|
||||
let doc = open(vec![styled(shape_group("Head", 0.0, 0.0, 70.0, 50.0, vec![a, b]), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)]))]);
|
||||
let NodeKind::Vector { network } = &node(&doc, "Snout").kind else { panic!("not a vector") };
|
||||
assert_eq!(network.regions.len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Sketch prototype links, hotspots, export options and layout grids → REST.
|
||||
//!
|
||||
//! A layer's `flow` links it to an artboard (`destinationArtboardID`, or `back`) with an
|
||||
//! `animationType` (0 none, 1–4 slide from the right, left, bottom, top); hotspots are layers that
|
||||
//! only carry a link; `isFlowHome` marks an artboard where a prototype starts.
|
||||
|
||||
use serde_json::{Map as JsonMap, Value, json};
|
||||
|
||||
use super::tree::{Cx, bool_field, str_field};
|
||||
|
||||
/// Most export settings read from one layer.
|
||||
const MAX_EXPORTS: usize = 64;
|
||||
|
||||
/// The click interaction of layer `l`'s `flow`, if it has one.
|
||||
pub(crate) fn interactions(l: &Value, node: &mut JsonMap<String, Value>) {
|
||||
let Some(flow) = l.get("flow") else { return };
|
||||
let Some(dest) = str_field(flow, "destinationArtboardID").filter(|d| !d.is_empty()) else { return };
|
||||
let action = if dest == "back" {
|
||||
json!({"type": "BACK"})
|
||||
} else {
|
||||
let direction = match flow.get("animationType").and_then(Value::as_i64) {
|
||||
Some(1) => Some("LEFT"),
|
||||
Some(2) => Some("RIGHT"),
|
||||
Some(3) => Some("TOP"),
|
||||
Some(4) => Some("BOTTOM"),
|
||||
_ => None,
|
||||
};
|
||||
let transition =
|
||||
direction.map_or(Value::Null, |d| json!({"type": "SLIDE_IN", "direction": d, "duration": 0.3, "easing": {"type": "EASE_IN_AND_OUT"}}));
|
||||
json!({"type": "NODE", "destinationId": dest, "navigation": "NAVIGATE", "transition": transition})
|
||||
};
|
||||
node.insert("interactions".into(), json!([{"trigger": {"type": "ON_CLICK"}, "actions": [action]}]));
|
||||
}
|
||||
|
||||
/// The artboards of a page marked as where a prototype starts, as REST `flowStartingPoints`.
|
||||
pub(crate) fn starting_points(page: &Value) -> Vec<Value> {
|
||||
super::tree::layers(page)
|
||||
.iter()
|
||||
.filter(|a| bool_field(a, "isFlowHome") == Some(true))
|
||||
.filter_map(|a| Some(json!({"nodeId": str_field(a, "do_objectID")?, "name": str_field(a, "name").unwrap_or("Flow")})))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Layer `l`'s export options as REST `exportSettings`.
|
||||
pub(crate) fn export_settings(cx: &mut Cx, l: &Value, node: &mut JsonMap<String, Value>) {
|
||||
let formats = l.get("exportOptions").and_then(|o| o.get("exportFormats")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
for f in formats.iter().take(MAX_EXPORTS) {
|
||||
let format = match str_field(f, "fileFormat").unwrap_or("png").to_ascii_lowercase().as_str() {
|
||||
"png" => "PNG",
|
||||
"jpg" | "jpeg" => "JPG",
|
||||
"svg" => "SVG",
|
||||
"pdf" => "PDF",
|
||||
"webp" => "WEBP",
|
||||
_ => {
|
||||
cx.warn("export settings in formats FigCraft doesn't export (TIFF, EPS, HEIC) were skipped");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let num = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
|
||||
let constraint = match (f.get("visibleScaleType").and_then(Value::as_i64), num("absoluteSize")) {
|
||||
(Some(1), Some(s)) => json!({"type": "WIDTH", "value": s.min(16_384.0)}),
|
||||
(Some(2), Some(s)) => json!({"type": "HEIGHT", "value": s.min(16_384.0)}),
|
||||
_ => json!({"type": "SCALE", "value": num("scale").unwrap_or(1.0).min(64.0)}),
|
||||
};
|
||||
let name: String = str_field(f, "name").unwrap_or("").chars().take(200).collect();
|
||||
if f.get("namingScheme").and_then(Value::as_i64) == Some(1) && !name.is_empty() {
|
||||
cx.warn("export names Sketch puts before the layer name come after it");
|
||||
}
|
||||
out.push(json!({"format": format, "suffix": name, "constraint": constraint}));
|
||||
}
|
||||
if !out.is_empty() {
|
||||
node.insert("exportSettings".into(), Value::Array(out));
|
||||
}
|
||||
}
|
||||
|
||||
/// An artboard's layout grid (columns, rows) and square grid as REST `layoutGrids`, hidden: Sketch
|
||||
/// shows them only while its layout (or grid) view is turned on.
|
||||
pub(crate) fn layout_grids(l: &Value, h: f64, node: &mut JsonMap<String, Value>) {
|
||||
let mut out = Vec::new();
|
||||
if let Some(g) = l.get("layout").filter(|g| bool_field(g, "isEnabled") != Some(false)) {
|
||||
let num = |k: &str| g.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v >= 0.0);
|
||||
if bool_field(g, "drawVertical") != Some(false)
|
||||
&& let (Some(count), Some(width)) = (num("numberOfColumns"), num("columnWidth"))
|
||||
{
|
||||
out.push(json!({"pattern": "COLUMNS", "alignment": "MIN", "count": count.min(1000.0), "sectionSize": width.min(1e5),
|
||||
"gutterSize": num("gutterWidth").unwrap_or(0.0).min(1e5), "offset": num("horizontalOffset").unwrap_or(0.0).min(1e5), "visible": false}));
|
||||
}
|
||||
if bool_field(g, "drawHorizontal") == Some(true) {
|
||||
let gutter = num("gutterHeight").unwrap_or(0.0).min(1e5);
|
||||
let section = (gutter * num("rowHeightMultiplication").unwrap_or(1.0)).min(1e5);
|
||||
if section > 0.0 {
|
||||
let count = (h / (section + gutter)).floor().clamp(1.0, 1000.0);
|
||||
out.push(json!({"pattern": "ROWS", "alignment": "MIN", "count": count, "sectionSize": section, "gutterSize": gutter, "offset": 0, "visible": false}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(g) = l.get("grid").filter(|g| bool_field(g, "isEnabled") != Some(false))
|
||||
&& let Some(size) = g.get("gridSize").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0)
|
||||
{
|
||||
out.push(json!({"pattern": "GRID", "sectionSize": size.min(1e5), "visible": false}));
|
||||
}
|
||||
if !out.is_empty() {
|
||||
node.insert("layoutGrids".into(), Value::Array(out));
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes about what the layer has that FigCraft keeps only as laid out: Smart Layout,
|
||||
/// and overrides a master locks.
|
||||
pub(crate) fn notes(cx: &mut Cx, l: &Value) {
|
||||
// Stacks become auto layout; the older Smart Layout doesn't.
|
||||
if l.get("groupLayout").and_then(|g| str_field(g, "_class")) == Some("MSImmutableInferredGroupLayout") {
|
||||
cx.warn("Smart Layout was imported as laid out; it doesn't resize with its content");
|
||||
}
|
||||
let locked = l.get("overrideProperties").and_then(Value::as_array).is_some_and(|p| p.iter().any(|o| bool_field(o, "canOverride") == Some(false)));
|
||||
if locked {
|
||||
cx.warn("overrides Sketch symbols locked can be changed in FigCraft");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Sketch prototype links, hotspots, export options, layout grids, smart layout and locked
|
||||
//! overrides.
|
||||
|
||||
use figcraft_doc::{Document, ExportFormat, Node, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fixture::*;
|
||||
use super::import;
|
||||
|
||||
fn open(layers: Vec<Value>) -> (Document, Vec<String>) {
|
||||
let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", layers)], &[]);
|
||||
import(&z, "f").unwrap()
|
||||
}
|
||||
|
||||
fn node<'d>(doc: &'d Document, name: &str) -> &'d Node {
|
||||
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}"))
|
||||
}
|
||||
|
||||
fn screens(button: Value) -> Vec<Value> {
|
||||
vec![artboard("A1", "Home", 0.0, 0.0, 100.0, 100.0, vec![button]), artboard("A2", "Detail", 200.0, 0.0, 100.0, 100.0, vec![])]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_navigates() {
|
||||
let b = with(
|
||||
rect("Go", 0.0, 0.0, 20.0, 20.0),
|
||||
"flow",
|
||||
json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "A2", "animationType": 1}),
|
||||
);
|
||||
let (doc, _) = open(screens(b));
|
||||
let go = node(&doc, "Go");
|
||||
assert_eq!(go.interactions.len(), 1, "{:?}", go.interactions);
|
||||
let detail = node(&doc, "Detail").id;
|
||||
let text = format!("{:?}", go.interactions[0]);
|
||||
assert!(text.contains(&format!("{detail:?}")), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_back() {
|
||||
let b = with(
|
||||
rect("Back", 0.0, 0.0, 20.0, 20.0),
|
||||
"flow",
|
||||
json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "back", "animationType": 0}),
|
||||
);
|
||||
let (doc, _) = open(screens(b));
|
||||
let text = format!("{:?}", node(&doc, "Back").interactions);
|
||||
assert!(text.contains("Back"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotspot_is_transparent_frame_with_interaction() {
|
||||
let mut h = with(
|
||||
rect("Hot", 0.0, 0.0, 20.0, 20.0),
|
||||
"flow",
|
||||
json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "A2", "animationType": 0}),
|
||||
);
|
||||
h["_class"] = json!("MSImmutableHotspotLayer");
|
||||
let (doc, _) = open(screens(h));
|
||||
let n = node(&doc, "Hot");
|
||||
assert!(matches!(n.kind, NodeKind::Frame));
|
||||
assert!(n.fills.is_empty());
|
||||
assert_eq!(n.interactions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_home_is_starting_point() {
|
||||
let mut home = artboard("A1", "Home", 0.0, 0.0, 100.0, 100.0, vec![]);
|
||||
home["isFlowHome"] = json!(true);
|
||||
let (doc, _) = open(vec![home]);
|
||||
let page = doc.node(doc.pages[0]).unwrap();
|
||||
let NodeKind::Page { flows, .. } = &page.kind else { panic!("not a page") };
|
||||
assert_eq!(flows.len(), 1);
|
||||
assert_eq!(flows[0].start, node(&doc, "Home").id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_options() {
|
||||
let mut r = rect("Icon", 0.0, 0.0, 20.0, 20.0);
|
||||
r["exportOptions"] = json!({"_class": "exportOptions", "exportFormats": [
|
||||
{"_class": "exportFormat", "fileFormat": "png", "scale": 2, "name": "@2x", "namingScheme": 0, "absoluteSize": 0, "visibleScaleType": 0},
|
||||
{"_class": "exportFormat", "fileFormat": "svg", "scale": 1, "name": "", "namingScheme": 0, "absoluteSize": 0, "visibleScaleType": 0},
|
||||
{"_class": "exportFormat", "fileFormat": "png", "scale": 1, "name": "", "namingScheme": 0, "absoluteSize": 64, "visibleScaleType": 1},
|
||||
]});
|
||||
let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![r])]);
|
||||
let e = &node(&doc, "Icon").exports;
|
||||
assert_eq!(e.len(), 3);
|
||||
assert_eq!(e[0].format, ExportFormat::Png);
|
||||
assert_eq!(e[0].suffix, "@2x");
|
||||
assert_eq!(e[1].format, ExportFormat::Svg);
|
||||
let text = format!("{:?}", e[2]);
|
||||
assert!(text.contains("64"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_and_square_grids() {
|
||||
let mut a = artboard("A", "Board", 0.0, 0.0, 1200.0, 800.0, vec![]);
|
||||
a["layout"] = json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 84, "gutterWidth": 16, "horizontalOffset": 24, "numberOfColumns": 12,
|
||||
"totalWidth": 1152, "guttersOutside": false, "drawVertical": true, "drawHorizontal": false, "gutterHeight": 8, "rowHeightMultiplication": 3});
|
||||
a["grid"] = json!({"_class": "simpleGrid", "isEnabled": true, "gridSize": 8, "thickGridTimes": 0});
|
||||
let (doc, _) = open(vec![a]);
|
||||
let grids = format!("{:?}", node(&doc, "Board").grids);
|
||||
assert_eq!(node(&doc, "Board").grids.len(), 2, "{grids}");
|
||||
assert!(grids.contains("12") && grids.contains("16"), "{grids}");
|
||||
// Hidden, as Sketch shows them only when its layout view is on.
|
||||
assert!(node(&doc, "Board").grids.iter().all(|g| !g.visible), "{grids}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_layout_warns_once() {
|
||||
let g = |id: &str| {
|
||||
with(
|
||||
group(id, 0.0, 0.0, 10.0, 10.0, vec![rect(&format!("{id}r"), 0.0, 0.0, 10.0, 10.0)]),
|
||||
"groupLayout",
|
||||
json!({"_class": "MSImmutableInferredGroupLayout", "axis": 0}),
|
||||
)
|
||||
};
|
||||
let (_, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![g("G1"), g("G2"), g("G3")])]);
|
||||
assert_eq!(warnings.iter().filter(|w| w.contains("Smart Layout")).count(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_overrides_are_noted() {
|
||||
let mut m = master("M", "S", "Locked", 50.0, 20.0, vec![text("L", 0.0, 0.0, 50.0, 20.0, "x", &[(0, 1, "Helvetica", 9.0, [0.0, 0.0, 0.0, 1.0])])]);
|
||||
m["overrideProperties"] = json!([{"_class": "MSImmutableOverrideProperty", "overrideName": "L_stringValue", "canOverride": false}]);
|
||||
let (_, warnings) = open(vec![m]);
|
||||
assert!(warnings.iter().any(|w| w.contains("locked")), "{warnings:?}");
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Sketch shared styles and swatches → REST style definitions and colour variables.
|
||||
//!
|
||||
//! A Sketch layer style holds fills, borders and effects together; FigCraft styles hold one kind
|
||||
//! each, so a layer style becomes up to three styles named after it: its fills (a paint style),
|
||||
//! its border colours (a paint style for strokes; widths stay on the layers) and its effects. A
|
||||
//! text style becomes a text style; its colour stays on the layers, as FigCraft text styles carry
|
||||
//! none. Layers link to them by `style.sharedObjectID`. Swatches (Color Variables, Sketch 69+)
|
||||
//! become colour variables in one collection; colours name theirs by `swatchID`.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Map as JsonMap, Value, json};
|
||||
|
||||
use super::tree::{Cx, color, str_field};
|
||||
|
||||
/// Most shared styles or swatches read from one file.
|
||||
const MAX_SHARED: usize = 20_000;
|
||||
/// The collection swatches become variables in.
|
||||
const COLLECTION: &str = "sketch-color-variables";
|
||||
const MODE: &str = "default";
|
||||
|
||||
/// A file's shared styles and swatches.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Shared {
|
||||
/// `(id, name, style)` of layer styles, then text styles.
|
||||
layer: Vec<(String, String, Value)>,
|
||||
text: Vec<(String, String, Value)>,
|
||||
/// Positions in `layer` and `text` by id.
|
||||
layer_index: BTreeMap<String, usize>,
|
||||
text_index: BTreeMap<String, usize>,
|
||||
swatches: BTreeMap<String, (String, Value)>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
pub(crate) fn read(document: &Value) -> Shared {
|
||||
let mut s = Shared::default();
|
||||
let objects =
|
||||
|key: &str| -> Vec<Value> { document.get(key).and_then(|c| c.get("objects")).and_then(Value::as_array).cloned().unwrap_or_default() };
|
||||
let foreign = |key: &str, inner: &str| -> Vec<Value> {
|
||||
document.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|f| f.get(inner).cloned()).collect()
|
||||
};
|
||||
let entry = |o: &Value| {
|
||||
Some((
|
||||
str_field(o, "do_objectID")?.to_string(),
|
||||
str_field(o, "name").unwrap_or("Style").chars().take(200).collect(),
|
||||
o.get("value")?.clone(),
|
||||
))
|
||||
};
|
||||
for o in objects("layerStyles").iter().chain(&foreign("foreignLayerStyles", "localSharedStyle")).take(MAX_SHARED) {
|
||||
if let Some(e) = entry(o).filter(|e| !s.layer_index.contains_key(&e.0)) {
|
||||
s.layer_index.insert(e.0.clone(), s.layer.len());
|
||||
s.layer.push(e);
|
||||
}
|
||||
}
|
||||
for o in objects("layerTextStyles").iter().chain(&foreign("foreignTextStyles", "localSharedStyle")).take(MAX_SHARED) {
|
||||
if let Some(e) = entry(o).filter(|e| !s.text_index.contains_key(&e.0)) {
|
||||
s.text_index.insert(e.0.clone(), s.text.len());
|
||||
s.text.push(e);
|
||||
}
|
||||
}
|
||||
let foreign_swatches = [foreign("foreignSwatches", "localSwatch"), foreign("foreignSwatches", "localSharedSwatch")].concat();
|
||||
for o in objects("sharedSwatches").iter().chain(&foreign_swatches).take(MAX_SHARED) {
|
||||
if let Some((id, name, value)) = entry(o)
|
||||
&& let Some(c) = color(&value)
|
||||
{
|
||||
s.swatches.entry(id).or_insert((name, c));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The style of shared text (`text`) or layer style `id`.
|
||||
pub(crate) fn style(&self, text: bool, id: &str) -> Option<&Value> {
|
||||
let (index, list) = if text { (&self.text_index, &self.text) } else { (&self.layer_index, &self.layer) };
|
||||
list.get(*index.get(id)?).map(|e| &e.2)
|
||||
}
|
||||
|
||||
pub(crate) fn has_swatch(&self, id: &str) -> bool {
|
||||
self.swatches.contains_key(id)
|
||||
}
|
||||
|
||||
/// The REST `styleDefinitions` of every shared style.
|
||||
pub(crate) fn definitions(cx: &mut Cx) -> Vec<Value> {
|
||||
let layer = cx.shared.layer.clone();
|
||||
let text = cx.shared.text.clone();
|
||||
let mut out = Vec::new();
|
||||
for (id, name, style) in &layer {
|
||||
let fills = super::style::fills(cx, style, 100.0, 100.0);
|
||||
if !fills.is_empty() {
|
||||
out.push(json!({"id": format!("{id}:fill"), "name": name, "styleType": "FILL", "node": {"fills": fills}}));
|
||||
}
|
||||
let strokes = super::style::border_paints(cx, style, 100.0, 100.0);
|
||||
if !strokes.is_empty() {
|
||||
out.push(json!({"id": format!("{id}:stroke"), "name": format!("{name} border"), "styleType": "FILL", "node": {"fills": strokes}}));
|
||||
}
|
||||
let effects = super::style::effects(cx, style);
|
||||
if !effects.is_empty() {
|
||||
out.push(json!({"id": format!("{id}:effect"), "name": name, "styleType": "EFFECT", "node": {"effects": effects}}));
|
||||
}
|
||||
}
|
||||
for (id, name, style) in &text {
|
||||
let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) else { continue };
|
||||
let mut ts = super::text::type_style(cx, enc);
|
||||
if let Some(m) = ts.as_object_mut() {
|
||||
m.remove("fills");
|
||||
}
|
||||
out.push(json!({"id": id, "name": name, "styleType": "TEXT", "node": {"type": "TEXT", "characters": "", "style": ts}}));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Link `node` (layer `l`) to the styles made from its shared style.
|
||||
pub(crate) fn link(&self, cx_defs: &BTreeSet<String>, l: &Value, node: &mut JsonMap<String, Value>) {
|
||||
let Some(sid) = l.get("style").and_then(|s| str_field(s, "sharedObjectID")) else { return };
|
||||
let mut refs = JsonMap::new();
|
||||
let is_text = str_field(l, "_class") == Some("text");
|
||||
if is_text && self.text_index.contains_key(sid) {
|
||||
refs.insert("text".into(), json!(sid));
|
||||
} else if self.layer_index.contains_key(sid) {
|
||||
for slot in ["fill", "stroke", "effect"] {
|
||||
let id = format!("{sid}:{slot}");
|
||||
if cx_defs.contains(&id) {
|
||||
refs.insert(slot.into(), json!(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !refs.is_empty() {
|
||||
node.insert("styles".into(), Value::Object(refs));
|
||||
}
|
||||
}
|
||||
|
||||
/// The REST `variableCollections` and `variables` for the swatches (none without swatches).
|
||||
pub(crate) fn variables(&self) -> Option<(Value, Value)> {
|
||||
if self.swatches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ids: Vec<&String> = self.swatches.keys().collect();
|
||||
let collections = json!({COLLECTION: {"id": COLLECTION, "name": "Color Variables", "modes": [{"modeId": MODE, "name": "Default"}],
|
||||
"defaultModeId": MODE, "variableIds": ids}});
|
||||
let vars: JsonMap<String, Value> = self
|
||||
.swatches
|
||||
.iter()
|
||||
.map(|(id, (name, c))| {
|
||||
(id.clone(), json!({"id": id, "name": name, "resolvedType": "COLOR", "variableCollectionId": COLLECTION, "valuesByMode": {MODE: c}}))
|
||||
})
|
||||
.collect();
|
||||
Some((collections, Value::Object(vars)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Sketch shared styles → FigCraft styles; Color Variables (swatches) → colour variables.
|
||||
|
||||
use figcraft_doc::color::PaintKind;
|
||||
use figcraft_doc::{Document, Node, StyleKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fixture::*;
|
||||
use super::import;
|
||||
|
||||
const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0];
|
||||
|
||||
fn open(doc_json: Value, layers: Vec<Value>) -> (Document, Vec<String>) {
|
||||
let z = sketch_zip("83.2", 139, doc_json, &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]);
|
||||
import(&z, "f").unwrap()
|
||||
}
|
||||
|
||||
fn node<'d>(doc: &'d Document, name: &str) -> &'d Node {
|
||||
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}"))
|
||||
}
|
||||
|
||||
fn style<'d>(doc: &'d Document, id: &str) -> &'d figcraft_doc::Style {
|
||||
doc.styles.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("no style {id}: {:?}", doc.styles.iter().map(|s| &s.id).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
fn card_style() -> Value {
|
||||
json!({"_class": "style", "fills": [solid_fill(0.0, 0.0, 1.0, 1.0)], "borders": [],
|
||||
"shadows": [{"_class": "shadow", "isEnabled": true, "blurRadius": 4, "offsetX": 0, "offsetY": 2, "spread": 0, "color": color(0.0, 0.0, 0.0, 0.3)}],
|
||||
"innerShadows": []})
|
||||
}
|
||||
|
||||
fn with_shared(d: &mut Value, layer_styles: Vec<Value>, text_styles: Vec<Value>) {
|
||||
d["layerStyles"]["objects"] = Value::Array(layer_styles);
|
||||
d["layerTextStyles"]["objects"] = Value::Array(text_styles);
|
||||
}
|
||||
|
||||
fn shared(id: &str, name: &str, value: Value) -> Value {
|
||||
json!({"_class": "sharedStyle", "do_objectID": id, "name": name, "value": value})
|
||||
}
|
||||
|
||||
fn linked(mut l: Value, style: Value, shared_id: &str) -> Value {
|
||||
l["style"] = style;
|
||||
l["style"]["sharedObjectID"] = json!(shared_id);
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_style_splits() {
|
||||
let mut d = document(&["P"]);
|
||||
with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]);
|
||||
let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "CARD")]);
|
||||
let n = node(&doc, "R");
|
||||
let fill = n.styles.fill.clone().expect("fill style linked");
|
||||
let effect = n.styles.effect.clone().expect("effect style linked");
|
||||
assert_eq!(style(&doc, &fill).name, "Card");
|
||||
assert!(matches!(style(&doc, &fill).kind, StyleKind::Paint { .. }));
|
||||
assert!(matches!(style(&doc, &effect).kind, StyleKind::Effect { .. }));
|
||||
assert_eq!(style(&doc, &effect).name, "Card");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_style_borders_become_a_stroke_style() {
|
||||
let mut st = card_style();
|
||||
st["borders"] =
|
||||
json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(1.0, 0.0, 0.0, 1.0), "position": 1, "thickness": 2}]);
|
||||
let mut d = document(&["P"]);
|
||||
with_shared(&mut d, vec![shared("CARD", "Card", st.clone())], vec![]);
|
||||
let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), st, "CARD")]);
|
||||
let stroke = node(&doc, "R").styles.stroke.clone().expect("stroke style linked");
|
||||
assert!(style(&doc, &stroke).name.starts_with("Card"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_shared_styles_are_listed() {
|
||||
let mut d = document(&["P"]);
|
||||
with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]);
|
||||
let (doc, _) = open(d, vec![]);
|
||||
assert!(doc.styles.iter().any(|s| s.name == "Card"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_links() {
|
||||
let enc = json!({"MSAttributedStringFontAttribute": {"_class": "fontDescriptor", "attributes": {"name": "Helvetica-Bold", "size": 32}},
|
||||
"MSAttributedStringColorAttribute": color(1.0, 0.0, 0.0, 1.0),
|
||||
"paragraphStyle": {"_class": "paragraphStyle", "alignment": 0, "maximumLineHeight": 40, "minimumLineHeight": 40}});
|
||||
let st = json!({"_class": "style", "textStyle": {"_class": "textStyle", "encodedAttributes": enc, "verticalAlignment": 0}});
|
||||
let mut d = document(&["P"]);
|
||||
with_shared(&mut d, vec![], vec![shared("H1", "Heading/H1", st.clone())]);
|
||||
let t = linked(text("Title", 0.0, 0.0, 200.0, 40.0, "Hello", &[(0, 5, "Helvetica-Bold", 32.0, [1.0, 0.0, 0.0, 1.0])]), st, "H1");
|
||||
let (doc, _) = open(d, vec![t]);
|
||||
let n = node(&doc, "Title");
|
||||
let sid = n.styles.text.clone().expect("text style linked");
|
||||
let s = style(&doc, &sid);
|
||||
assert_eq!(s.name, "Heading/H1");
|
||||
let StyleKind::Text { style: ts } = &s.kind else { panic!("not a text style") };
|
||||
assert_eq!((ts.font_family.as_str(), ts.font_style.as_str(), ts.font_size), ("Helvetica", "Bold", 32.0));
|
||||
assert!(n.fills.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locally_changed_style_keeps_link() {
|
||||
let mut local = card_style();
|
||||
local["fills"] = json!([solid_fill(0.0, 1.0, 0.0, 1.0)]);
|
||||
let mut d = document(&["P"]);
|
||||
with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]);
|
||||
let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), local, "CARD")]);
|
||||
let n = node(&doc, "R");
|
||||
assert!(n.styles.fill.is_some());
|
||||
assert!(matches!(n.fills[0].kind, PaintKind::Solid { color } if color.g == 1.0 && color.b == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_styles_too() {
|
||||
let mut d = document(&["P"]);
|
||||
d["foreignLayerStyles"] = json!([{"_class": "MSImmutableForeignLayerStyle", "sourceLibraryName": "Kit", "localSharedStyle": shared("FCARD", "Kit card", card_style())}]);
|
||||
let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "FCARD")]);
|
||||
assert_eq!(style(&doc, node(&doc, "R").styles.fill.as_deref().unwrap()).name, "Kit card");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatches_are_color_variables() {
|
||||
let mut d = document(&["P"]);
|
||||
d["sharedSwatches"] = json!({"_class": "swatchContainer", "objects": [{"_class": "swatch", "do_objectID": "SW1", "name": "Brand/Blue", "value": color(0.06, 0.38, 1.0, 1.0)}]});
|
||||
d["foreignSwatches"] = json!([{"_class": "MSImmutableForeignSwatch", "sourceLibraryName": "Kit", "localSwatch": {"_class": "swatch", "do_objectID": "SW2", "name": "Kit/Red", "value": color(1.0, 0.0, 0.0, 1.0)}}]);
|
||||
let mut fill = solid_fill(0.06, 0.38, 1.0, 1.0);
|
||||
fill["color"]["swatchID"] = json!("SW1");
|
||||
let mut fill2 = solid_fill(1.0, 0.0, 0.0, 1.0);
|
||||
fill2["color"]["swatchID"] = json!("SW2");
|
||||
let (doc, _) = open(
|
||||
d,
|
||||
vec![styled(rect("R", 0.0, 0.0, 50.0, 50.0), "fills", json!([fill])), styled(rect("S", 0.0, 0.0, 50.0, 50.0), "fills", json!([fill2]))],
|
||||
);
|
||||
let coll = doc.variables.collections.iter().find(|c| c.name == "Color Variables").expect("collection");
|
||||
let var = doc.variables.variables.iter().find(|v| v.name == "Brand/Blue").expect("variable");
|
||||
assert_eq!(var.collection, coll.id);
|
||||
assert!(doc.variables.variables.iter().any(|v| v.name == "Kit/Red"));
|
||||
assert_eq!(node(&doc, "R").bound.get("fill"), Some(&var.id));
|
||||
assert!(node(&doc, "S").bound.contains_key("fill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_shared_ids_are_ignored() {
|
||||
let (doc, _) = open(document(&["P"]), vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "NOPE")]);
|
||||
let n = node(&doc, "R");
|
||||
assert!(n.styles.fill.is_none());
|
||||
let mut f = solid_fill(0.0, 0.0, 0.0, 1.0);
|
||||
f["color"]["swatchID"] = json!("NOPE");
|
||||
let (doc, _) =
|
||||
open(document(&["P"]), vec![styled(text("T", 0.0, 0.0, 10.0, 10.0, "x", &[(0, 1, "Helvetica", 9.0, BLACK)]), "fills", json!([f]))]);
|
||||
assert!(node(&doc, "T").bound.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Sketch 2025+ stacks (`groupLayout` `MSImmutableFlexGroupLayout`) → REST auto layout.
|
||||
//!
|
||||
//! The enums follow the order Sketch's plugin API documents them in (checked against files):
|
||||
//! direction row, column; justify start, center, end, between, around, evenly; align items start,
|
||||
//! center, end, stretch, none; stacking order first on top, last on top. Sizing (`horizontalSizing`,
|
||||
//! `verticalSizing`) is fixed, fit (hug), fill, relative. Sketch lists a stack's first item last
|
||||
//! (on top in the layer list), so the flow order is the reverse of its `layers`.
|
||||
|
||||
use serde_json::{Map as JsonMap, Value, json};
|
||||
|
||||
/// Whether `l` is a stack.
|
||||
pub(crate) fn is_stack(l: &Value) -> bool {
|
||||
l.get("groupLayout").and_then(|g| g.get("_class")).and_then(Value::as_str) == Some("MSImmutableFlexGroupLayout")
|
||||
}
|
||||
|
||||
/// Make `node` (the REST node of frame `l`, its children already built in Sketch's order) an auto
|
||||
/// layout frame when `l` is a stack.
|
||||
pub(crate) fn apply(l: &Value, node: &mut JsonMap<String, Value>) {
|
||||
if !is_stack(l) || !matches!(node.get("type").and_then(Value::as_str), Some("FRAME" | "COMPONENT" | "INSTANCE")) {
|
||||
return;
|
||||
}
|
||||
let Some(g) = l.get("groupLayout") else { return };
|
||||
let int = |k: &str| g.get(k).and_then(Value::as_i64).unwrap_or(0);
|
||||
let num = |v: &Value, k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(0.0).clamp(0.0, 1e5);
|
||||
let row = int("flexDirection") == 0;
|
||||
node.insert("layoutMode".into(), json!(if row { "HORIZONTAL" } else { "VERTICAL" }));
|
||||
node.insert("itemSpacing".into(), json!(num(g, "allGuttersGap")));
|
||||
node.insert(
|
||||
"primaryAxisAlignItems".into(),
|
||||
json!(match int("justifyContent") {
|
||||
1 => "CENTER",
|
||||
2 => "MAX",
|
||||
3 => "SPACE_BETWEEN",
|
||||
4 => "SPACE_AROUND",
|
||||
5 => "SPACE_EVENLY",
|
||||
_ => "MIN",
|
||||
}),
|
||||
);
|
||||
let stretch = int("alignItems") == 3;
|
||||
node.insert(
|
||||
"counterAxisAlignItems".into(),
|
||||
json!(match int("alignItems") {
|
||||
1 => "CENTER",
|
||||
2 => "MAX",
|
||||
_ => "MIN",
|
||||
}),
|
||||
);
|
||||
if g.get("wrappingEnabled").and_then(Value::as_bool) == Some(true) {
|
||||
node.insert("layoutWrap".into(), json!("WRAP"));
|
||||
node.insert("counterAxisSpacing".into(), json!(num(g, "crossAxisGutterGap")));
|
||||
}
|
||||
for (k, rest) in
|
||||
[("leftPadding", "paddingLeft"), ("topPadding", "paddingTop"), ("rightPadding", "paddingRight"), ("bottomPadding", "paddingBottom")]
|
||||
{
|
||||
node.insert(rest.into(), json!(num(l, k)));
|
||||
}
|
||||
node.insert("itemReverseZIndex".into(), json!(int("stackingOrder") == 0));
|
||||
node.insert("strokesIncludedInLayout".into(), json!(g.get("bordersAffectLayout").and_then(Value::as_bool) == Some(true)));
|
||||
// The stack's own sizing: fit hugs its content.
|
||||
let (main, cross) = if row { ("horizontalSizing", "verticalSizing") } else { ("verticalSizing", "horizontalSizing") };
|
||||
let mode = |k: &str| if l.get(k).and_then(Value::as_i64) == Some(1) { "AUTO" } else { "FIXED" };
|
||||
node.insert("primaryAxisSizingMode".into(), json!(mode(main)));
|
||||
node.insert("counterAxisSizingMode".into(), json!(mode(cross)));
|
||||
// Items: flow order, sizing, absolute ones.
|
||||
// Source layers by id (the first of a repeated id).
|
||||
let mut sources: std::collections::BTreeMap<&str, &Value> = std::collections::BTreeMap::new();
|
||||
for src in super::tree::layers(l) {
|
||||
if let Some(id) = src.get("do_objectID").and_then(Value::as_str) {
|
||||
sources.entry(id).or_insert(src);
|
||||
}
|
||||
}
|
||||
if let Some(kids) = node.get_mut("children").and_then(Value::as_array_mut) {
|
||||
// Children were built one per source layer, skipped ones aside: match them by id.
|
||||
for kid in kids.iter_mut() {
|
||||
let Some(src) = kid.get("id").and_then(Value::as_str).and_then(|id| sources.get(id).copied()) else { continue };
|
||||
let Some(m) = kid.as_object_mut() else { continue };
|
||||
item(src, m, row, stretch);
|
||||
}
|
||||
kids.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
/// An item's place in its stack: absolute, or sized fixed, hugging or filling.
|
||||
fn item(src: &Value, m: &mut JsonMap<String, Value>, row: bool, stretch: bool) {
|
||||
if src.get("flexItem").and_then(|f| f.get("ignoreLayout")).and_then(Value::as_bool) == Some(true) {
|
||||
m.insert("layoutPositioning".into(), json!("ABSOLUTE"));
|
||||
return;
|
||||
}
|
||||
let sizing = |k: &str| match src.get(k).and_then(Value::as_i64) {
|
||||
Some(1) => "HUG",
|
||||
Some(2) => "FILL",
|
||||
_ => "FIXED",
|
||||
};
|
||||
let (h, v) = (sizing("horizontalSizing"), sizing("verticalSizing"));
|
||||
m.insert("layoutSizingHorizontal".into(), json!(h));
|
||||
m.insert("layoutSizingVertical".into(), json!(v));
|
||||
let (main, cross) = if row { (h, v) } else { (v, h) };
|
||||
if main == "FILL" {
|
||||
m.insert("layoutGrow".into(), json!(1));
|
||||
}
|
||||
if cross == "FILL" || stretch {
|
||||
m.insert("layoutAlign".into(), json!("STRETCH"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
//! Sketch styles → REST paints, strokes, effects, opacity and blend modes.
|
||||
//!
|
||||
//! A layer's `style` lists `fills` and `borders` (each a colour, gradient or, for fills, an image
|
||||
//! pattern, with its own `contextSettings` opacity and blend mode), `borderOptions` (dashes, caps,
|
||||
//! joins), `shadows`, `innerShadows`, one `blur` and the layer's own `contextSettings`. Every
|
||||
//! border has its own thickness and position; FigCraft strokes share one, so the first enabled
|
||||
//! border's is used.
|
||||
|
||||
use serde_json::{Map as JsonMap, Value, json};
|
||||
|
||||
use super::path::parse_point;
|
||||
use super::tree::{Cx, color};
|
||||
|
||||
/// Most paints or effects read from one list.
|
||||
const MAX_LIST: usize = 64;
|
||||
|
||||
/// Write the paints, strokes, effects, opacity and blend mode of `style` into `node`, a layer of
|
||||
/// `w`×`h`. Lists the style doesn't have are left as they are.
|
||||
pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap<String, Value>) {
|
||||
let fills = fills(cx, style, w, h);
|
||||
if !fills.is_empty() {
|
||||
node.insert("fills".into(), Value::Array(fills));
|
||||
}
|
||||
borders(cx, style, w, h, node);
|
||||
let effects = effects(cx, style);
|
||||
if !effects.is_empty() {
|
||||
node.insert("effects".into(), Value::Array(effects));
|
||||
}
|
||||
if let Some(ctx) = style.get("contextSettings") {
|
||||
if let Some(o) = num(ctx, "opacity") {
|
||||
node.insert("opacity".into(), json!(o.clamp(0.0, 1.0)));
|
||||
}
|
||||
if let Some(b) = ctx.get("blendMode").and_then(Value::as_i64).filter(|b| *b != 0).and_then(blend) {
|
||||
node.insert("blendMode".into(), json!(b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A style's fills as REST paints.
|
||||
pub(crate) fn fills(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec<Value> {
|
||||
// Tints (`layeringType` 1) colour the layers inside; they aren't paints of the layer.
|
||||
list(style, "fills").iter().filter(|f| f.get("layeringType").and_then(Value::as_i64) != Some(1)).filter_map(|f| paint(cx, f, w, h)).collect()
|
||||
}
|
||||
|
||||
/// A style's borders as REST paints.
|
||||
pub(crate) fn border_paints(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec<Value> {
|
||||
list(style, "borders").iter().filter_map(|b| paint(cx, b, w, h)).collect()
|
||||
}
|
||||
|
||||
/// Remove what [`apply`] writes, so another style can take its place.
|
||||
pub(crate) fn clear(node: &mut JsonMap<String, Value>) {
|
||||
for key in [
|
||||
"fills",
|
||||
"strokes",
|
||||
"strokeWeight",
|
||||
"strokeAlign",
|
||||
"strokeDashes",
|
||||
"strokeCap",
|
||||
"strokeCapStart",
|
||||
"strokeCapEnd",
|
||||
"strokeJoin",
|
||||
"effects",
|
||||
"opacity",
|
||||
"blendMode",
|
||||
] {
|
||||
node.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// One fill or border as a REST paint; `None` for kinds FigCraft doesn't have (with a warning).
|
||||
fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option<Value> {
|
||||
let mut p = match f.get("fillType").and_then(Value::as_i64).unwrap_or(0) {
|
||||
0 => json!({"type": "SOLID", "color": f.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}))}),
|
||||
1 => gradient(f.get("gradient")?, w, h)?,
|
||||
4 => {
|
||||
let Some(r) = f.get("image").and_then(|i| i.get("_ref")).and_then(Value::as_str) else {
|
||||
cx.warn("image fills stored inside the JSON were skipped");
|
||||
return None;
|
||||
};
|
||||
let mut p = json!({"type": "IMAGE", "imageRef": r});
|
||||
match f.get("patternFillType").and_then(Value::as_i64).unwrap_or(1) {
|
||||
0 => {
|
||||
p["scaleMode"] = json!("TILE");
|
||||
p["scalingFactor"] = json!(num(f, "patternTileScale").filter(|s| *s > 0.0).unwrap_or(1.0));
|
||||
}
|
||||
2 => {
|
||||
p["scaleMode"] = json!("STRETCH");
|
||||
p["imageTransform"] = json!([[1, 0, 0], [0, 1, 0]]);
|
||||
}
|
||||
3 => p["scaleMode"] = json!("FIT"),
|
||||
_ => p["scaleMode"] = json!("FILL"),
|
||||
}
|
||||
p
|
||||
}
|
||||
5 => {
|
||||
cx.warn("noise fills were skipped");
|
||||
return None;
|
||||
}
|
||||
other => {
|
||||
cx.warn(format!("fills of type {other} were skipped"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
p["visible"] = json!(f.get("isEnabled").and_then(Value::as_bool).unwrap_or(true));
|
||||
// A colour variable binds a plain colour (a gradient's or image's `color` is unused).
|
||||
if f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0
|
||||
&& let Some(var) = swatch(cx, f.get("color"))
|
||||
{
|
||||
p["boundVariables"] = var;
|
||||
}
|
||||
if let Some(ctx) = f.get("contextSettings") {
|
||||
if let Some(o) = num(ctx, "opacity") {
|
||||
p["opacity"] = json!(o.clamp(0.0, 1.0));
|
||||
}
|
||||
if let Some(b) = ctx.get("blendMode").and_then(Value::as_i64).and_then(blend) {
|
||||
p["blendMode"] = json!(b);
|
||||
}
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// A gradient fill: `from` and `to` in 0–1 of the layer; a radial gradient's `elipseLength` is its
|
||||
/// width across the main axis as a share of the main axis, measured in pixels.
|
||||
fn gradient(g: &Value, w: f64, h: f64) -> Option<Value> {
|
||||
let point = |k: &str| g.get(k).and_then(Value::as_str).and_then(parse_point);
|
||||
let (from, to) = (point("from").unwrap_or((0.5, 0.0)), point("to").unwrap_or((0.5, 1.0)));
|
||||
let stops: Vec<Value> = list(g, "stops")
|
||||
.iter()
|
||||
.filter_map(|s| Some(json!({"position": num(s, "position")?.clamp(0.0, 1.0), "color": s.get("color").and_then(color)?})))
|
||||
.collect();
|
||||
let (ty, handles) = match g.get("gradientType").and_then(Value::as_i64).unwrap_or(0) {
|
||||
1 => {
|
||||
let e = num(g, "elipseLength").filter(|e| *e > 0.0).unwrap_or(1.0);
|
||||
let (dx, dy) = ((to.0 - from.0) * w, (to.1 - from.1) * h);
|
||||
let across = if w > 0.0 && h > 0.0 { (from.0 - dy * e / w, from.1 + dx * e / h) } else { from };
|
||||
("GRADIENT_RADIAL", json!([pt(from), pt(to), pt(across)]))
|
||||
}
|
||||
// Sketch starts on the right and turns clockwise; from/to don't change it.
|
||||
2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((1.0, 0.5)), pt((0.5, 1.0))])),
|
||||
// Square to the line in pixels, as Sketch draws it (FigCraft's default is square in 0–1).
|
||||
_ => {
|
||||
let (dx, dy) = ((to.0 - from.0) * w, (to.1 - from.1) * h);
|
||||
let across = if w > 0.0 && h > 0.0 { (from.0 - dy / w, from.1 + dx / h) } else { (from.0 - (to.1 - from.1), from.1 + (to.0 - from.0)) };
|
||||
("GRADIENT_LINEAR", json!([pt(from), pt(to), pt(across)]))
|
||||
}
|
||||
};
|
||||
Some(json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops}))
|
||||
}
|
||||
|
||||
fn pt((x, y): (f64, f64)) -> Value {
|
||||
json!({"x": x, "y": y})
|
||||
}
|
||||
|
||||
/// Borders → stroke paints, and the first enabled border's thickness and position.
|
||||
fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap<String, Value>) {
|
||||
let list = list(style, "borders");
|
||||
let strokes = border_paints(cx, style, w, h);
|
||||
if strokes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let enabled: Vec<&Value> = list.iter().filter(|b| b.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)).collect();
|
||||
let first = enabled.first().copied().or(list.first());
|
||||
let shape = |b: &Value| (num(b, "thickness").unwrap_or(1.0), b.get("position").and_then(Value::as_i64).unwrap_or(0));
|
||||
if let Some(b) = first {
|
||||
let (t, pos) = shape(b);
|
||||
if enabled.iter().any(|o| shape(o) != (t, pos)) {
|
||||
cx.warn("layers with several borders of different widths or positions use the first one's");
|
||||
}
|
||||
node.insert("strokeWeight".into(), json!(t.clamp(0.0, 1e6)));
|
||||
node.insert(
|
||||
"strokeAlign".into(),
|
||||
json!(match pos {
|
||||
1 => "INSIDE",
|
||||
2 => "OUTSIDE",
|
||||
_ => "CENTER",
|
||||
}),
|
||||
);
|
||||
}
|
||||
node.insert("strokes".into(), Value::Array(strokes));
|
||||
// Per-side thickness (Sketch 2025+): a side is as thick as the border less its inset.
|
||||
let first = list.iter().find(|b| b.get("isEnabled").and_then(Value::as_bool) != Some(false));
|
||||
if let Some(b) = first
|
||||
&& let Some(sides) = b.get("sides")
|
||||
{
|
||||
let t = num(b, "thickness").unwrap_or(1.0).clamp(0.0, 1e6);
|
||||
let side = |k: &str| (t - num(sides, k).unwrap_or(0.0)).clamp(0.0, 1e6);
|
||||
node.insert(
|
||||
"individualStrokeWeights".into(),
|
||||
json!({"top": side("topInset"), "right": side("rightInset"), "bottom": side("bottomInset"), "left": side("leftInset")}),
|
||||
);
|
||||
}
|
||||
if let Some(o) = style.get("borderOptions") {
|
||||
let dashes: Vec<f64> = o
|
||||
.get("dashPattern")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(Value::as_f64)
|
||||
.filter(|d| d.is_finite() && *d >= 0.0)
|
||||
.take(MAX_LIST)
|
||||
.collect();
|
||||
if !dashes.is_empty() {
|
||||
node.insert("strokeDashes".into(), json!(dashes));
|
||||
}
|
||||
node.insert(
|
||||
"strokeCap".into(),
|
||||
json!(match o.get("lineCapStyle").and_then(Value::as_i64) {
|
||||
Some(1) => "ROUND",
|
||||
Some(2) => "SQUARE",
|
||||
_ => "NONE",
|
||||
}),
|
||||
);
|
||||
node.insert(
|
||||
"strokeJoin".into(),
|
||||
json!(match o.get("lineJoinStyle").and_then(Value::as_i64) {
|
||||
Some(1) => "ROUND",
|
||||
Some(2) => "BEVEL",
|
||||
_ => "MITER",
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (key, field) in [("startMarkerType", "strokeCapStart"), ("endMarkerType", "strokeCapEnd")] {
|
||||
let cap = match style.get(key).and_then(Value::as_i64).unwrap_or(0) {
|
||||
0 => continue,
|
||||
1 => "LINE_ARROW",
|
||||
2 => "TRIANGLE_ARROW",
|
||||
5 => "CIRCLE_FILLED",
|
||||
_ => {
|
||||
cx.warn("line ends other than arrows and filled circles were drawn without an end");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
node.insert(field.into(), json!(cap));
|
||||
}
|
||||
}
|
||||
|
||||
/// Shadows, inner shadows and the blur as REST effects.
|
||||
pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
for (key, ty) in [("shadows", "DROP_SHADOW"), ("innerShadows", "INNER_SHADOW")] {
|
||||
for s in list(style, key) {
|
||||
// Sketch 2025 and newer keep inner shadows in the one list, marked.
|
||||
let ty = if s.get("isInnerShadow").and_then(Value::as_bool) == Some(true) { "INNER_SHADOW" } else { ty };
|
||||
let mut c = s.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 0.5}));
|
||||
let o = s.get("contextSettings").and_then(|c| num(c, "opacity")).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
c["a"] = json!(c["a"].as_f64().unwrap_or(1.0) * o);
|
||||
out.push(json!({
|
||||
"type": ty,
|
||||
"visible": s.get("isEnabled").and_then(Value::as_bool).unwrap_or(true),
|
||||
"radius": num(s, "blurRadius").unwrap_or(0.0).clamp(0.0, 1e6),
|
||||
"spread": num(s, "spread").unwrap_or(0.0),
|
||||
"offset": {"x": num(s, "offsetX").unwrap_or(0.0), "y": num(s, "offsetY").unwrap_or(0.0)},
|
||||
"color": c,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// One `blur` (older files) or a list of `blurs` (Sketch 2025 and newer).
|
||||
let one = style.get("blur").into_iter();
|
||||
let many = style.get("blurs").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().take(MAX_LIST);
|
||||
for b in one.chain(many).filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) {
|
||||
let radius = num(b, "radius").unwrap_or(0.0).clamp(0.0, 1e6);
|
||||
match b.get("type").and_then(Value::as_i64).unwrap_or(0) {
|
||||
0 => out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius})),
|
||||
3 => out.push(background_blur(b, radius)),
|
||||
4 => out.push(json!({
|
||||
"type": "GLASS",
|
||||
"visible": true,
|
||||
"radius": radius,
|
||||
"refraction": num(b, "distortion").unwrap_or(0.5).clamp(0.0, 1.0),
|
||||
"depth": num(b, "depth").unwrap_or(0.5).clamp(0.0, 1.0),
|
||||
})),
|
||||
_ => {
|
||||
// Motion and zoom blurs: a plain blur of half the radius softens about as much.
|
||||
cx.warn("motion and zoom blurs were drawn as plain blurs");
|
||||
out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius / 2.0}));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.truncate(MAX_LIST);
|
||||
out
|
||||
}
|
||||
|
||||
/// A background blur; a progressive one (`isProgressive`) ramps along its gradient, whose stops'
|
||||
/// opacity scales the radius at each end.
|
||||
fn background_blur(b: &Value, radius: f64) -> Value {
|
||||
let g = b.get("gradient").filter(|_| b.get("isProgressive").and_then(Value::as_bool) == Some(true));
|
||||
let Some(g) = g else { return json!({"type": "BACKGROUND_BLUR", "visible": true, "radius": radius}) };
|
||||
let stops = g.get("stops").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
let at = |s: Option<&Value>| s.and_then(|s| s.get("color")).and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0) * radius;
|
||||
let end = |k: &str, d: (f64, f64)| {
|
||||
let (x, y) = g.get(k).and_then(Value::as_str).and_then(super::path::parse_point).unwrap_or(d);
|
||||
json!({"x": x.clamp(-1e3, 1e3), "y": y.clamp(-1e3, 1e3)})
|
||||
};
|
||||
json!({
|
||||
"type": "BACKGROUND_BLUR",
|
||||
"visible": true,
|
||||
"blurType": "PROGRESSIVE",
|
||||
"startRadius": at(stops.first()),
|
||||
"radius": at(stops.last()),
|
||||
"startOffset": end("from", (0.5, 0.0)),
|
||||
"endOffset": end("to", (0.5, 1.0)),
|
||||
})
|
||||
}
|
||||
|
||||
/// A bitmap's colour adjustments (`colorControls`: saturation and contrast 0–2 with 1 neutral,
|
||||
/// brightness −1–1, hue in radians) as REST image filters; hue has no filter (a note).
|
||||
pub(crate) fn color_controls(cx: &mut Cx, style: Option<&Value>) -> Option<Value> {
|
||||
let c = style?.get("colorControls").filter(|c| c.get("isEnabled").and_then(Value::as_bool) == Some(true))?;
|
||||
let get = |k: &str, neutral: f64| num(c, k).unwrap_or(neutral);
|
||||
if get("hue", 0.0).abs() > 1e-6 {
|
||||
cx.warn("hue changes of bitmaps were skipped");
|
||||
}
|
||||
Some(json!({
|
||||
"saturation": (get("saturation", 1.0) - 1.0).clamp(-1.0, 1.0),
|
||||
"contrast": (get("contrast", 1.0) - 1.0).clamp(-1.0, 1.0),
|
||||
"exposure": get("brightness", 0.0).clamp(-1.0, 1.0),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The variable binding of a colour that names a swatch the file has (`{color: alias}`).
|
||||
pub(crate) fn swatch(cx: &Cx, c: Option<&Value>) -> Option<Value> {
|
||||
let id = c?.get("swatchID").and_then(Value::as_str).filter(|id| cx.shared.has_swatch(id))?;
|
||||
Some(json!({"color": {"type": "VARIABLE_ALIAS", "id": id}}))
|
||||
}
|
||||
|
||||
/// The tint a style gives the layers inside: its first enabled plain fill with `layeringType` 1
|
||||
/// (Sketch 2025 and newer), or with no `layeringType` when `legacy` (older files: a group's or
|
||||
/// instance's fill was its tint). Sketch draws a tint as the colour of everything inside.
|
||||
pub(crate) fn tint_of(style: Option<&Value>, legacy: bool) -> Option<Value> {
|
||||
let fills = style?.get("fills")?.as_array()?;
|
||||
let f = fills.iter().filter(|f| f.get("isEnabled").and_then(Value::as_bool) != Some(false)).find(|f| {
|
||||
match f.get("layeringType").and_then(Value::as_i64) {
|
||||
Some(t) => t == 1,
|
||||
None => legacy,
|
||||
}
|
||||
})?;
|
||||
(f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0).then(|| f.get("color").filter(|c| c.is_object()).cloned()).flatten()
|
||||
}
|
||||
|
||||
/// Recolour `layers` (Sketch JSON) with tint `color`: every enabled fill and border takes it,
|
||||
/// text takes it, nested instances take it as their own tint. Opacity stays as it was.
|
||||
pub(crate) fn tint_layers(layers: &mut [Value], color: &Value, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for l in layers {
|
||||
if let Some(st) = l.get_mut("style").and_then(Value::as_object_mut) {
|
||||
for key in ["fills", "borders"] {
|
||||
for p in st.get_mut(key).and_then(Value::as_array_mut).into_iter().flatten() {
|
||||
if let Some(m) = p.as_object_mut()
|
||||
&& m.get("isEnabled").and_then(Value::as_bool) != Some(false)
|
||||
&& !matches!(m.get("fillType").and_then(Value::as_i64), Some(4))
|
||||
{
|
||||
let c = tinted(m.get("color"), color);
|
||||
m.insert("fillType".into(), json!(0));
|
||||
m.insert("color".into(), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(enc) = st.get_mut("textStyle").and_then(|t| t.get_mut("encodedAttributes")).and_then(Value::as_object_mut) {
|
||||
let c = tinted(enc.get("MSAttributedStringColorAttribute"), color);
|
||||
enc.insert("MSAttributedStringColorAttribute".into(), c);
|
||||
}
|
||||
}
|
||||
if let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) {
|
||||
for r in runs.iter_mut().filter_map(|r| r.get_mut("attributes")).filter_map(Value::as_object_mut) {
|
||||
let c = tinted(r.get("MSAttributedStringColorAttribute"), color);
|
||||
r.insert("MSAttributedStringColorAttribute".into(), c);
|
||||
}
|
||||
}
|
||||
if l.get("_class").and_then(Value::as_str) == Some("symbolInstance") {
|
||||
set_tint(l, color);
|
||||
}
|
||||
if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) {
|
||||
tint_layers(kids, color, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour `old` (Sketch JSON) tinted with `tint`: the tint's colour, with both opacities.
|
||||
fn tinted(old: Option<&Value>, tint: &Value) -> Value {
|
||||
let alpha = |c: Option<&Value>| c.and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
let mut c = tint.clone();
|
||||
if let Some(m) = c.as_object_mut() {
|
||||
m.insert("alpha".into(), json!(alpha(Some(tint)) * alpha(old)));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Give layer `l` (Sketch JSON) tint `color`: as its one fill.
|
||||
pub(crate) fn set_tint(l: &mut Value, color: &Value) {
|
||||
let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 0, "color": color});
|
||||
if let Some(m) = l.as_object_mut() {
|
||||
let st = m.entry("style").or_insert_with(|| json!({"_class": "style"}));
|
||||
if let Some(st) = st.as_object_mut() {
|
||||
st.insert("fills".into(), json!([fill]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch's blend modes (0 normal … 17 plus lighter) by REST name.
|
||||
fn blend(mode: i64) -> Option<&'static str> {
|
||||
const MODES: [&str; 18] = [
|
||||
"NORMAL",
|
||||
"DARKEN",
|
||||
"MULTIPLY",
|
||||
"COLOR_BURN",
|
||||
"LIGHTEN",
|
||||
"SCREEN",
|
||||
"COLOR_DODGE",
|
||||
"OVERLAY",
|
||||
"SOFT_LIGHT",
|
||||
"HARD_LIGHT",
|
||||
"DIFFERENCE",
|
||||
"EXCLUSION",
|
||||
"HUE",
|
||||
"SATURATION",
|
||||
"COLOR",
|
||||
"LUMINOSITY",
|
||||
"LINEAR_BURN",
|
||||
"LINEAR_DODGE",
|
||||
];
|
||||
MODES.get(usize::try_from(mode).ok()?).copied()
|
||||
}
|
||||
|
||||
fn list<'v>(v: &'v Value, key: &str) -> &'v [Value] {
|
||||
let l = v.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
l.get(..MAX_LIST).unwrap_or(l)
|
||||
}
|
||||
|
||||
fn num(v: &Value, key: &str) -> Option<f64> {
|
||||
v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite())
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Sketch styles → paints, strokes, effects, opacity, blend modes and masks.
|
||||
|
||||
use figcraft_doc::color::{BlendMode, ColorProfile, GradientKind, ImageScaleMode, PaintKind};
|
||||
use figcraft_doc::{Document, EffectKind, NodeKind, StrokeAlign, StrokeCap, StrokeJoin};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fixture::*;
|
||||
use super::import;
|
||||
|
||||
fn open_doc(document_json: Value, layers: Vec<Value>, files: &[(&str, &[u8])]) -> (Document, Vec<String>) {
|
||||
let z = sketch_zip("50.2", 103, document_json, &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], files);
|
||||
import(&z, "f").unwrap()
|
||||
}
|
||||
|
||||
fn open(layers: Vec<Value>) -> (Document, Vec<String>) {
|
||||
open_doc(document(&["P"]), layers, &[])
|
||||
}
|
||||
|
||||
fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node {
|
||||
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}"))
|
||||
}
|
||||
|
||||
fn r(name: &str) -> Value {
|
||||
rect(name, 0.0, 0.0, 100.0, 50.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_and_disabled_fills() {
|
||||
let mut second = solid_fill(0.0, 0.0, 1.0, 1.0);
|
||||
second["isEnabled"] = json!(false);
|
||||
let mut half = solid_fill(1.0, 0.0, 0.0, 1.0);
|
||||
half["contextSettings"]["opacity"] = json!(0.5);
|
||||
let (doc, _) = open(vec![styled(r("Two"), "fills", json!([half, second]))]);
|
||||
let fills = &node(&doc, "Two").fills;
|
||||
assert_eq!(fills.len(), 2);
|
||||
assert!(matches!(fills[0].kind, PaintKind::Solid { color } if color.r == 1.0 && color.b == 0.0));
|
||||
assert!((fills[0].opacity - 0.5).abs() < 1e-6 && fills[0].visible);
|
||||
assert!(!fills[1].visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_shape_group_takes_the_groups_style() {
|
||||
let inner = with(r("Inner"), "booleanOperation", json!(-1));
|
||||
let g = styled(shape_group("Button", 0.0, 0.0, 100.0, 50.0, vec![inner]), "fills", json!([solid_fill(1.0, 0.0, 0.0, 1.0)]));
|
||||
let (doc, _) = open(vec![g]);
|
||||
assert!(matches!(node(&doc, "Button").fills[0].kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_shape_group_takes_the_groups_style() {
|
||||
let a = with(rect("A", 0.0, 0.0, 50.0, 50.0), "booleanOperation", json!(-1));
|
||||
let b = with(rect("B", 10.0, 10.0, 20.0, 20.0), "booleanOperation", json!(1));
|
||||
let g = styled(shape_group("Ring", 0.0, 0.0, 50.0, 50.0, vec![a, b]), "fills", json!([solid_fill(0.0, 1.0, 0.0, 1.0)]));
|
||||
let (doc, _) = open(vec![g]);
|
||||
assert_eq!(node(&doc, "Ring").fills.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients() {
|
||||
let stops = json!([{"_class": "gradientStop", "position": 0, "color": color(1.0, 0.0, 0.0, 1.0)},
|
||||
{"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 1.0, 1.0)}]);
|
||||
let grad = |ty: i64, ellipse: f64| {
|
||||
json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0),
|
||||
"gradient": {"_class": "gradient", "gradientType": ty, "from": "{0.5, 0}", "to": "{0.5, 1}", "elipseLength": ellipse, "stops": stops.clone()}})
|
||||
};
|
||||
let (doc, _) = open(vec![
|
||||
styled(r("Linear"), "fills", json!([grad(0, 0.0)])),
|
||||
styled(r("Radial"), "fills", json!([grad(1, 0.5)])),
|
||||
styled(r("Angular"), "fills", json!([grad(2, 0.0)])),
|
||||
]);
|
||||
let g = |name: &str| match &node(&doc, name).fills[0].kind {
|
||||
PaintKind::Gradient(g) => g.clone(),
|
||||
other => panic!("{name}: {other:?}"),
|
||||
};
|
||||
let lin = g("Linear");
|
||||
assert_eq!(lin.kind, GradientKind::Linear);
|
||||
assert_eq!((lin.from, lin.to), ((0.5, 0.0), (0.5, 1.0)));
|
||||
assert_eq!(lin.stops.len(), 2);
|
||||
assert_eq!(lin.stops[1].color.b, 1.0);
|
||||
assert_eq!(g("Radial").kind, GradientKind::Radial);
|
||||
// Sketch starts an angular gradient on the right and turns clockwise, whatever its from/to.
|
||||
let ang = g("Angular");
|
||||
assert_eq!(ang.kind, GradientKind::Angular);
|
||||
assert_eq!((ang.from, ang.to, ang.width), ((0.5, 0.5), (1.0, 0.5), (0.5, 1.0)));
|
||||
// The radial gradient's width handle: half the length, across the main axis (in pixels).
|
||||
let rad = g("Radial");
|
||||
let (dx, dy) = ((rad.width.0 - rad.from.0) * 100.0, (rad.width.1 - rad.from.1) * 50.0);
|
||||
assert!(((dx * dx + dy * dy).sqrt() - 25.0).abs() < 1e-6, "{rad:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_fill_modes() {
|
||||
let png = {
|
||||
let mut out = Vec::new();
|
||||
image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
};
|
||||
let pat = |mode: i64| {
|
||||
json!({"_class": "fill", "isEnabled": true, "fillType": 4, "patternFillType": mode, "patternTileScale": 1,
|
||||
"image": {"_class": "MSJSONFileReference", "_ref": "images/p1.png"}})
|
||||
};
|
||||
let layers = (0..4).map(|m| styled(r(&format!("M{m}")), "fills", json!([pat(m)]))).collect();
|
||||
let (doc, _) = open_doc(document(&["P"]), layers, &[("images/p1.png", &png)]);
|
||||
let mode = |name: &str| match &node(&doc, name).fills[0].kind {
|
||||
PaintKind::Image(i) => i.scale_mode,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(mode("M0"), ImageScaleMode::Tile);
|
||||
assert_eq!(mode("M1"), ImageScaleMode::Fill);
|
||||
assert_eq!(mode("M2"), ImageScaleMode::Crop);
|
||||
assert_eq!(mode("M3"), ImageScaleMode::Fit);
|
||||
assert_eq!(doc.images.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_fill_warns() {
|
||||
let noise = json!({"_class": "fill", "isEnabled": true, "fillType": 5, "noiseIntensity": 0.3});
|
||||
let (_, warnings) = open(vec![styled(r("Noisy"), "fills", json!([noise]))]);
|
||||
assert!(warnings.iter().any(|w| w.contains("noise")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borders() {
|
||||
let border = |pos: i64, t: f64| json!({"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": pos, "thickness": t});
|
||||
let opts = json!({"_class": "borderOptions", "isEnabled": true, "dashPattern": [4, 2], "lineCapStyle": 1, "lineJoinStyle": 2});
|
||||
let (doc, warnings) = open(vec![
|
||||
styled(r("Center"), "borders", json!([border(0, 2.0)])),
|
||||
styled(styled(r("Inside"), "borders", json!([border(1, 3.0)])), "borderOptions", opts),
|
||||
styled(r("Outside"), "borders", json!([border(2, 1.0)])),
|
||||
styled(r("Two"), "borders", json!([border(1, 1.0), border(1, 5.0)])),
|
||||
]);
|
||||
let s = |n: &str| node(&doc, n).stroke.clone();
|
||||
assert_eq!((s("Center").align, s("Center").weight), (StrokeAlign::Center, 2.0));
|
||||
assert_eq!((s("Inside").align, s("Inside").weight), (StrokeAlign::Inside, 3.0));
|
||||
assert_eq!(s("Inside").dashes, vec![4.0, 2.0]);
|
||||
assert_eq!((s("Inside").cap_start, s("Inside").join), (StrokeCap::Round, StrokeJoin::Bevel));
|
||||
assert_eq!(s("Outside").align, StrokeAlign::Outside);
|
||||
assert_eq!(node(&doc, "Two").strokes.len(), 2);
|
||||
assert_eq!(s("Two").weight, 1.0);
|
||||
assert!(warnings.iter().any(|w| w.contains("borders")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_and_blurs() {
|
||||
let shadow = json!({"_class": "shadow", "isEnabled": true, "blurRadius": 8, "offsetX": 2, "offsetY": 4, "spread": 1, "color": color(0.0, 0.0, 0.0, 0.5),
|
||||
"contextSettings": {"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1}});
|
||||
let mut off = shadow.clone();
|
||||
off["isEnabled"] = json!(false);
|
||||
let blur = |ty: i64| json!({"_class": "blur", "isEnabled": true, "radius": 6, "type": ty});
|
||||
let (doc, warnings) = open(vec![
|
||||
styled(styled(r("Shadowed"), "shadows", json!([shadow, off])), "innerShadows", json!([shadow])),
|
||||
styled(r("Blurred"), "blur", blur(0)),
|
||||
styled(r("Frosted"), "blur", blur(3)),
|
||||
styled(r("Moving"), "blur", blur(1)),
|
||||
]);
|
||||
let fx = &node(&doc, "Shadowed").effects;
|
||||
assert_eq!(fx.len(), 3);
|
||||
match &fx[0].kind {
|
||||
EffectKind::DropShadow(s) => assert_eq!((s.offset, s.radius, s.spread, s.color.a), ((2.0, 4.0), 8.0, 1.0, 0.5)),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(!fx[1].visible);
|
||||
assert!(matches!(fx[2].kind, EffectKind::InnerShadow(_)));
|
||||
assert!(matches!(node(&doc, "Blurred").effects[0].kind, EffectKind::LayerBlur(_)));
|
||||
assert!(matches!(node(&doc, "Frosted").effects[0].kind, EffectKind::BackgroundBlur(_)));
|
||||
// Motion blur: drawn as a plain blur of half the radius.
|
||||
assert!(matches!(node(&doc, "Moving").effects[0].kind, EffectKind::LayerBlur(_)));
|
||||
assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_and_blend() {
|
||||
let modes = [
|
||||
BlendMode::Normal,
|
||||
BlendMode::Darken,
|
||||
BlendMode::Multiply,
|
||||
BlendMode::ColorBurn,
|
||||
BlendMode::Lighten,
|
||||
BlendMode::Screen,
|
||||
BlendMode::ColorDodge,
|
||||
BlendMode::Overlay,
|
||||
BlendMode::SoftLight,
|
||||
BlendMode::HardLight,
|
||||
BlendMode::Difference,
|
||||
BlendMode::Exclusion,
|
||||
BlendMode::Hue,
|
||||
BlendMode::Saturation,
|
||||
BlendMode::Color,
|
||||
BlendMode::Luminosity,
|
||||
BlendMode::PlusDarker,
|
||||
BlendMode::PlusLighter,
|
||||
];
|
||||
let layers = (0..modes.len())
|
||||
.map(|i| styled(r(&format!("B{i}")), "contextSettings", json!({"_class": "graphicsContextSettings", "opacity": 0.3, "blendMode": i})))
|
||||
.collect();
|
||||
let (doc, _) = open(layers);
|
||||
for (i, m) in modes.iter().enumerate() {
|
||||
let n = node(&doc, &format!("B{i}"));
|
||||
assert!((n.opacity - 0.3).abs() < 1e-6);
|
||||
// A normal layer passes through like any other.
|
||||
if i > 0 {
|
||||
assert_eq!(n.blend, *m, "mode {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_mask_chain() {
|
||||
let mask = with(r("Mask"), "hasClippingMask", json!(true));
|
||||
let a = r("A");
|
||||
let b = with(r("B"), "shouldBreakMaskChain", json!(true));
|
||||
let c = r("C");
|
||||
let (doc, _) = open(vec![group("G", 0.0, 0.0, 100.0, 50.0, vec![mask, a, b, c])]);
|
||||
let m = node(&doc, "Mask");
|
||||
assert!(m.is_mask);
|
||||
// The mask and what it clips share a group of their own, so B and C stay unclipped.
|
||||
let holder = m.parent.unwrap();
|
||||
let kids: Vec<&str> = doc.node(holder).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
|
||||
assert_eq!(kids, ["Mask", "A"]);
|
||||
assert_ne!(node(&doc, "B").parent, Some(holder));
|
||||
let bb = doc.world_bounds(node(&doc, "A").id).unwrap();
|
||||
assert_eq!((bb.x0, bb.y0), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_p3_document() {
|
||||
let mut d = document(&["P"]);
|
||||
d["colorSpace"] = json!(2);
|
||||
let (doc, _) = open_doc(d, vec![], &[]);
|
||||
assert_eq!(doc.color_profile, ColorProfile::DisplayP3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_on_frames_and_text_kinds_untouched() {
|
||||
// An artboard keeps its background when its style has no fills.
|
||||
let (doc, _) = open(vec![]);
|
||||
assert!(matches!(node(&doc, "Board").kind, NodeKind::Frame));
|
||||
assert_eq!(node(&doc, "Board").fills.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_group_masks_clip() {
|
||||
// Sketch 51 and older: the mask is a shape group.
|
||||
let inner = with(r("Outline"), "booleanOperation", json!(-1));
|
||||
let mask = with(shape_group("Card", 0.0, 0.0, 100.0, 50.0, vec![inner]), "hasClippingMask", json!(true));
|
||||
let a = rect("A", 0.0, 0.0, 300.0, 300.0);
|
||||
let b = with(r("B"), "shouldBreakMaskChain", json!(true));
|
||||
let (doc, _) = open(vec![group("G", 0.0, 0.0, 300.0, 300.0, vec![mask, a, b])]);
|
||||
let m = node(&doc, "Card");
|
||||
assert!(m.is_mask);
|
||||
let holder = doc.node(m.parent.unwrap()).unwrap();
|
||||
let kids: Vec<&str> = holder.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
|
||||
assert_eq!(kids, ["Card", "A"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_icon_keeps_its_geometry() {
|
||||
let oval = {
|
||||
let mut o = with(rect("Path", 0.0, 0.0, 67.0, 67.0), "booleanOperation", json!(-1));
|
||||
o["_class"] = json!("oval");
|
||||
o
|
||||
};
|
||||
let mask = styled(
|
||||
with(shape_group("Circle", 0.0, 0.0, 67.0, 67.0, vec![oval]), "hasClippingMask", json!(true)),
|
||||
"fills",
|
||||
json!([solid_fill(0.9, 0.9, 0.9, 1.0)]),
|
||||
);
|
||||
let p = |id: &str, x: f64, y: f64, w: f64, h: f64, op: i64| {
|
||||
with(shape_path(id, x, y, w, h, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true), "booleanOperation", json!(op))
|
||||
};
|
||||
let content = styled(
|
||||
shape_group(
|
||||
"Body",
|
||||
12.0,
|
||||
12.0,
|
||||
45.0,
|
||||
62.0,
|
||||
vec![p("P1", 9.0, 9.0, 22.0, 22.0, -1), p("P2", 25.0, 15.0, 20.0, 15.0, 0), p("P3", 0.0, 17.0, 31.0, 45.0, 0)],
|
||||
),
|
||||
"fills",
|
||||
json!([solid_fill(0.5, 0.5, 0.5, 1.0)]),
|
||||
);
|
||||
let (doc, _) = open(vec![group("Icon", 152.0, 20.0, 67.0, 67.0, vec![mask, content])]);
|
||||
let b = doc.world_bounds(node(&doc, "Circle").id).unwrap();
|
||||
assert_eq!((b.x0, b.y0, b.width(), b.height()), (152.0, 20.0, 67.0, 67.0), "mask");
|
||||
let b = doc.world_bounds(node(&doc, "Body").id).unwrap();
|
||||
assert!((b.x0 - 164.0).abs() < 1e-6 && (b.y0 - 41.0).abs() < 1e-6 && (b.width() - 45.0).abs() < 1e-6, "body {b:?}");
|
||||
assert!(matches!(node(&doc, "Circle").kind, NodeKind::Ellipse { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_styled_masks_each_draw() {
|
||||
let fill = json!([solid_fill(0.5, 0.5, 0.5, 1.0)]);
|
||||
let m1 = styled(with(r("Mask1"), "hasClippingMask", json!(true)), "fills", fill.clone());
|
||||
let m2 = styled(with(rect("Mask2", 200.0, 0.0, 100.0, 50.0), "hasClippingMask", json!(true)), "fills", fill);
|
||||
let (doc, _) = open(vec![group("G", 0.0, 0.0, 300.0, 50.0, vec![m1, r("A"), m2, rect("B", 200.0, 0.0, 10.0, 10.0)])]);
|
||||
// The drawn copy of the second mask isn't inside the first mask's group.
|
||||
let copy = doc.nodes().find(|n| n.name == "Mask2 (drawn)").unwrap();
|
||||
let first_group = node(&doc, "Mask1").parent.unwrap();
|
||||
assert_ne!(copy.parent, Some(first_group));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_color_controls_become_filters() {
|
||||
let png = {
|
||||
let mut out = Vec::new();
|
||||
image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
};
|
||||
let mut bitmap = r("Photo");
|
||||
bitmap["_class"] = json!("bitmap");
|
||||
bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"});
|
||||
bitmap["style"]["colorControls"] =
|
||||
json!({"_class": "colorControls", "isEnabled": true, "brightness": 0, "contrast": 1, "hue": 0.5, "saturation": 0});
|
||||
let (doc, warnings) = open_doc(document(&["P"]), vec![bitmap], &[("images/a.png", &png)]);
|
||||
let PaintKind::Image(img) = &node(&doc, "Photo").fills[0].kind else { panic!("no image") };
|
||||
assert_eq!(img.filters.saturation, -1.0);
|
||||
assert_eq!(img.filters.contrast, 0.0);
|
||||
assert!(warnings.iter().any(|w| w.contains("hue")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// An alpha mask (`clippingMaskMode` 1) only masks: Sketch doesn't draw its fill under the content.
|
||||
#[test]
|
||||
fn alpha_masks_draw_no_copy() {
|
||||
let fill = json!([solid_fill(0.0, 0.0, 0.0, 1.0)]);
|
||||
let m = styled(with(with(r("Fade"), "hasClippingMask", json!(true)), "clippingMaskMode", json!(1)), "fills", fill);
|
||||
let (doc, _) = open(vec![group("G", 0.0, 0.0, 100.0, 50.0, vec![m, r("Photo")])]);
|
||||
assert!(!doc.nodes().any(|n| n.name == "Fade (drawn)"));
|
||||
assert_eq!(node(&doc, "Fade").mask_type, figcraft_doc::MaskType::Alpha);
|
||||
}
|
||||
|
||||
/// A progressive opacity (Fade: `contextSettings.isProgressive` with a gradient) becomes an alpha
|
||||
/// mask of that gradient over the layer, in a group in the layer's place.
|
||||
#[test]
|
||||
fn fades_become_alpha_masks() {
|
||||
let fade = json!({"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1, "isProgressive": true,
|
||||
"gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [
|
||||
{"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}});
|
||||
let l = styled(rect("Photo", 30.0, 40.0, 100.0, 50.0), "contextSettings", fade);
|
||||
let (doc, _) = open(vec![group("G", 0.0, 0.0, 200.0, 200.0, vec![l])]);
|
||||
// The group takes the layer's name too.
|
||||
let photo = doc.nodes().find(|n| n.name == "Photo" && matches!(n.kind, NodeKind::Rectangle)).unwrap();
|
||||
let wrap = doc.node(photo.parent.unwrap()).unwrap();
|
||||
assert_eq!(wrap.name, "Photo");
|
||||
assert!(matches!(wrap.kind, NodeKind::Group), "{:?}", wrap.kind);
|
||||
let mask = doc.node(wrap.children[0]).unwrap();
|
||||
assert!(mask.is_mask && mask.mask_type == figcraft_doc::MaskType::Alpha);
|
||||
assert!(matches!(mask.fills[0].kind, PaintKind::Gradient(_)));
|
||||
assert_eq!(doc.world_bounds(mask.id), doc.world_bounds(photo.id));
|
||||
let b = doc.world_bounds(photo.id).unwrap();
|
||||
assert_eq!((b.x0, b.y0, b.width(), b.height()), (30.0, 40.0, 100.0, 50.0));
|
||||
}
|
||||
|
||||
/// Sketch's linear gradients run square in pixels: on a 200×100 layer, from the top left to the
|
||||
/// bottom right, the stripes are at right angles to that line in pixels, not in the layer's 0–1.
|
||||
#[test]
|
||||
fn linear_gradients_are_square_in_pixels() {
|
||||
let g = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0),
|
||||
"gradient": {"_class": "gradient", "gradientType": 0, "from": "{0, 0}", "to": "{1, 1}", "stops": [
|
||||
{"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(1.0, 1.0, 1.0, 1.0)}]}});
|
||||
let (doc, _) = open(vec![styled(rect("Wide", 0.0, 0.0, 200.0, 100.0), "fills", json!([g]))]);
|
||||
let PaintKind::Gradient(g) = &node(&doc, "Wide").fills[0].kind else { panic!("not a gradient") };
|
||||
// The line runs (200, 100) in pixels; square to it, (−100, 200), is (−0.5, 2) in 0–1.
|
||||
let (wx, wy) = ((g.width.0 - g.from.0) * 200.0, (g.width.1 - g.from.1) * 100.0);
|
||||
assert!((wx * 200.0 + wy * 100.0).abs() < 1e-6, "{g:?}");
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
//! Sketch symbols → REST components and instances.
|
||||
//!
|
||||
//! A `symbolMaster` (on any page, or copied from a library into `document.foreignSymbols`) is a
|
||||
//! component; a `symbolInstance` names it by `symbolID`. REST instances carry their layers, so an
|
||||
//! instance is its master's layers with the instance's `overrideValues` applied, laid out at the
|
||||
//! master's size (the import then resizes them to the instance by their constraints). An override
|
||||
//! is named `<path>_<property>`: `path` is the `do_objectID` of a layer of the master, or of
|
||||
//! nested instances and then a layer of theirs, joined by `/`; properties: `stringValue` (text),
|
||||
//! `symbolID` (swap a nested instance; empty hides it), `image`, `layerStyle` and `textStyle`
|
||||
//! (shared style ids).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_doc::geom::Affine;
|
||||
use serde_json::{Map as JsonMap, Value, json};
|
||||
|
||||
use super::shared::Shared;
|
||||
use super::tree::{Cx, base, bool_field, children, local, size, str_field};
|
||||
|
||||
/// Work units an override handed on to a nested instance costs (the JSON values it adds).
|
||||
const OVERRIDE_COST: u64 = 8;
|
||||
/// Most masters read from one file.
|
||||
const MAX_MASTERS: usize = 100_000;
|
||||
|
||||
/// Every symbol master of a file by `symbolID`, and the library ones no page holds.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Symbols {
|
||||
masters: BTreeMap<String, Value>,
|
||||
/// What expanding each master costs (see [`weight`]).
|
||||
weights: BTreeMap<String, u64>,
|
||||
/// Masters copied from libraries that no page holds (they get a page of their own), with the
|
||||
/// libraries' names.
|
||||
pub foreign: Vec<Value>,
|
||||
pub libraries: Vec<String>,
|
||||
}
|
||||
|
||||
impl Symbols {
|
||||
/// The masters of `pages` and of the document's library copies.
|
||||
pub(crate) fn read(document: &Value, pages: &[(String, Value)]) -> Symbols {
|
||||
let mut s = Symbols::default();
|
||||
let mut found = Vec::new();
|
||||
for (_, p) in pages {
|
||||
masters_in(p, &mut found, 0);
|
||||
}
|
||||
for m in found {
|
||||
s.add(m.clone());
|
||||
}
|
||||
for f in document.get("foreignSymbols").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() {
|
||||
let Some(m) = f.get("symbolMaster") else { continue };
|
||||
let Some(id) = str_field(m, "symbolID") else { continue };
|
||||
if s.masters.contains_key(id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(lib) = str_field(f, "sourceLibraryName")
|
||||
&& !s.libraries.iter().any(|l| l == lib)
|
||||
{
|
||||
s.libraries.push(lib.chars().take(64).collect());
|
||||
}
|
||||
s.add(m.clone());
|
||||
s.foreign.push(m.clone());
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn add(&mut self, m: Value) {
|
||||
if self.masters.len() >= MAX_MASTERS {
|
||||
return;
|
||||
}
|
||||
if let Some(id) = str_field(&m, "symbolID")
|
||||
&& !self.masters.contains_key(id)
|
||||
{
|
||||
self.weights.insert(id.to_string(), weight(&m, 0));
|
||||
self.masters.insert(id.to_string(), m);
|
||||
}
|
||||
}
|
||||
|
||||
fn master(&self, symbol_id: &str) -> Option<&Value> {
|
||||
self.masters.get(symbol_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// What copying and mapping `v` costs, in work units: one per JSON value, one per 8 bytes of text.
|
||||
pub(crate) fn weight(v: &Value, depth: usize) -> u64 {
|
||||
if depth > 256 {
|
||||
return 1;
|
||||
}
|
||||
match v {
|
||||
Value::String(s) => 1 + s.len() as u64 / 8,
|
||||
Value::Array(a) => 1 + a.iter().map(|x| weight(x, depth + 1)).sum::<u64>(),
|
||||
Value::Object(o) => 1 + o.iter().map(|(k, x)| k.len() as u64 / 8 + weight(x, depth + 1)).sum::<u64>(),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn masters_in<'v>(l: &'v Value, out: &mut Vec<&'v Value>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for c in super::tree::layers(l) {
|
||||
if str_field(c, "_class") == Some("symbolMaster") {
|
||||
out.push(c);
|
||||
} else {
|
||||
masters_in(c, out, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A master's background as REST fills (masters show one only when they have it on).
|
||||
pub(crate) fn master_fills(m: &Value) -> Option<Value> {
|
||||
if bool_field(m, "hasBackgroundColor") != Some(true) {
|
||||
return None;
|
||||
}
|
||||
let c = m.get("backgroundColor").and_then(super::tree::color)?;
|
||||
Some(json!([{"type": "SOLID", "color": c}]))
|
||||
}
|
||||
|
||||
/// What a master shows as a frame (its instances the same): clipping, and in Sketch 2025 and newer
|
||||
/// its style (fills, borders, effects) and corners.
|
||||
fn frame_look(cx: &mut Cx, m: &Value, w: f64, h: f64, node: &mut JsonMap<String, Value>) {
|
||||
// `clippingBehavior` 2 doesn't clip.
|
||||
node.insert("clipsContent".into(), json!(m.get("clippingBehavior").and_then(Value::as_i64) != Some(2)));
|
||||
if m.get("groupBehavior").is_some()
|
||||
&& let Some(st) = m.get("style")
|
||||
{
|
||||
// Tint fills aren't painted (see `style::fills`).
|
||||
super::style::apply(cx, st, w, h, node);
|
||||
}
|
||||
super::tree::style_corners(m, node);
|
||||
}
|
||||
|
||||
/// A symbol master as a COMPONENT node.
|
||||
pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option<Value> {
|
||||
let (w, h) = size(l);
|
||||
let world = parent * local(l, w, h);
|
||||
let mut node = base(l, "COMPONENT", parent, world, w, h, root);
|
||||
frame_look(cx, l, w, h, &mut node);
|
||||
if let Some(f) = master_fills(l) {
|
||||
node.insert("fills".into(), f);
|
||||
}
|
||||
super::tree::extras(cx, l, h, &mut node);
|
||||
let sid = str_field(l, "symbolID").unwrap_or("").to_string();
|
||||
// A graphic's fill tints its layers.
|
||||
let tinted;
|
||||
let l = match super::style::tint_of(l.get("style"), false).filter(|_| super::tree::spend_on_tint(cx, l)) {
|
||||
Some(t) => {
|
||||
let mut c = l.clone();
|
||||
if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) {
|
||||
super::style::tint_layers(kids, &t, 0);
|
||||
}
|
||||
tinted = c;
|
||||
&tinted
|
||||
}
|
||||
_ => l,
|
||||
};
|
||||
cx.chain.push(sid);
|
||||
let kids = children(cx, l, world, (w, h), depth, false);
|
||||
cx.chain.pop();
|
||||
node.insert("children".into(), Value::Array(kids));
|
||||
super::stack::apply(l, &mut node);
|
||||
Some(Value::Object(node))
|
||||
}
|
||||
|
||||
/// A symbol instance as an INSTANCE node holding its master's layers with its overrides, or an
|
||||
/// empty frame when its master isn't in the file.
|
||||
pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option<Value> {
|
||||
let (w, h) = size(l);
|
||||
let world = parent * local(l, w, h);
|
||||
let sid = str_field(l, "symbolID").unwrap_or("");
|
||||
let cost = cx.symbols.weights.get(sid).copied().unwrap_or(0);
|
||||
if cx.symbols.master(sid).is_some() && !cx.spend(cost) {
|
||||
let mut node = base(l, "FRAME", parent, world, w, h, root);
|
||||
node.insert("children".into(), json!([]));
|
||||
return Some(Value::Object(node));
|
||||
}
|
||||
let Some(m) = cx.symbols.master(sid).cloned() else {
|
||||
cx.warn("instances of symbols that aren't in the file were imported as empty frames");
|
||||
let mut node = base(l, "FRAME", parent, world, w, h, root);
|
||||
node.insert("children".into(), json!([]));
|
||||
return Some(Value::Object(node));
|
||||
};
|
||||
if cx.chain.iter().any(|c| c == sid) {
|
||||
let name: String = str_field(&m, "name").unwrap_or("").chars().take(64).collect();
|
||||
cx.warn(format!("symbol {name} contains itself; the inner copy was left out"));
|
||||
return None;
|
||||
}
|
||||
// Sketch 50 stores "not scaled" as 0.
|
||||
if l.get("scale").and_then(Value::as_f64).is_some_and(|s| s.is_finite() && s != 0.0 && (s - 1.0).abs() > 1e-9) {
|
||||
cx.warn("scaled symbol instances were imported at their size, unscaled");
|
||||
}
|
||||
let mut node = base(l, "INSTANCE", parent, world, w, h, root);
|
||||
node.insert("componentId".into(), json!(str_field(&m, "do_objectID").unwrap_or(sid)));
|
||||
frame_look(cx, &m, w, h, &mut node);
|
||||
// Instances show the master's background only when it says so.
|
||||
if bool_field(&m, "includeBackgroundColorInInstance") == Some(true)
|
||||
&& let Some(f) = master_fills(&m)
|
||||
{
|
||||
node.insert("fills".into(), f);
|
||||
}
|
||||
// An instance's fill is a tint for its layers, not a paint of its own.
|
||||
if let Some(st) = l.get("style") {
|
||||
let mut st = st.clone();
|
||||
if let Some(o) = st.as_object_mut() {
|
||||
o.remove("fills");
|
||||
}
|
||||
super::style::apply(cx, &st, w, h, &mut node);
|
||||
}
|
||||
super::tree::extras(cx, l, h, &mut node);
|
||||
// An instance shows its master's grids (else they'd differ, and count as an override).
|
||||
super::proto::layout_grids(&m, h, &mut node);
|
||||
let overrides = overrides(l);
|
||||
let nested = nested(&overrides);
|
||||
let mut copy = m;
|
||||
let mut budget = cx.work_left();
|
||||
if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) {
|
||||
apply(layers, &overrides, &nested, &cx.shared, &cx.symbols, 0, &mut budget);
|
||||
}
|
||||
let used = cx.work_left().saturating_sub(budget);
|
||||
cx.spend(used);
|
||||
if budget == 0 {
|
||||
// The budget ran out handing overrides on: noted.
|
||||
cx.spend(1);
|
||||
}
|
||||
// The tint: the instance's own (a colour override on it, or its fill), else a graphic master's.
|
||||
let own = str_field(l, "do_objectID").map(|id| format!("{id}_fillColor"));
|
||||
let tint = own
|
||||
.and_then(|k| overrides.get(&k).filter(|c| c.is_object()).cloned())
|
||||
.or_else(|| super::style::tint_of(l.get("style"), true))
|
||||
.or_else(|| super::style::tint_of(copy.get("style"), false));
|
||||
if let Some(t) = tint
|
||||
&& let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut)
|
||||
{
|
||||
super::style::tint_layers(layers, &t, 0);
|
||||
}
|
||||
// The master's layers laid out for the instance's size, as Sketch does it.
|
||||
let (mw, mh) = size(©);
|
||||
// A graphic's layers resize in proportion, whatever their pins.
|
||||
let graphic = copy.get("groupBehavior").and_then(Value::as_i64) == Some(2);
|
||||
// A stack's layers are placed by its auto layout (when the file is opened), not by their pins.
|
||||
let stacked = !graphic && super::stack::is_stack(©);
|
||||
if ((mw - w).abs() > 1e-9 || (mh - h).abs() > 1e-9)
|
||||
&& !stacked
|
||||
&& let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut)
|
||||
{
|
||||
resize(layers, (mw, mh), (w, h), cx.legacy_resize || graphic, 0);
|
||||
}
|
||||
cx.chain.push(sid.to_string());
|
||||
let mut kids = children(cx, ©, world, (w, h), depth, false);
|
||||
cx.chain.pop();
|
||||
node.insert("children".into(), Value::Array(std::mem::take(&mut kids)));
|
||||
// The master's stack lays the instance out too (matched by the master's layer ids), sized as
|
||||
// the instance says.
|
||||
for k in ["horizontalSizing", "verticalSizing"] {
|
||||
if let Some(v) = l.get(k) {
|
||||
copy[k] = v.clone();
|
||||
}
|
||||
}
|
||||
super::stack::apply(©, &mut node);
|
||||
let prefix = str_field(l, "do_objectID").unwrap_or("I").to_string();
|
||||
for k in node.get_mut("children").and_then(Value::as_array_mut).into_iter().flatten() {
|
||||
prefix_ids(k, &prefix, 0);
|
||||
}
|
||||
Some(Value::Object(node))
|
||||
}
|
||||
|
||||
/// Lay `layers` (the children of a box of `old` size) out for the box at `new` size, the way Sketch
|
||||
/// resizes a symbol instance's layers: groups pass the change on to their own layers. Files older
|
||||
/// than format 110 scale everything; newer ones follow each layer's `resizingConstraint`.
|
||||
fn resize(layers: &mut [Value], old: (f64, f64), new: (f64, f64), legacy: bool, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for l in layers {
|
||||
let mask = if legacy { 63 } else { super::tree::pin_mask(l) };
|
||||
let Some(f) = l.get_mut("frame").and_then(Value::as_object_mut) else { continue };
|
||||
let n = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let (x, y, w, h) = (n("x"), n("y"), n("width").max(0.0), n("height").max(0.0));
|
||||
// A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32.
|
||||
let pinned = |bit: i64| mask & bit == 0;
|
||||
let (nx, nw) = resize_axis(x, w, old.0, new.0, (pinned(4), pinned(2), pinned(1)));
|
||||
let (ny, nh) = resize_axis(y, h, old.1, new.1, (pinned(32), pinned(16), pinned(8)));
|
||||
for (k, v) in [("x", nx), ("y", ny), ("width", nw), ("height", nh)] {
|
||||
f.insert(k.into(), json!(v));
|
||||
}
|
||||
// A graphic's layers resize in proportion, whatever their pins.
|
||||
let graphic = l.get("groupBehavior").and_then(Value::as_i64) == Some(2);
|
||||
if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) {
|
||||
resize(kids, (w, h), (nw, nh), legacy || graphic, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One axis of [`resize`]: a layer at `pos` of `size` in a parent going from `old` to `new`, with
|
||||
/// its start, size and end pinned or not. Nothing pinned (or everything): it scales. One side
|
||||
/// pinned: the size and the free side share the change, in proportion to their lengths. Only the
|
||||
/// size fixed: its centre scales. Both sides pinned: the size takes the change.
|
||||
fn resize_axis(pos: f64, size: f64, old: f64, new: f64, (start, fixed, end): (bool, bool, bool)) -> (f64, f64) {
|
||||
if old <= 1e-9 || !new.is_finite() {
|
||||
return (pos, size);
|
||||
}
|
||||
let k = new / old;
|
||||
let d = new - old;
|
||||
let far = old - pos - size;
|
||||
let share = |part: f64, other: f64| if part + other > 1e-9 { d * part / (part + other) } else { d };
|
||||
let (p, s) = match (start, fixed, end) {
|
||||
(true, false, false) => (pos, size + share(size, far)),
|
||||
(false, false, true) => {
|
||||
let s = size + share(size, pos);
|
||||
(new - far - s, s)
|
||||
}
|
||||
(false, true, false) => ((pos + size / 2.0) * k - size / 2.0, size),
|
||||
(true, false, true) => (pos, size + d),
|
||||
(true, true, false) => (pos, size),
|
||||
(false, true, true) => (new - far - size, size),
|
||||
_ => (pos * k, size * k),
|
||||
};
|
||||
(if p.is_finite() { p } else { pos }, if s.is_finite() { s.max(0.0) } else { size })
|
||||
}
|
||||
|
||||
/// An instance's overrides by name (`<path>_<property>` → value).
|
||||
fn overrides(l: &Value) -> BTreeMap<String, Value> {
|
||||
l.get("overrideValues")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.take(100_000)
|
||||
.filter_map(|o| Some((str_field(o, "overrideName")?.to_string(), o.get("value")?.clone())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The overrides of layers inside nested instances, by the nested instance's id: `X/rest_prop` →
|
||||
/// `X` → [(`rest_prop`, value)].
|
||||
fn nested(ov: &BTreeMap<String, Value>) -> BTreeMap<&str, Vec<(&str, &Value)>> {
|
||||
let mut out: BTreeMap<&str, Vec<(&str, &Value)>> = BTreeMap::new();
|
||||
for (k, v) in ov {
|
||||
if let Some((first, rest)) = k.split_once('/') {
|
||||
out.entry(first).or_default().push((rest, v));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Apply `ov` to the layers of a master's copy: to the layers it names, and passed on to nested
|
||||
/// instances for the layers inside them (an outer override wins over the nested instance's own).
|
||||
/// Each override handed to a nested instance costs [`OVERRIDE_COST`] units of `budget` plus its
|
||||
/// value's [`weight`]; once it is spent, the rest aren't handed on.
|
||||
fn apply(
|
||||
layers: &mut [Value],
|
||||
ov: &BTreeMap<String, Value>,
|
||||
nested: &BTreeMap<&str, Vec<(&str, &Value)>>,
|
||||
shared: &Shared,
|
||||
symbols: &Symbols,
|
||||
depth: usize,
|
||||
budget: &mut u64,
|
||||
) {
|
||||
if depth > 64 || ov.is_empty() {
|
||||
return;
|
||||
}
|
||||
for l in layers {
|
||||
let Some(id) = str_field(l, "do_objectID").map(str::to_string) else { continue };
|
||||
let get = |prop: &str| ov.get(&format!("{id}_{prop}"));
|
||||
match str_field(l, "_class") {
|
||||
Some("symbolInstance") => {
|
||||
match get("symbolID").map(|v| v.as_str().unwrap_or("")) {
|
||||
Some("") => l["isVisible"] = json!(false),
|
||||
// A swap to a symbol the file doesn't have keeps the original, as in Sketch.
|
||||
Some(other) if symbols.master(other).is_some() => l["symbolID"] = json!(other),
|
||||
_ => {}
|
||||
}
|
||||
let inner = nested.get(id.as_str()).map(Vec::as_slice).unwrap_or_default();
|
||||
// Each costs its copy's size: deep chains of large overrides use up the budget.
|
||||
let mut take = 0;
|
||||
for (_, v) in inner {
|
||||
let cost = OVERRIDE_COST + weight(v, 0);
|
||||
if cost > *budget {
|
||||
break;
|
||||
}
|
||||
*budget -= cost;
|
||||
take += 1;
|
||||
}
|
||||
if take > 0 {
|
||||
let mut own = l.get("overrideValues").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
own.extend(inner.iter().take(take).map(|(rest, v)| json!({"_class": "overrideValue", "overrideName": rest, "value": v})));
|
||||
l["overrideValues"] = Value::Array(own);
|
||||
}
|
||||
if let Some(v) = get("isVisible") {
|
||||
l["isVisible"] = json!(visible(v));
|
||||
}
|
||||
// A colour override on an instance is its tint.
|
||||
if let Some(c) = get("fillColor").filter(|c| c.is_object()) {
|
||||
super::style::set_tint(l, c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some("text") => {
|
||||
if let Some(v) = get("isVisible") {
|
||||
l["isVisible"] = json!(visible(v));
|
||||
}
|
||||
if let Some(s) = get("stringValue").and_then(Value::as_str) {
|
||||
set_string(l, s);
|
||||
}
|
||||
if let Some(c) = get("textColor").filter(|c| c.is_object()) {
|
||||
super::style::tint_layers(std::slice::from_mut(l), c, 0);
|
||||
}
|
||||
}
|
||||
Some("bitmap") => {
|
||||
if let Some(v) = get("isVisible") {
|
||||
l["isVisible"] = json!(visible(v));
|
||||
}
|
||||
if let Some(img) = get("image").filter(|v| v.get("_ref").is_some()) {
|
||||
l["image"] = img.clone();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(v) = get("isVisible") {
|
||||
l["isVisible"] = json!(visible(v));
|
||||
}
|
||||
// A colour override on a shape or group: its first fill's colour.
|
||||
if let Some(c) = get("fillColor").filter(|c| c.is_object())
|
||||
&& let Some(f) = l
|
||||
.get_mut("style")
|
||||
.and_then(|s| s.get_mut("fills"))
|
||||
.and_then(Value::as_array_mut)
|
||||
.and_then(|f| f.first_mut())
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
f.insert("fillType".into(), json!(0));
|
||||
f.insert("color".into(), c.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (prop, text) in [("layerStyle", false), ("textStyle", true)] {
|
||||
let sid = get(prop).and_then(Value::as_str);
|
||||
// Shared styles that aren't objects are junk: left alone.
|
||||
let Some((sid, style)) = sid.and_then(|sid| Some((sid, shared.style(text, sid).filter(|s| s.is_object())?))) else { continue };
|
||||
let mut style = style.clone();
|
||||
if let Some(m) = style.as_object_mut() {
|
||||
// The layer now uses that shared style: it links to it.
|
||||
m.insert("sharedObjectID".into(), json!(sid));
|
||||
}
|
||||
if text
|
||||
&& let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes"))
|
||||
&& let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut)
|
||||
{
|
||||
for r in runs.iter_mut().filter_map(Value::as_object_mut) {
|
||||
r.insert("attributes".into(), enc.clone());
|
||||
}
|
||||
}
|
||||
if let Some(m) = l.as_object_mut() {
|
||||
m.insert("style".into(), style);
|
||||
}
|
||||
}
|
||||
if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) {
|
||||
apply(kids, ov, nested, shared, symbols, depth + 1, budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A visibility override's value: "0", 0 or false hide.
|
||||
fn visible(v: &Value) -> bool {
|
||||
!matches!(v, Value::Bool(false)) && v.as_str() != Some("0") && v.as_i64() != Some(0)
|
||||
}
|
||||
|
||||
/// A text layer's string replaced, in the style of its first run.
|
||||
fn set_string(l: &mut Value, s: &str) {
|
||||
let units = s.encode_utf16().count();
|
||||
let first = l.get("attributedString").and_then(|a| a.get("attributes")).and_then(Value::as_array).and_then(|a| a.first()).cloned();
|
||||
let Some(a) = l.get_mut("attributedString").and_then(Value::as_object_mut) else { return };
|
||||
a.insert("string".into(), json!(s));
|
||||
let runs = match first {
|
||||
Some(Value::Object(mut r)) => {
|
||||
r.insert("location".into(), json!(0));
|
||||
r.insert("length".into(), json!(units));
|
||||
vec![Value::Object(r)]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
a.insert("attributes".into(), Value::Array(runs));
|
||||
}
|
||||
|
||||
/// Make the ids of an instance's layers its own (`<instance>;<layer>`): the master's layers keep
|
||||
/// theirs.
|
||||
fn prefix_ids(v: &mut Value, prefix: &str, depth: usize) {
|
||||
if depth > 256 {
|
||||
return;
|
||||
}
|
||||
let Some(m) = v.as_object_mut() else { return };
|
||||
if let Some(id) = m.get("id").and_then(Value::as_str).map(str::to_string) {
|
||||
m.insert("id".into(), json!(format!("{prefix};{id}")));
|
||||
}
|
||||
if let Some(kids) = m.get_mut("children").and_then(Value::as_array_mut) {
|
||||
for k in kids {
|
||||
prefix_ids(k, prefix, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Library masters no page holds: a page of their own, side by side.
|
||||
pub(crate) fn library_page(cx: &mut Cx, masters: &[Value]) -> Value {
|
||||
let mut x = 0.0;
|
||||
let mut kids = Vec::new();
|
||||
for m in masters {
|
||||
let mut placed = m.clone();
|
||||
let (w, _) = size(m);
|
||||
if let Some(f) = placed.get_mut("frame").and_then(Value::as_object_mut) {
|
||||
f.insert("x".into(), json!(x));
|
||||
f.insert("y".into(), json!(0.0));
|
||||
}
|
||||
x += w + 100.0;
|
||||
if let Some(node) = component(cx, &placed, Affine::IDENTITY, 0, true) {
|
||||
kids.push(node);
|
||||
}
|
||||
}
|
||||
let mut page = JsonMap::new();
|
||||
page.insert("type".into(), json!("CANVAS"));
|
||||
page.insert("id".into(), json!("library-symbols"));
|
||||
page.insert("name".into(), json!("Library symbols"));
|
||||
page.insert("children".into(), Value::Array(kids));
|
||||
Value::Object(page)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Sketch symbols → components and instances with overrides.
|
||||
|
||||
use figcraft_doc::{Document, Node, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fixture::*;
|
||||
use super::{import, import_within_nodes};
|
||||
|
||||
const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0];
|
||||
|
||||
/// One page with `layers`, one "Symbols" page with `masters`, and `document` changes.
|
||||
fn open_doc(doc_json: Value, layers: Vec<Value>, masters: Vec<Value>) -> (Document, Vec<String>) {
|
||||
let z = sketch_zip("50.2", 103, doc_json, &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]);
|
||||
import(&z, "f").unwrap()
|
||||
}
|
||||
|
||||
/// [`open`] for a file saved by another Sketch.
|
||||
fn open_version(app: &str, version: u64, layers: Vec<Value>, masters: Vec<Value>) -> (Document, Vec<String>) {
|
||||
let z = sketch_zip(app, version, document(&["P", "S"]), &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]);
|
||||
import(&z, "f").unwrap()
|
||||
}
|
||||
|
||||
fn open(layers: Vec<Value>, masters: Vec<Value>) -> (Document, Vec<String>) {
|
||||
let mut d = document(&["P", "S"]);
|
||||
d["do_objectID"] = json!("DOC");
|
||||
open_doc(d, layers, masters)
|
||||
}
|
||||
|
||||
fn named<'d>(doc: &'d Document, name: &str) -> Vec<&'d Node> {
|
||||
doc.nodes().filter(|n| n.name == name).collect()
|
||||
}
|
||||
|
||||
fn inside<'d>(doc: &'d Document, root: &Node, name: &str) -> &'d Node {
|
||||
doc.subtree(root.id).into_iter().filter_map(|id| doc.node(id)).find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}"))
|
||||
}
|
||||
|
||||
fn chars(n: &Node) -> &str {
|
||||
match &n.kind {
|
||||
NodeKind::Text(td) => &td.characters,
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn instance_of<'d>(doc: &'d Document, name: &str) -> (&'d Node, &'d Node) {
|
||||
let inst = named(doc, name).into_iter().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap_or_else(|| panic!("no instance {name}"));
|
||||
let NodeKind::Instance { component, .. } = inst.kind else { panic!("not an instance") };
|
||||
(inst, doc.node(component).unwrap())
|
||||
}
|
||||
|
||||
fn button() -> Value {
|
||||
master("M1", "S1", "Button", 100.0, 40.0, vec![text("Label", 10.0, 10.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, BLACK)])])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_is_component_instance_links() {
|
||||
let (doc, _) = open(vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[])], vec![button()]);
|
||||
let (inst, main) = instance_of(&doc, "I1");
|
||||
assert!(matches!(main.kind, NodeKind::Component { .. }));
|
||||
assert_eq!(main.name, "Button");
|
||||
assert_eq!(chars(inside(&doc, inst, "Label")), "Go");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_override() {
|
||||
let (doc, _) = open(vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[("Label_stringValue", json!("OK"))])], vec![button()]);
|
||||
let (inst, main) = instance_of(&doc, "I1");
|
||||
assert_eq!(chars(inside(&doc, inst, "Label")), "OK");
|
||||
assert_eq!(chars(inside(&doc, main, "Label")), "Go");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_symbol_swap_and_hide() {
|
||||
let icon_a = master("MA", "SA", "Icon A", 20.0, 20.0, vec![rect("Dot A", 0.0, 0.0, 20.0, 20.0)]);
|
||||
let icon_b = master("MB", "SB", "Icon B", 20.0, 20.0, vec![rect("Dot B", 0.0, 0.0, 20.0, 20.0)]);
|
||||
let card = master("MC", "SC", "Card", 100.0, 40.0, vec![instance("N", "SA", 0.0, 0.0, 20.0, 20.0, &[])]);
|
||||
let swapped = instance("Swapped", "SC", 0.0, 0.0, 100.0, 40.0, &[("N_symbolID", json!("SB"))]);
|
||||
let hidden = instance("Hidden", "SC", 0.0, 100.0, 100.0, 40.0, &[("N_symbolID", json!(""))]);
|
||||
let (doc, _) = open(vec![swapped, hidden], vec![icon_a, icon_b, card]);
|
||||
let (sw, _) = instance_of(&doc, "Swapped");
|
||||
assert!(doc.subtree(sw.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot B"));
|
||||
assert!(!doc.subtree(sw.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot A"));
|
||||
let (hd, _) = instance_of(&doc, "Hidden");
|
||||
let nested = doc.node(hd.children[0]).unwrap();
|
||||
assert!(!nested.visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_path_override() {
|
||||
let inner = master("MB", "SB", "Badge", 50.0, 20.0, vec![text("Count", 0.0, 0.0, 50.0, 20.0, "1", &[(0, 1, "Helvetica", 12.0, BLACK)])]);
|
||||
let outer = master("MA", "SA", "Row", 100.0, 20.0, vec![instance("N", "SB", 50.0, 0.0, 50.0, 20.0, &[])]);
|
||||
let (doc, _) = open(vec![instance("I1", "SA", 0.0, 0.0, 100.0, 20.0, &[("N/Count_stringValue", json!("42"))])], vec![inner, outer]);
|
||||
let (inst, _) = instance_of(&doc, "I1");
|
||||
assert_eq!(chars(inside(&doc, inst, "Count")), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_override() {
|
||||
let mut bitmap = rect("Photo", 0.0, 0.0, 40.0, 40.0);
|
||||
bitmap["_class"] = json!("bitmap");
|
||||
bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"});
|
||||
let m = master("M", "S", "Avatar", 40.0, 40.0, vec![bitmap]);
|
||||
let png = {
|
||||
let mut out = Vec::new();
|
||||
image::RgbaImage::new(1, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
};
|
||||
let mut png2 = Vec::new();
|
||||
image::RgbaImage::new(2, 1).write_to(&mut std::io::Cursor::new(&mut png2), image::ImageFormat::Png).unwrap();
|
||||
let inst = instance("I1", "S", 0.0, 0.0, 40.0, 40.0, &[("Photo_image", json!({"_class": "MSJSONFileReference", "_ref": "images/b.png"}))]);
|
||||
let z = sketch_zip(
|
||||
"50.2",
|
||||
103,
|
||||
document(&["P", "S"]),
|
||||
&[page("P", "Page", vec![inst]), page("S", "Symbols", vec![m])],
|
||||
&[("images/a.png", &png), ("images/b.png", &png2)],
|
||||
);
|
||||
let (doc, _) = import(&z, "f").unwrap();
|
||||
assert_eq!(doc.images.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_symbols_are_components() {
|
||||
let mut d = document(&["P", "S"]);
|
||||
d["foreignSymbols"] = json!([{"_class": "MSImmutableForeignSymbol", "sourceLibraryName": "Kit", "symbolMaster": button()}]);
|
||||
let (doc, warnings) = open_doc(d, vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[])], vec![]);
|
||||
let (_, main) = instance_of(&doc, "I1");
|
||||
assert_eq!(main.name, "Button");
|
||||
let page = doc.pages.iter().copied().find(|p| doc.is_ancestor(*p, main.id)).unwrap();
|
||||
assert_eq!(doc.node(page).unwrap().name, "Library symbols");
|
||||
assert!(warnings.iter().any(|w| w.contains("Kit")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_master_warns() {
|
||||
let (doc, warnings) = open(vec![instance("Lost", "NOPE", 0.0, 0.0, 30.0, 30.0, &[])], vec![]);
|
||||
let n = named(&doc, "Lost")[0];
|
||||
assert!(matches!(n.kind, NodeKind::Frame));
|
||||
assert_eq!((n.width, n.height), (30.0, 30.0));
|
||||
assert!(warnings.iter().any(|w| w.contains("symbol")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_containing_master_stops() {
|
||||
let a = master("MA", "SA", "Loop A", 50.0, 50.0, vec![instance("Self", "SA", 0.0, 0.0, 10.0, 10.0, &[])]);
|
||||
let b = master("MB", "SB", "Loop B", 50.0, 50.0, vec![instance("ToC", "SC", 0.0, 0.0, 10.0, 10.0, &[])]);
|
||||
let c = master("MC", "SC", "Loop C", 50.0, 50.0, vec![instance("ToB", "SB", 0.0, 0.0, 10.0, 10.0, &[])]);
|
||||
let (_, warnings) = open(vec![instance("I1", "SA", 0.0, 0.0, 50.0, 50.0, &[]), instance("I2", "SB", 100.0, 0.0, 50.0, 50.0, &[])], vec![a, b, c]);
|
||||
assert!(warnings.iter().any(|w| w.contains("contains itself")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_explosion_is_bounded() {
|
||||
let big = master("M", "S", "Big", 100.0, 100.0, (0..1000).map(|i| rect(&format!("R{i}"), 0.0, 0.0, 1.0, 1.0)).collect());
|
||||
let many: Vec<Value> = (0..1000).map(|i| instance(&format!("I{i}"), "S", 0.0, 0.0, 100.0, 100.0, &[])).collect();
|
||||
let z = sketch_zip("50.2", 103, document(&["P", "S"]), &[page("P", "Page", many), page("S", "Symbols", vec![big])], &[]);
|
||||
let (doc, warnings) = import_within_nodes(&z, "f", 20_000).unwrap();
|
||||
assert!(doc.nodes().count() <= 20_100);
|
||||
assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_instance_follows_constraints() {
|
||||
// Pinned right with a fixed width (bits 1 and 2 cleared); a Sketch 64 file.
|
||||
let pinned = with(rect("Tag", 80.0, 0.0, 20.0, 40.0), "resizingConstraint", json!(60));
|
||||
let m = master("M", "S", "Bar", 100.0, 40.0, vec![pinned]);
|
||||
let (doc, _) = open_version("64", 125, vec![instance("Wide", "S", 0.0, 0.0, 200.0, 40.0, &[])], vec![m]);
|
||||
let (inst, _) = instance_of(&doc, "Wide");
|
||||
let tag = inside(&doc, inst, "Tag");
|
||||
let b = doc.world_bounds(tag.id).unwrap();
|
||||
assert!((b.x0 - 180.0).abs() < 1e-6 && (b.width() - 20.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
/// Overrides pointing at junk (a shared style that isn't an object, text runs that aren't) never
|
||||
/// panic.
|
||||
#[test]
|
||||
fn override_application_survives_junk() {
|
||||
let mut d = document(&["P", "S"]);
|
||||
d["layerStyles"]["objects"] = json!([{"_class": "sharedStyle", "do_objectID": "S1", "name": "Junk", "value": 5}]);
|
||||
d["layerTextStyles"]["objects"] =
|
||||
json!([{"_class": "sharedStyle", "do_objectID": "T1", "name": "Junk text", "value": {"textStyle": {"encodedAttributes": {}}}}]);
|
||||
let mut t = text("Label", 0.0, 0.0, 50.0, 20.0, "x", &[(0, 1, "Helvetica", 9.0, BLACK)]);
|
||||
t["attributedString"]["attributes"] = json!([5, "x", null]);
|
||||
let m = master("M", "S", "Junk master", 50.0, 20.0, vec![rect("C", 0.0, 0.0, 10.0, 10.0), t]);
|
||||
let inst = instance(
|
||||
"I1",
|
||||
"S",
|
||||
0.0,
|
||||
0.0,
|
||||
50.0,
|
||||
20.0,
|
||||
&[("C_layerStyle", json!("S1")), ("Label_textStyle", json!("T1")), ("Label_stringValue", json!("y"))],
|
||||
);
|
||||
let z = sketch_zip("50.2", 103, d, &[page("P", "Page", vec![inst]), page("S", "Symbols", vec![m])], &[]);
|
||||
assert!(import(&z, "f").is_ok());
|
||||
}
|
||||
|
||||
/// A huge master expanded by many instances stops at the file's work budget.
|
||||
#[test]
|
||||
fn expansion_work_is_bounded() {
|
||||
let long = "x".repeat(200_000);
|
||||
let m = master("M", "S", "Heavy", 50.0, 20.0, vec![text("Long", 0.0, 0.0, 50.0, 20.0, &long, &[(0, 200_000, "Helvetica", 9.0, BLACK)])]);
|
||||
let many: Vec<Value> = (0..3000).map(|i| instance(&format!("I{i}"), "S", 0.0, 0.0, 50.0, 20.0, &[])).collect();
|
||||
let z = sketch_zip("50.2", 103, document(&["P", "S"]), &[page("P", "Page", many), page("S", "Symbols", vec![m])], &[]);
|
||||
let (_, warnings) = import(&z, "f").unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("expand")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// Outer overrides fanned out to many nested instances stop at the work budget.
|
||||
#[test]
|
||||
fn override_fan_out_is_bounded() {
|
||||
let inner = master("MB", "SB", "Cell", 10.0, 10.0, vec![text("T", 0.0, 0.0, 10.0, 10.0, "1", &[(0, 1, "Helvetica", 9.0, BLACK)])]);
|
||||
let cells: Vec<Value> = (0..3000).map(|_| instance("X", "SB", 0.0, 0.0, 10.0, 10.0, &[])).collect();
|
||||
let grid = master("MA", "SA", "Grid", 100.0, 100.0, cells);
|
||||
let overrides: Vec<(String, Value)> = (0..3000).map(|i| (format!("X/N{i}_stringValue"), json!("v"))).collect();
|
||||
let refs: Vec<(&str, Value)> = overrides.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
|
||||
let z = sketch_zip(
|
||||
"50.2",
|
||||
103,
|
||||
document(&["P", "S"]),
|
||||
&[page("P", "Page", vec![instance("I", "SA", 0.0, 0.0, 100.0, 100.0, &refs)]), page("S", "Symbols", vec![inner, grid])],
|
||||
&[],
|
||||
);
|
||||
let (_, warnings) = import(&z, "f").unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("expand")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// Sketch 50 stores "not scaled" as `scale: 0`.
|
||||
#[test]
|
||||
fn scale_zero_is_unscaled() {
|
||||
let mut i = instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[]);
|
||||
i["scale"] = json!(0);
|
||||
let (_, warnings) = open(vec![i], vec![button()]);
|
||||
assert!(!warnings.iter().any(|w| w.contains("scaled")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A master's background shows in its instances only when Sketch says so.
|
||||
#[test]
|
||||
fn master_background_in_instances_only_when_included() {
|
||||
let bg = |id: &str, sid: &str, include: bool| {
|
||||
let mut m = master(id, sid, id, 20.0, 20.0, vec![]);
|
||||
m["hasBackgroundColor"] = json!(true);
|
||||
m["backgroundColor"] = json!({"_class": "color", "red": 0, "green": 0, "blue": 0, "alpha": 1});
|
||||
m["includeBackgroundColorInInstance"] = json!(include);
|
||||
m
|
||||
};
|
||||
let (doc, _) = open(
|
||||
vec![instance("Hidden", "SH", 0.0, 0.0, 20.0, 20.0, &[]), instance("Shown", "SS", 50.0, 0.0, 20.0, 20.0, &[])],
|
||||
vec![bg("MH", "SH", false), bg("MS", "SS", true)],
|
||||
);
|
||||
let (hidden, main) = instance_of(&doc, "Hidden");
|
||||
assert!(hidden.fills.is_empty(), "{:?}", hidden.fills);
|
||||
assert_eq!(main.fills.len(), 1, "the master shows its background");
|
||||
assert_eq!(instance_of(&doc, "Shown").0.fills.len(), 1);
|
||||
}
|
||||
|
||||
/// How Sketch lays out a resized instance's layers, measured on its renders: a master of 200×100
|
||||
/// with a 100×40 rectangle at (20, 30), shown at 320×60.
|
||||
#[test]
|
||||
fn resized_instances_lay_out_like_sketch() {
|
||||
let case = |mask: i64, version: u64| {
|
||||
let r = with(rect("R", 20.0, 30.0, 100.0, 40.0), "resizingConstraint", json!(mask));
|
||||
let m = master("M", "S", "Box", 200.0, 100.0, vec![r]);
|
||||
let (doc, _) = open_version("64", version, vec![instance("I", "S", 0.0, 0.0, 320.0, 60.0, &[])], vec![m]);
|
||||
let (inst, _) = instance_of(&doc, "I");
|
||||
let b = doc.world_bounds(inside(&doc, inst, "R").id).unwrap();
|
||||
(b.x0, b.y0, b.width(), b.height())
|
||||
};
|
||||
let near = |a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)| {
|
||||
(a.0 - b.0).abs() < 0.5 && (a.1 - b.1).abs() < 0.5 && (a.2 - b.2).abs() < 0.5 && (a.3 - b.3).abs() < 0.5
|
||||
};
|
||||
// Sketch 52 and newer (format 110+).
|
||||
for (mask, want) in [
|
||||
(63, (32.0, 18.0, 160.0, 24.0)),
|
||||
(59, (20.0, 18.0, 166.67, 24.0)),
|
||||
(62, (40.0, 18.0, 200.0, 24.0)),
|
||||
(61, (62.0, 18.0, 100.0, 24.0)),
|
||||
(58, (20.0, 18.0, 220.0, 24.0)),
|
||||
(57, (20.0, 18.0, 100.0, 24.0)),
|
||||
(60, (140.0, 18.0, 100.0, 24.0)),
|
||||
(54, (40.0, 12.86, 200.0, 17.14)),
|
||||
(0, (32.0, 18.0, 160.0, 24.0)),
|
||||
(47, (32.0, 10.0, 160.0, 40.0)),
|
||||
] {
|
||||
let got = case(mask, 125);
|
||||
assert!(near(got, want), "mask {mask}: {got:?} vs {want:?}");
|
||||
}
|
||||
// Older files: everything scales.
|
||||
for mask in [59, 62, 61, 0] {
|
||||
let got = case(mask, 103);
|
||||
assert!(near(got, (32.0, 18.0, 160.0, 24.0)), "legacy mask {mask}: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A nested swap to a symbol the file doesn't have keeps the original, as Sketch shows it.
|
||||
#[test]
|
||||
fn swap_to_a_missing_symbol_keeps_the_original() {
|
||||
let icon = master("MA", "SA", "Icon A", 20.0, 20.0, vec![rect("Dot A", 0.0, 0.0, 20.0, 20.0)]);
|
||||
let card = master("MC", "SC", "Card", 100.0, 40.0, vec![instance("N", "SA", 0.0, 0.0, 20.0, 20.0, &[])]);
|
||||
let (doc, warnings) = open(vec![instance("I", "SC", 0.0, 0.0, 100.0, 40.0, &[("N_symbolID", json!("GONE"))])], vec![icon, card]);
|
||||
let (inst, _) = instance_of(&doc, "I");
|
||||
assert!(doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot A"));
|
||||
assert!(!warnings.iter().any(|w| w.contains("empty frames")), "{warnings:?}");
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! `.sketch` import: detection, the archive, the version cut and pages, on fixtures built in code.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::fixture::*;
|
||||
use super::*;
|
||||
|
||||
fn page_names(doc: &Document) -> Vec<String> {
|
||||
doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_sketch_and_not_others() {
|
||||
let z = sketch_zip("50.2", 103, document(&["P1"]), &[page("P1", "Page 1", vec![])], &[]);
|
||||
assert!(is_sketch(&z));
|
||||
assert!(!is_sketch(b"fig-kiwi...."));
|
||||
assert!(!is_sketch(b"{}"));
|
||||
assert!(!is_sketch(b""));
|
||||
// A zip that isn't a Sketch file.
|
||||
let mut other = Vec::new();
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut other));
|
||||
w.start_file("canvas.fig", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
w.write_all(b"x").unwrap();
|
||||
w.finish().unwrap();
|
||||
}
|
||||
assert!(!is_sketch(&other));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_become_pages_in_document_order() {
|
||||
let z = sketch_zip("50.2", 103, document(&["B", "A"]), &[page("A", "Alpha", vec![]), page("B", "Beta", vec![])], &[]);
|
||||
let (doc, _) = import(&z, "f").unwrap();
|
||||
assert_eq!(page_names(&doc), ["Beta", "Alpha"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_than_48_is_refused() {
|
||||
let z = sketch_zip("47.1", 95, document(&["P"]), &[page("P", "P", vec![])], &[]);
|
||||
let e = import(&z, "f").unwrap_err().to_string();
|
||||
assert!(e.contains(TOO_OLD), "{e}");
|
||||
let mut sqlite = b"SQLite format 3\0".to_vec();
|
||||
sqlite.resize(512, 0);
|
||||
assert!(is_sketch(&sqlite));
|
||||
assert!(import(&sqlite, "f").unwrap_err().to_string().contains(TOO_OLD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archived_text_without_meta_version_is_refused() {
|
||||
let text = json!({
|
||||
"_class": "text", "do_objectID": "T", "name": "T",
|
||||
"frame": {"_class": "rect", "x": 0, "y": 0, "width": 10, "height": 10},
|
||||
"attributedString": {"_class": "MSAttributedString", "archivedAttributedString": {"_archive": "YnBsaXN0MDA="}},
|
||||
});
|
||||
let z = raw_zip(&document(&["P"]), &json!({"app": "com.bohemiancoding.sketch3"}), &[page("P", "P", vec![text])], &[]);
|
||||
let e = import(&z, "f").unwrap_err().to_string();
|
||||
assert!(e.contains(TOO_OLD), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_than_tested_imports_with_one_warning() {
|
||||
let z = sketch_zip("2099.1", NEWEST_TESTED_VERSION + 1, document(&["P"]), &[page("P", "P", vec![])], &[]);
|
||||
let (_, warnings) = import(&z, "f").unwrap();
|
||||
assert_eq!(warnings.iter().filter(|w| w.contains("newer Sketch")).count(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_never_panics() {
|
||||
let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "P", vec![rect("R", 0.0, 0.0, 10.0, 10.0)])], &[]);
|
||||
for cut in (0..z.len()).step_by(7) {
|
||||
let _ = import(&z[..cut], "f");
|
||||
}
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
for _ in 0..200 {
|
||||
let mut b = z.clone();
|
||||
for _ in 0..8 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
let i = seed as usize % b.len();
|
||||
b[i] = (seed >> 8) as u8;
|
||||
}
|
||||
let _ = import(&b, "f");
|
||||
}
|
||||
for doc in [json!([]), json!(null), json!({"pages": 5}), json!({"pages": [{"_ref": 7}, {"_ref": "pages/../../x"}]})] {
|
||||
let _ = import(&sketch_zip("50.2", 103, doc, &[json!(5), json!({"do_objectID": "Q", "layers": "x"})], &[]), "f");
|
||||
}
|
||||
let _ = import(&raw_zip(&document(&["P"]), &json!("x"), &[], &[]), "f");
|
||||
}
|
||||
|
||||
/// A file using most of what the importer reads (Sketch 2025 frames and stacks, tints, symbols
|
||||
/// with overrides, text, paths, gradients, masks, fades, grids) with each of its values in turn
|
||||
/// replaced by a value of another type: every import ends without a panic.
|
||||
#[test]
|
||||
fn junk_values_never_panic() {
|
||||
let text_layer = text("T", 0.0, 0.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]);
|
||||
let dot = styled(rect("Dot", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)]));
|
||||
let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0);
|
||||
tint["layeringType"] = json!(1);
|
||||
let mut icon = master("MI", "SI", "Icon", 24.0, 24.0, vec![dot.clone()]);
|
||||
icon["groupBehavior"] = json!(2);
|
||||
icon["style"]["fills"] = json!([tint]);
|
||||
let mut chip = stack(
|
||||
master("MC", "SC", "Chip", 60.0, 24.0, vec![text_layer.clone(), instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[])]),
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
6.0,
|
||||
[8.0, 4.0, 10.0, 4.0],
|
||||
);
|
||||
chip["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [12], "style": 2, "smoothing": 0.6});
|
||||
let over = [
|
||||
("T_stringValue", json!("Longer")),
|
||||
("N_fillColor", color(0.0, 1.0, 0.0, 1.0)),
|
||||
("T_textColor", color(0.0, 0.0, 1.0, 1.0)),
|
||||
("N_isVisible", json!("0")),
|
||||
];
|
||||
let inst = instance("I", "SC", 0.0, 100.0, 200.0, 24.0, &over);
|
||||
let grad = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0),
|
||||
"gradient": {"_class": "gradient", "gradientType": 0, "from": "{0, 0}", "to": "{1, 1}", "stops": [
|
||||
{"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(1.0, 1.0, 1.0, 0.0)}]}});
|
||||
let mut faded = styled(rect("Faded", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad.clone()]));
|
||||
faded["style"]["contextSettings"] =
|
||||
json!({"_class": "graphicsContextSettings", "opacity": 1, "isProgressive": true, "gradient": grad["gradient"].clone()});
|
||||
let mask =
|
||||
with(with(styled(rect("Mask", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad])), "hasClippingMask", json!(true)), "clippingMaskMode", json!(1));
|
||||
let path = shape_path("P", 0.0, 0.0, 20.0, 20.0, &[(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)], true);
|
||||
let mut g = group("G", 0.0, 0.0, 100.0, 100.0, vec![mask, faded, path, dot, text_layer]);
|
||||
g["style"] = json!({"_class": "style", "fills": [tint_fill()]});
|
||||
let mut frame = stack(frame_group("F", 0.0, 0.0, 300.0, 300.0, vec![g, inst]), 1, 0, 3, 8.0, [0.0; 4]);
|
||||
frame["layout"] =
|
||||
json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 20, "gutterWidth": 10, "numberOfColumns": 4, "drawVertical": true});
|
||||
let pages = [page("P", "Page", vec![frame]), page("S", "Symbols", vec![icon, chip])];
|
||||
// Every value's path, with each junk value in turn (a deterministic sample keeps it quick).
|
||||
fn paths(v: &serde_json::Value, at: &mut Vec<String>, out: &mut Vec<Vec<String>>) {
|
||||
out.push(at.clone());
|
||||
match v {
|
||||
serde_json::Value::Object(m) => {
|
||||
for (k, c) in m {
|
||||
at.push(k.clone());
|
||||
paths(c, at, out);
|
||||
at.pop();
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(a) => {
|
||||
for (i, c) in a.iter().enumerate() {
|
||||
at.push(i.to_string());
|
||||
paths(c, at, out);
|
||||
at.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let junk = [json!(null), json!(5), json!(-1e300), json!("x"), json!(true), json!([]), json!({}), json!([1, "a"])];
|
||||
for (pi, p) in pages.iter().enumerate() {
|
||||
let mut all = Vec::new();
|
||||
paths(p, &mut Vec::new(), &mut all);
|
||||
for (n, at) in all.iter().enumerate().skip(1) {
|
||||
let mut changed = pages.clone();
|
||||
let mut v = &mut changed[pi];
|
||||
for k in at {
|
||||
v = match v {
|
||||
serde_json::Value::Object(m) => m.get_mut(k).unwrap(),
|
||||
serde_json::Value::Array(a) => &mut a[k.parse::<usize>().unwrap()],
|
||||
_ => panic!("a path only runs through objects and arrays"),
|
||||
};
|
||||
}
|
||||
*v = junk[n % junk.len()].clone();
|
||||
let _ = import(&sketch_zip("2026.3.1", 196, document(&["P", "S"]), &changed, &[]), "f");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tint_fill() -> serde_json::Value {
|
||||
let mut f = solid_fill(0.0, 0.0, 1.0, 1.0);
|
||||
f["layeringType"] = json!(1);
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_does_not_fail_the_file() {
|
||||
let mut deep = rect("R", 0.0, 0.0, 1.0, 1.0);
|
||||
for i in 0..300 {
|
||||
deep = group(&format!("G{i}"), 0.0, 0.0, 1.0, 1.0, vec![deep]);
|
||||
}
|
||||
let z = sketch_zip("50.2", 103, document(&["A", "B"]), &[page("A", "Deep", vec![deep]), page("B", "Normal", vec![])], &[]);
|
||||
let (doc, warnings) = import(&z, "f").unwrap();
|
||||
assert!(page_names(&doc).contains(&"Normal".to_string()));
|
||||
assert!(!warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_over_budget_are_skipped() {
|
||||
let z = sketch_zip("50.2", 103, document(&["A", "B"]), &[page("A", "Big", vec![]), page("B", "Small", vec![])], &[]);
|
||||
let (doc, warnings) = import_within(&z, "f", 200).unwrap();
|
||||
// Each page JSON is larger than 200 bytes: both are skipped, with a warning; nothing panics.
|
||||
assert!(warnings.iter().any(|w| w.contains("too large")), "{warnings:?}");
|
||||
assert!(!page_names(&doc).contains(&"Big".to_string()));
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Sketch text layers → REST text nodes.
|
||||
//!
|
||||
//! A text layer's `attributedString` holds the `string` and `attributes`: runs given by `location`
|
||||
//! and `length` in UTF-16 code units (like the REST importer's per-character style ids), each with
|
||||
//! a font (PostScript name and size), colour, kerning, paragraph style, decorations and text
|
||||
//! transform. `textBehaviour` sizes the box (0 auto width, 1 fixed width, 2 fixed); the style's
|
||||
//! `textStyle` gives the vertical alignment.
|
||||
|
||||
use serde_json::{Map as JsonMap, Value, json};
|
||||
|
||||
use super::tree::{Cx, color};
|
||||
|
||||
/// Most characters read from one text layer (the REST importer's own limit).
|
||||
const MAX_CHARS: usize = 1 << 20;
|
||||
/// Most runs read from one text layer.
|
||||
const MAX_RUNS: usize = 10_000;
|
||||
|
||||
/// Fill `node` (a TEXT node) from text layer `l`.
|
||||
pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap<String, Value>) {
|
||||
let a = l.get("attributedString");
|
||||
let string: String = a.and_then(|a| a.get("string")).and_then(Value::as_str).unwrap_or("").chars().take(MAX_CHARS).collect();
|
||||
let units = string.encode_utf16().count();
|
||||
let runs: &[Value] = a.and_then(|a| a.get("attributes")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
if runs.len() > MAX_RUNS {
|
||||
cx.warn(format!("text with more than {MAX_RUNS} styled runs keeps the first ones"));
|
||||
}
|
||||
let fallback = l.get("style").and_then(|s| s.get("textStyle")).and_then(|t| t.get("encodedAttributes"));
|
||||
let styles: Vec<(usize, usize, Value)> = runs
|
||||
.iter()
|
||||
.take(MAX_RUNS)
|
||||
.filter_map(|r| {
|
||||
let start = usize::try_from(r.get("location")?.as_u64()?).ok()?.min(units);
|
||||
let len = usize::try_from(r.get("length")?.as_u64()?).unwrap_or(usize::MAX);
|
||||
Some((start, start.saturating_add(len).min(units), type_style(cx, r.get("attributes")?)))
|
||||
})
|
||||
.collect();
|
||||
let base = styles.first().map(|s| s.2.clone()).or_else(|| fallback.map(|f| type_style(cx, f))).unwrap_or_else(|| type_style(cx, &json!({})));
|
||||
let mut style = base.clone();
|
||||
let mut fills = None;
|
||||
if let Some(m) = style.as_object_mut() {
|
||||
fills = m.remove("fills");
|
||||
// Paragraph settings are the first run's.
|
||||
let paragraph = runs.first().and_then(|r| r.get("attributes")).or(fallback).and_then(|a| a.get("paragraphStyle"));
|
||||
let align = match paragraph.and_then(|p| p.get("alignment")).and_then(Value::as_i64) {
|
||||
Some(1) => "RIGHT",
|
||||
Some(2) => "CENTER",
|
||||
Some(3) => "JUSTIFIED",
|
||||
_ => "LEFT",
|
||||
};
|
||||
m.insert("textAlignHorizontal".into(), json!(align));
|
||||
if let Some(sp) = paragraph.and_then(|p| p.get("paragraphSpacing")).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) {
|
||||
m.insert("paragraphSpacing".into(), json!(sp.min(1e4)));
|
||||
}
|
||||
let vertical = l.get("style").and_then(|s| s.get("textStyle")).and_then(|t| t.get("verticalAlignment")).and_then(Value::as_i64);
|
||||
m.insert(
|
||||
"textAlignVertical".into(),
|
||||
json!(match vertical {
|
||||
Some(1) => "CENTER",
|
||||
Some(2) => "BOTTOM",
|
||||
_ => "TOP",
|
||||
}),
|
||||
);
|
||||
// Sketch 2025 and newer size text by `horizontalSizing`/`verticalSizing` (1 fits).
|
||||
let fits = |k: &str| l.get(k).and_then(Value::as_i64) == Some(1);
|
||||
let resize = match l.get("textBehaviour").and_then(Value::as_i64) {
|
||||
Some(1) => "HEIGHT",
|
||||
Some(2) => "NONE",
|
||||
Some(_) => "WIDTH_AND_HEIGHT",
|
||||
None if l.get("horizontalSizing").is_none() || fits("horizontalSizing") => "WIDTH_AND_HEIGHT",
|
||||
None if fits("verticalSizing") => "HEIGHT",
|
||||
None => "NONE",
|
||||
};
|
||||
m.insert("textAutoResize".into(), json!(resize));
|
||||
}
|
||||
node.insert("characters".into(), json!(string));
|
||||
node.insert("style".into(), style);
|
||||
if let Some(f) = fills {
|
||||
node.insert("fills".into(), f);
|
||||
}
|
||||
// Runs that differ from the first: a style id per UTF-16 unit.
|
||||
let mut ids = vec![0u64; units];
|
||||
let mut table = JsonMap::new();
|
||||
for (start, end, s) in &styles {
|
||||
if *s == base {
|
||||
continue;
|
||||
}
|
||||
let id = table.len() as u64 + 1;
|
||||
table.insert(id.to_string(), s.clone());
|
||||
for slot in ids.get_mut(*start..*end).into_iter().flatten() {
|
||||
*slot = id;
|
||||
}
|
||||
}
|
||||
if !table.is_empty() {
|
||||
node.insert("characterStyleOverrides".into(), json!(ids));
|
||||
node.insert("styleOverrideTable".into(), Value::Object(table));
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a text layer moves so its lines sit where Sketch draws them. With a fixed line height,
|
||||
/// Sketch puts a line's extra height (line height − the font's own, taken as 1.2 × its size) above
|
||||
/// the text (`lineSpacingBehaviour` 0) or below it (1); FigCraft, like Sketch's default (2), puts
|
||||
/// half on each side. Every line gets the same extra, so the whole layer moves.
|
||||
pub(crate) fn baseline_shift(l: &Value) -> f64 {
|
||||
let first = l
|
||||
.get("attributedString")
|
||||
.and_then(|a| a.get("attributes"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|r| r.first())
|
||||
.and_then(|r| r.get("attributes"));
|
||||
let Some(a) = first else { return 0.0 };
|
||||
let size = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")).and_then(|f| f.get("size")).and_then(Value::as_f64);
|
||||
let line = a.get("paragraphStyle").and_then(|p| p.get("maximumLineHeight").or_else(|| p.get("minimumLineHeight"))).and_then(Value::as_f64);
|
||||
let (Some(size), Some(line)) = (size.filter(|s| s.is_finite() && *s > 0.0), line.filter(|v| v.is_finite() && *v > 0.0)) else { return 0.0 };
|
||||
let extra = (line - size * 1.2).clamp(-1e4, 1e4);
|
||||
match l.get("lineSpacingBehaviour").and_then(Value::as_i64) {
|
||||
Some(0) => extra / 2.0,
|
||||
Some(1) => -extra / 2.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A run's attributes as a REST type style (with its colour as `fills`).
|
||||
pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value {
|
||||
let font = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes"));
|
||||
let ps = font.and_then(|f| f.get("name")).and_then(Value::as_str).unwrap_or("");
|
||||
let size = font.and_then(|f| f.get("size")).and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(12.0);
|
||||
let (family, style) = installed(cx, ps).unwrap_or_else(|| font_name(ps));
|
||||
if ps.is_empty() {
|
||||
cx.warn("text without a font uses Helvetica");
|
||||
}
|
||||
let mut s = json!({"fontFamily": family, "fontStyle": style, "fontSize": size.min(10_000.0)});
|
||||
// Every field is written, neutral when the run has none: a run's style starts from the text's
|
||||
// (its first run's), and an unwritten field would keep that one's value.
|
||||
s["fontVariations"] = font.and_then(|f| f.get("variation")).and_then(variations).unwrap_or_else(|| json!({}));
|
||||
let p = a.get("paragraphStyle");
|
||||
let line = |k: &str| p.and_then(|p| p.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
|
||||
if let Some(lh) = line("maximumLineHeight").or_else(|| line("minimumLineHeight")) {
|
||||
s["lineHeightPx"] = json!(lh.min(1e4));
|
||||
s["lineHeightUnit"] = json!("PIXELS");
|
||||
} else {
|
||||
s["lineHeightUnit"] = json!("INTRINSIC_%");
|
||||
}
|
||||
s["letterSpacing"] = json!(a.get("kerning").and_then(Value::as_f64).filter(|k| k.is_finite()).unwrap_or(0.0).clamp(-1e4, 1e4));
|
||||
let set = |k: &str| a.get(k).and_then(Value::as_i64).is_some_and(|v| v != 0);
|
||||
s["textDecoration"] = json!(if set("underlineStyle") {
|
||||
"UNDERLINE"
|
||||
} else if set("strikethroughStyle") {
|
||||
"STRIKETHROUGH"
|
||||
} else {
|
||||
"NONE"
|
||||
});
|
||||
s["textCase"] = json!(match a.get("MSAttributedStringTextTransformAttribute").and_then(Value::as_i64) {
|
||||
Some(1) => "UPPER",
|
||||
Some(2) => "LOWER",
|
||||
_ => "ORIGINAL",
|
||||
});
|
||||
let c = a.get("MSAttributedStringColorAttribute").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}));
|
||||
let mut paint = json!({"type": "SOLID", "color": c});
|
||||
if let Some(var) = super::style::swatch(cx, a.get("MSAttributedStringColorAttribute")) {
|
||||
paint["boundVariables"] = var;
|
||||
}
|
||||
s["fills"] = json!([paint]);
|
||||
s
|
||||
}
|
||||
|
||||
/// A font's `variation` (axis values keyed by the axis tag read as a big-endian number, as Apple's
|
||||
/// text engine keys them: 2003265652 is `wght`) as `{tag: value}`; `None` when it has none.
|
||||
fn variations(v: &Value) -> Option<Value> {
|
||||
let axes: JsonMap<String, Value> = v
|
||||
.as_object()?
|
||||
.iter()
|
||||
.take(64)
|
||||
.filter_map(|(k, val)| {
|
||||
let tag = u32::try_from(k.parse::<u64>().ok()?).ok()?.to_be_bytes();
|
||||
let tag = std::str::from_utf8(&tag).ok().filter(|t| t.chars().all(|c| c.is_ascii_graphic()))?.to_string();
|
||||
let val = val.as_f64().filter(|x| x.is_finite())?;
|
||||
Some((tag, json!(val.clamp(-1e5, 1e5))))
|
||||
})
|
||||
.collect();
|
||||
(!axes.is_empty()).then_some(Value::Object(axes))
|
||||
}
|
||||
|
||||
/// The installed family and style of PostScript name `ps`, when the import has a font lookup and
|
||||
/// the font is installed.
|
||||
fn installed(cx: &mut Cx, ps: &str) -> Option<(String, String)> {
|
||||
if ps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(found) = cx.font_cache.get(ps) {
|
||||
return found.clone();
|
||||
}
|
||||
let found = cx.fonts.as_ref().and_then(|f| f(ps));
|
||||
// A file names a few hundred fonts at most; junk ones don't grow the cache past that.
|
||||
if cx.font_cache.len() < 4096 {
|
||||
cx.font_cache.insert(ps.to_string(), found.clone());
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Family and style of a font from its PostScript name: "HelveticaNeue-Bold" → ("Helvetica Neue",
|
||||
/// "Bold"). Words of the family are split at case changes, which can't always match the real name
|
||||
/// (a CJK family would split as "Ping Fang SC"); the engine matches families against the installed
|
||||
/// fonts ignoring spaces. Apple's system fonts take the SF Pro names, PingFang its own.
|
||||
pub(crate) fn font_name(ps: &str) -> (String, String) {
|
||||
let (family, style) = ps.split_once('-').unwrap_or((ps, ""));
|
||||
let style = if style.is_empty() { "Regular".to_string() } else { crate::fig::style_name(style) };
|
||||
let family = match family {
|
||||
"" => "Helvetica".to_string(),
|
||||
".SFNSText" | ".SFUIText" | "SanFranciscoText" | ".SFProText" => "SF Pro Text".into(),
|
||||
".SFNSDisplay" | ".SFUIDisplay" | "SanFranciscoDisplay" | ".SFProDisplay" => "SF Pro Display".into(),
|
||||
// Apple's CJK families whose names don't split at case changes.
|
||||
"PingFangSC" | "PingFangTC" | "PingFangHK" => format!("PingFang {}", family.get(8..).unwrap_or("")),
|
||||
// Apple's SF families: "SF" and then words ("SFUIText" → "SF UI Text").
|
||||
f if f.starts_with("SF") && f.chars().nth(2).is_some_and(char::is_uppercase) => format!("SF {}", words(f.get(2..).unwrap_or(""))),
|
||||
f => words(f.trim_start_matches('.')),
|
||||
};
|
||||
(family, style)
|
||||
}
|
||||
|
||||
/// "IBMPlexSans" → "IBM Plex Sans": a space before a capital that follows a small letter, and
|
||||
/// before the last capital of a run of capitals followed by a small letter.
|
||||
fn words(s: &str) -> String {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut out = String::with_capacity(s.len() + 4);
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
let prev = i.checked_sub(1).and_then(|p| chars.get(p));
|
||||
let next = chars.get(i + 1);
|
||||
let split = c.is_uppercase()
|
||||
&& (prev.is_some_and(|p| p.is_lowercase()) || (prev.is_some_and(|p| p.is_uppercase()) && next.is_some_and(|n| n.is_lowercase())));
|
||||
if split {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push(*c);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Sketch text layers → text with styled runs; font PostScript names → family and style.
|
||||
|
||||
use figcraft_doc::color::PaintKind;
|
||||
use figcraft_doc::{Document, LetterSpacing, LineHeight, NodeKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fixture::*;
|
||||
use super::import;
|
||||
use super::text::font_name;
|
||||
|
||||
const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0];
|
||||
const RED: [f64; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
|
||||
fn open(layers: Vec<Value>) -> (Document, Vec<String>) {
|
||||
let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]);
|
||||
import(&z, "f").unwrap()
|
||||
}
|
||||
|
||||
fn data<'d>(doc: &'d Document, name: &str) -> &'d TextData {
|
||||
match &doc.nodes().find(|n| n.name == name).unwrap().kind {
|
||||
NodeKind::Text(td) => td,
|
||||
other => panic!("{name}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn red(fills: &Option<Vec<figcraft_doc::color::Paint>>) -> bool {
|
||||
fills.as_ref().is_some_and(|f| f.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_runs() {
|
||||
let t = text("Hi", 0.0, 0.0, 200.0, 20.0, "Hello world", &[(0, 5, "HelveticaNeue-Bold", 16.0, RED), (5, 6, "HelveticaNeue", 12.0, BLACK)]);
|
||||
let (doc, _) = open(vec![t]);
|
||||
let td = data(&doc, "Hi");
|
||||
assert_eq!(td.characters, "Hello world");
|
||||
let style_at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style);
|
||||
let (a, b) = (style_at(0), style_at(7));
|
||||
assert_eq!((a.font_family.as_str(), a.font_style.as_str(), a.font_size), ("Helvetica Neue", "Bold", 16.0));
|
||||
assert_eq!((b.font_family.as_str(), b.font_style.as_str(), b.font_size), ("Helvetica Neue", "Regular", 12.0));
|
||||
let node = doc.nodes().find(|n| n.name == "Hi").unwrap();
|
||||
assert!(red(&a.fills) || (red(&Some(node.fills.clone())) && !red(&b.fills)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_ranges() {
|
||||
// "a😀b": the emoji takes two UTF-16 units.
|
||||
let t = text(
|
||||
"Emoji",
|
||||
0.0,
|
||||
0.0,
|
||||
200.0,
|
||||
20.0,
|
||||
"a😀b",
|
||||
&[(0, 1, "Helvetica", 10.0, BLACK), (1, 2, "Helvetica", 20.0, BLACK), (3, 1, "Helvetica", 30.0, BLACK)],
|
||||
);
|
||||
let (doc, _) = open(vec![t]);
|
||||
let td = data(&doc, "Emoji");
|
||||
let size_at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(td.style.font_size, |r| r.style.font_size);
|
||||
assert_eq!((size_at(0), size_at(1), size_at(2)), (10.0, 20.0, 30.0));
|
||||
// A range splitting the emoji and one past the end: no panic, the text stays whole.
|
||||
let t = text(
|
||||
"Split",
|
||||
0.0,
|
||||
50.0,
|
||||
200.0,
|
||||
20.0,
|
||||
"a😀b",
|
||||
&[(0, 2, "Helvetica", 10.0, BLACK), (2, 99, "Helvetica", 20.0, BLACK), (500, 3, "Helvetica", 5.0, BLACK)],
|
||||
);
|
||||
let (doc, _) = open(vec![t]);
|
||||
assert_eq!(data(&doc, "Split").characters, "a😀b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_and_spacing() {
|
||||
let mut t = text("Para", 0.0, 0.0, 200.0, 40.0, "Centered", &[(0, 8, "Helvetica", 14.0, BLACK)]);
|
||||
let a = &mut t["attributedString"]["attributes"][0]["attributes"];
|
||||
a["paragraphStyle"] =
|
||||
json!({"_class": "paragraphStyle", "alignment": 2, "maximumLineHeight": 24, "minimumLineHeight": 24, "paragraphSpacing": 8});
|
||||
a["kerning"] = json!(0.5);
|
||||
let (doc, _) = open(vec![t]);
|
||||
let td = data(&doc, "Para");
|
||||
assert_eq!(td.align_h, TextAlignH::Center);
|
||||
assert_eq!(td.style.line_height, LineHeight::Pixels(24.0));
|
||||
assert_eq!(td.paragraph_spacing, 8.0);
|
||||
assert_eq!(td.style.letter_spacing, LetterSpacing::Pixels(0.5));
|
||||
for (code, want) in [(0, TextAlignH::Left), (1, TextAlignH::Right), (3, TextAlignH::Justified), (4, TextAlignH::Left)] {
|
||||
let mut t = text("A", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]);
|
||||
t["attributedString"]["attributes"][0]["attributes"]["paragraphStyle"]["alignment"] = json!(code);
|
||||
let (doc, _) = open(vec![t]);
|
||||
assert_eq!(data(&doc, "A").align_h, want, "alignment {code}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behaviour_and_vertical_alignment() {
|
||||
let cases = [(0, TextAutoResize::WidthAndHeight), (1, TextAutoResize::Height), (2, TextAutoResize::None)];
|
||||
for (b, want) in cases {
|
||||
let mut t = text("T", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]);
|
||||
t["textBehaviour"] = json!(b);
|
||||
let (doc, _) = open(vec![t]);
|
||||
assert_eq!(data(&doc, "T").auto_resize, want, "behaviour {b}");
|
||||
}
|
||||
for (v, want) in [(0, TextAlignV::Top), (1, TextAlignV::Middle), (2, TextAlignV::Bottom)] {
|
||||
let mut t = text("V", 0.0, 0.0, 100.0, 60.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]);
|
||||
t["textBehaviour"] = json!(2);
|
||||
t["style"]["textStyle"] = json!({"_class": "textStyle", "verticalAlignment": v, "encodedAttributes": {}});
|
||||
let (doc, _) = open(vec![t]);
|
||||
assert_eq!(data(&doc, "V").align_v, want, "vertical {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decorations_and_case() {
|
||||
let mut t = text(
|
||||
"Deco",
|
||||
0.0,
|
||||
0.0,
|
||||
200.0,
|
||||
20.0,
|
||||
"abcdef",
|
||||
&[(0, 2, "Helvetica", 14.0, BLACK), (2, 2, "Helvetica", 14.0, BLACK), (4, 2, "Helvetica", 14.0, BLACK)],
|
||||
);
|
||||
t["attributedString"]["attributes"][0]["attributes"]["underlineStyle"] = json!(1);
|
||||
t["attributedString"]["attributes"][1]["attributes"]["strikethroughStyle"] = json!(1);
|
||||
t["attributedString"]["attributes"][2]["attributes"]["MSAttributedStringTextTransformAttribute"] = json!(1);
|
||||
let (doc, _) = open(vec![t]);
|
||||
let td = data(&doc, "Deco");
|
||||
let at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style);
|
||||
assert_eq!(at(0).decoration, TextDecoration::Underline);
|
||||
assert_eq!(at(2).decoration, TextDecoration::Strikethrough);
|
||||
assert_eq!(at(4).case, TextCase::Upper);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_layer_fill_wins_over_run_colour() {
|
||||
let t = styled(text("Filled", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]), "fills", json!([solid_fill(1.0, 0.0, 0.0, 1.0)]));
|
||||
let (doc, _) = open(vec![t]);
|
||||
let n = doc.nodes().find(|n| n.name == "Filled").unwrap();
|
||||
assert!(red(&Some(n.fills.clone())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_names() {
|
||||
let cases = [
|
||||
("HelveticaNeue-Bold", "Helvetica Neue", "Bold"),
|
||||
("HelveticaNeue", "Helvetica Neue", "Regular"),
|
||||
("HelveticaNeue-Medium", "Helvetica Neue", "Medium"),
|
||||
("PingFangSC-Semibold", "PingFang SC", "Semi Bold"),
|
||||
("PingFangTC-Regular", "PingFang TC", "Regular"),
|
||||
(".SFNSText", "SF Pro Text", "Regular"),
|
||||
(".SFNSDisplay-Bold", "SF Pro Display", "Bold"),
|
||||
("SanFranciscoText-Semibold", "SF Pro Text", "Semi Bold"),
|
||||
("Impact", "Impact", "Regular"),
|
||||
("SFUIText-Semibold", "SF UI Text", "Semi Bold"),
|
||||
("SFUIDisplay-Regular", "SF UI Display", "Regular"),
|
||||
("SFCompactDisplay-Semibold", "SF Compact Display", "Semi Bold"),
|
||||
("SFProText-Bold", "SF Pro Text", "Bold"),
|
||||
("IBMPlexSans-MediumItalic", "IBM Plex Sans", "Medium Italic"),
|
||||
("Roboto-Italic", "Roboto", "Italic"),
|
||||
("", "Helvetica", "Regular"),
|
||||
];
|
||||
for (ps, family, style) in cases {
|
||||
assert_eq!(font_name(ps), (family.to_string(), style.to_string()), "{ps}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_and_odd_text_is_safe() {
|
||||
let long = "x".repeat(10_000);
|
||||
let t = text("Long", 0.0, 0.0, 100.0, 20.0, &long, &[(0, usize::MAX >> 1, "Helvetica", 14.0, BLACK)]);
|
||||
let mut odd = text("Odd", 0.0, 0.0, 100.0, 20.0, "abc", &[(0, 3, "Helvetica", -5.0, BLACK)]);
|
||||
odd["attributedString"]["attributes"][0]["attributes"]["MSAttributedStringFontAttribute"] = json!("junk");
|
||||
let mut empty = text("Empty", 0.0, 0.0, 100.0, 20.0, "", &[]);
|
||||
empty["attributedString"]["string"] = json!(5);
|
||||
let (doc, _) = open(vec![t, odd, empty]);
|
||||
assert_eq!(data(&doc, "Long").characters.len(), 10_000);
|
||||
assert!(data(&doc, "Odd").style.font_size > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_fonts_are_reported() {
|
||||
let mut d = document(&["P"]);
|
||||
d["fontReferences"] = json!([
|
||||
{"_class": "fontReference", "fontFamilyName": "IBM Plex Sans", "fontFileName": "IBMPlexSans-Regular.otf", "fontData": {"_ref": "fonts/abc"}},
|
||||
{"_class": "fontReference", "fontFamilyName": "IBM Plex Sans", "fontFileName": "IBMPlexSans-Bold.otf", "fontData": {"_ref": "fonts/def"}},
|
||||
{"_class": "fontReference", "fontFamilyName": "IBM Plex Mono", "fontData": {"_ref": "fonts/ghi"}},
|
||||
]);
|
||||
let z = sketch_zip("83.2", 139, d, &[page("P", "Page", vec![])], &[("fonts/abc", b"x")]);
|
||||
let (_, warnings) = import(&z, "f").unwrap();
|
||||
let w = warnings.iter().find(|w| w.contains("embedded")).expect("a note about embedded fonts");
|
||||
assert!(w.contains("IBM Plex Mono") && w.contains("IBM Plex Sans"), "{w}");
|
||||
}
|
||||
|
||||
/// Where Sketch puts a line's extra height: above the text (`lineSpacingBehaviour` 0), below it
|
||||
/// (1) or around it (2, like FigCraft); the text layer moves by the difference.
|
||||
#[test]
|
||||
fn line_spacing_behaviour_moves_text() {
|
||||
let mk = |name: &str, beh: i64| {
|
||||
let mut t = text(name, 0.0, 100.0, 100.0, 30.0, "Hg", &[(0, 2, "Helvetica", 14.0, BLACK)]);
|
||||
t["lineSpacingBehaviour"] = json!(beh);
|
||||
let a = &mut t["attributedString"]["attributes"][0]["attributes"]["paragraphStyle"];
|
||||
a["maximumLineHeight"] = json!(30);
|
||||
a["minimumLineHeight"] = json!(30);
|
||||
t
|
||||
};
|
||||
let (doc, _) = open(vec![mk("B0", 0), mk("B1", 1), mk("B2", 2)]);
|
||||
let y = |n: &str| doc.world_transform(doc.nodes().find(|x| x.name == n).unwrap().id).as_coeffs()[5];
|
||||
// Extra height: 30 − 14 × 1.2 = 13.2; half of it either way.
|
||||
assert!((y("B2") - 100.0).abs() < 1e-6, "{}", y("B2"));
|
||||
assert!((y("B0") - 106.6).abs() < 1e-6, "{}", y("B0"));
|
||||
assert!((y("B1") - 93.4).abs() < 1e-6, "{}", y("B1"));
|
||||
}
|
||||
|
||||
/// A variable font's axes (Sketch keys them by the tag as a number: 2003265652 is `wght`).
|
||||
#[test]
|
||||
fn font_variations() {
|
||||
let mut t = text("Wide", 0.0, 0.0, 200.0, 50.0, "Orbit", &[(0, 5, "Skia", 40.0, BLACK)]);
|
||||
t["attributedString"]["attributes"][0]["attributes"]["MSAttributedStringFontAttribute"]["attributes"]["variation"] =
|
||||
json!({"2003265652": 3, "2003072104": 0.65, "junk": 1, "1": "x"});
|
||||
let (doc, _) = open(vec![t]);
|
||||
let v = &data(&doc, "Wide").style.variations;
|
||||
assert_eq!(v.get("wght"), Some(&3.0));
|
||||
assert_eq!(v.get("wdth"), Some(&0.65));
|
||||
assert_eq!(v.len(), 2, "{v:?}");
|
||||
}
|
||||
|
||||
/// Font references without font data are only the fonts the document uses: no note.
|
||||
#[test]
|
||||
fn font_references_without_data_are_no_embedded_fonts() {
|
||||
let mut d = document(&["P"]);
|
||||
d["fontReferences"] =
|
||||
json!([{"_class": "fontReference", "fontFamilyName": "Skia", "fontFileName": "Skia.ttf", "postscriptNames": ["Skia-Regular"]}]);
|
||||
let z = sketch_zip("2026.3.1", 196, d, &[page("P", "Page", vec![])], &[]);
|
||||
let (_, warnings) = import(&z, "f").unwrap();
|
||||
assert!(!warnings.iter().any(|w| w.contains("embedded")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// With a font lookup, a PostScript name gives the installed family and style it belongs to (the
|
||||
/// name alone can't always tell: "CourierNewPSMT" is Courier New Regular); without one, or for a
|
||||
/// font that isn't installed, the family is read from the name.
|
||||
#[test]
|
||||
fn postscript_names_find_installed_fonts() {
|
||||
let mk = |name: &str, ps: &str| text(name, 0.0, 0.0, 100.0, 20.0, "Hi", &[(0, 2, ps, 14.0, BLACK)]);
|
||||
let z = sketch_zip("83.2", 139, document(&["P"]), &[page("P", "Page", vec![mk("A", "CourierNewPSMT"), mk("B", "BrandSans-Bold")])], &[]);
|
||||
let lookup: crate::sketch::FontLookup = Box::new(|ps: &str| (ps == "CourierNewPSMT").then(|| ("Courier New".to_string(), "Regular".to_string())));
|
||||
let (doc, _) = crate::sketch::import_with_fonts(&z, "f", Some(lookup)).unwrap();
|
||||
assert_eq!((data(&doc, "A").style.font_family.as_str(), data(&doc, "A").style.font_style.as_str()), ("Courier New", "Regular"));
|
||||
assert_eq!(data(&doc, "B").style.font_family, "Brand Sans");
|
||||
let (doc, _) = import(&z, "f").unwrap();
|
||||
assert_eq!(data(&doc, "A").style.font_family, "Courier New PSMT");
|
||||
}
|
||||
|
||||
/// A run without kerning, a fixed line height or font axes has none: it doesn't take the first
|
||||
/// run's (the text's base style) as its own.
|
||||
#[test]
|
||||
fn later_runs_dont_inherit_the_first_runs_settings() {
|
||||
let mut t = text("Mixed", 0.0, 0.0, 200.0, 40.0, "AaBb", &[(0, 2, "Skia", 20.0, BLACK), (2, 2, "Helvetica", 14.0, BLACK)]);
|
||||
let first = &mut t["attributedString"]["attributes"][0]["attributes"];
|
||||
first["kerning"] = json!(2);
|
||||
first["paragraphStyle"]["minimumLineHeight"] = json!(24);
|
||||
first["paragraphStyle"]["maximumLineHeight"] = json!(24);
|
||||
first["MSAttributedStringFontAttribute"]["attributes"]["variation"] = json!({"2003265652": 3});
|
||||
let (doc, _) = open(vec![t]);
|
||||
let td = data(&doc, "Mixed");
|
||||
let at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style);
|
||||
assert_eq!(at(0).letter_spacing.resolve(20.0), 2.0);
|
||||
let second = at(2);
|
||||
assert_eq!(second.font_family, "Helvetica");
|
||||
assert_eq!(second.letter_spacing.resolve(14.0), 0.0, "{second:?}");
|
||||
assert_eq!(second.line_height, figcraft_doc::LineHeight::Auto, "{second:?}");
|
||||
assert!(second.variations.is_empty(), "{second:?}");
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
//! Sketch pages and layers → REST API file JSON nodes.
|
||||
|
||||
use figcraft_doc::VectorNetwork;
|
||||
use figcraft_doc::geom::{Affine, Rect};
|
||||
use serde_json::{Map as JsonMap, Value, json};
|
||||
|
||||
use super::Archive;
|
||||
|
||||
/// Most distinct notes kept.
|
||||
const MAX_NOTES: usize = 200;
|
||||
/// Work units symbol expansion may spend on one file (one per JSON value copied, see
|
||||
/// `symbols::weight`): about three times what the largest design kit checked spends (21M, with
|
||||
/// 2 GB at its peak), well below what runs out of memory; less on 32-bit targets (the web), where
|
||||
/// such a kit keeps its first instances and notes the rest.
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
const MAX_WORK: u64 = 60_000_000;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const MAX_WORK: u64 = 15_000_000;
|
||||
/// Deepest layer nesting followed (deeper layers are skipped with a warning).
|
||||
const MAX_DEPTH: usize = 200;
|
||||
/// Largest size and farthest position kept (the REST importer's own bounds).
|
||||
const MAX_SIZE: f64 = 1e6;
|
||||
const MAX_COORD: f64 = 1e7;
|
||||
|
||||
/// The REST JSON of a Sketch file, with what the mapping noticed.
|
||||
pub(crate) struct Map {
|
||||
pub json: Value,
|
||||
pub warnings: Vec<String>,
|
||||
/// Vector networks the JSON references by `vectorNetworkRef`.
|
||||
pub networks: Vec<Option<VectorNetwork>>,
|
||||
/// A text layer stored the pre-48 way was met: the file is refused.
|
||||
pub too_old: bool,
|
||||
}
|
||||
|
||||
/// The mapping's state while walking the file.
|
||||
pub(crate) struct Cx {
|
||||
pub symbols: super::symbols::Symbols,
|
||||
pub shared: super::shared::Shared,
|
||||
/// Ids of the styles made from the shared styles.
|
||||
pub style_defs: std::collections::BTreeSet<String>,
|
||||
/// Work spent expanding symbols, and the most the file may take (see `symbols::weight`).
|
||||
pub work: u64,
|
||||
pub max_work: u64,
|
||||
/// Notes given so far (each once).
|
||||
pub seen: std::collections::BTreeSet<String>,
|
||||
/// Inside a shape group: whether its shapes are filled (open ones then close) and whether the
|
||||
/// group has a border. Outside one: `None` / false (a shape's own style decides).
|
||||
pub operand_fill: Option<bool>,
|
||||
pub operand_border: bool,
|
||||
/// The file is older than format 110 (Sketch 52): Sketch scales a resized instance's layers
|
||||
/// instead of following their pins.
|
||||
pub legacy_resize: bool,
|
||||
/// The symbols being expanded, outermost first (a symbol inside itself stops there).
|
||||
pub chain: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
pub networks: Vec<Option<VectorNetwork>>,
|
||||
pub too_old: bool,
|
||||
/// Layers mapped so far, and the most the file may give.
|
||||
pub nodes: usize,
|
||||
pub max_nodes: usize,
|
||||
/// Finds installed faces by PostScript name, and what it found (or didn't) so far.
|
||||
pub fonts: Option<super::FontLookup>,
|
||||
pub font_cache: std::collections::BTreeMap<String, Option<(String, String)>>,
|
||||
}
|
||||
|
||||
impl Cx {
|
||||
pub(crate) fn warn(&mut self, msg: impl Into<String>) {
|
||||
let msg = msg.into();
|
||||
if self.seen.len() > MAX_NOTES || !self.seen.insert(msg.clone()) {
|
||||
return;
|
||||
}
|
||||
if self.seen.len() > MAX_NOTES {
|
||||
self.warnings.push("more notes were left out".into());
|
||||
} else {
|
||||
self.warnings.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spend `n` units of the work budget; false (with a note, once) when it would run out.
|
||||
pub(crate) fn spend(&mut self, n: u64) -> bool {
|
||||
if self.work.saturating_add(n) > self.max_work {
|
||||
self.work = self.max_work;
|
||||
self.warn("the file's symbols expand to more than the import handles; the instances past that were imported as empty frames");
|
||||
return false;
|
||||
}
|
||||
self.work += n;
|
||||
true
|
||||
}
|
||||
|
||||
/// Spend `n` units of the work budget on tinting; false (with a note, once) when it would run
|
||||
/// out.
|
||||
pub(crate) fn spend_on_tint(&mut self, n: u64) -> bool {
|
||||
if self.work.saturating_add(n) > self.max_work {
|
||||
self.warn("tints past what the import handles were skipped; those layers keep their own colours");
|
||||
return false;
|
||||
}
|
||||
self.work += n;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn work_left(&self) -> u64 {
|
||||
self.max_work.saturating_sub(self.work)
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge the copy tinting layer `l` makes against the work budget: whether it may be tinted.
|
||||
pub(crate) fn spend_on_tint(cx: &mut Cx, l: &Value) -> bool {
|
||||
cx.spend_on_tint(super::symbols::weight(l, 0))
|
||||
}
|
||||
|
||||
/// Map the pages of `archive` to a REST file JSON named `name`.
|
||||
pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize, fonts: Option<super::FontLookup>) -> Map {
|
||||
let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages);
|
||||
let shared = super::shared::Shared::read(&archive.document);
|
||||
let mut cx = Cx {
|
||||
symbols,
|
||||
shared,
|
||||
style_defs: std::collections::BTreeSet::new(),
|
||||
work: 0,
|
||||
max_work: MAX_WORK,
|
||||
seen: std::collections::BTreeSet::new(),
|
||||
operand_fill: None,
|
||||
operand_border: false,
|
||||
legacy_resize: archive.version > 0 && archive.version < 110,
|
||||
chain: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
networks: Vec::new(),
|
||||
too_old: false,
|
||||
nodes: 0,
|
||||
max_nodes,
|
||||
fonts,
|
||||
font_cache: std::collections::BTreeMap::new(),
|
||||
};
|
||||
let defs = super::shared::Shared::definitions(&mut cx);
|
||||
cx.style_defs = defs.iter().filter_map(|d| d.get("id").and_then(Value::as_str).map(str::to_string)).collect();
|
||||
let mut canvases = Vec::new();
|
||||
for (id, page) in &archive.pages {
|
||||
let mut node = JsonMap::new();
|
||||
node.insert("type".into(), json!("CANVAS"));
|
||||
node.insert("id".into(), json!(id));
|
||||
node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page")));
|
||||
let kids = children(&mut cx, page, Affine::IDENTITY, (0.0, 0.0), 0, true);
|
||||
node.insert("children".into(), Value::Array(kids));
|
||||
let starts = super::proto::starting_points(page);
|
||||
if !starts.is_empty() {
|
||||
node.insert("flowStartingPoints".into(), Value::Array(starts));
|
||||
}
|
||||
canvases.push(Value::Object(node));
|
||||
}
|
||||
let foreign = std::mem::take(&mut cx.symbols.foreign);
|
||||
if !foreign.is_empty() {
|
||||
canvases.push(super::symbols::library_page(&mut cx, &foreign));
|
||||
let libraries = cx.symbols.libraries.join(", ");
|
||||
cx.warn(format!("symbols from the libraries {libraries} became local components on the page Library symbols"));
|
||||
}
|
||||
let mut json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}, "styleDefinitions": defs});
|
||||
if let Some((collections, variables)) = cx.shared.variables() {
|
||||
json["variableCollections"] = collections;
|
||||
json["variables"] = variables;
|
||||
}
|
||||
Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old }
|
||||
}
|
||||
|
||||
/// One Sketch layer as a REST node (with its children), or `None` when it is skipped. `parent`:
|
||||
/// the parent layer's transform to the page; `root`: the layer sits on the page itself.
|
||||
pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option<Value> {
|
||||
if depth > MAX_DEPTH {
|
||||
cx.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
|
||||
return None;
|
||||
}
|
||||
if is_archived_text(l) {
|
||||
cx.too_old = true;
|
||||
return None;
|
||||
}
|
||||
// A rectangle with angled or inside corners is a path of that outline.
|
||||
let outlined;
|
||||
let l = match super::path::cut_corners(l) {
|
||||
Some(p) => {
|
||||
outlined = p;
|
||||
&outlined
|
||||
}
|
||||
None => l,
|
||||
};
|
||||
let class = str_field(l, "_class")?;
|
||||
if class == "shapeGroup" {
|
||||
return count(cx).then(|| super::path::shape_group(cx, l, parent, depth, root)).flatten();
|
||||
}
|
||||
if class == "symbolMaster" {
|
||||
return count(cx).then(|| super::symbols::component(cx, l, parent, depth, root)).flatten();
|
||||
}
|
||||
if class == "symbolInstance" {
|
||||
return count(cx).then(|| super::symbols::instance(cx, l, parent, depth, root)).flatten();
|
||||
}
|
||||
let ty = match class {
|
||||
"artboard" | "frame" => "FRAME",
|
||||
// Sketch 2025 and newer: a group that behaves as a frame (1) or a graphic (2), or clips.
|
||||
"group" if is_frame(l) => "FRAME",
|
||||
"group" | "graphic" => "GROUP",
|
||||
// Shapes whose points were moved are paths.
|
||||
"rectangle" | "oval" if bool_field(l, "edited") == Some(true) => "VECTOR",
|
||||
"rectangle" | "bitmap" => "RECTANGLE",
|
||||
"oval" => "ELLIPSE",
|
||||
"slice" => "SLICE",
|
||||
"shapePath" | "star" | "polygon" | "triangle" => "VECTOR",
|
||||
"text" => "TEXT",
|
||||
"MSImmutableHotspotLayer" | "hotspot" => "FRAME",
|
||||
other => {
|
||||
cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other)));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !count(cx) {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = size(l);
|
||||
let mut world = parent * local(l, w, h);
|
||||
if ty == "TEXT" {
|
||||
world *= Affine::translate((0.0, super::text::baseline_shift(l)));
|
||||
}
|
||||
let mut node = base(l, ty, parent, world, w, h, root);
|
||||
if ty == "TEXT" {
|
||||
super::text::node(cx, l, &mut node);
|
||||
}
|
||||
if ty == "VECTOR" {
|
||||
let shape = super::path::shape(cx, l, w, h)?;
|
||||
super::path::put_shape(cx, shape, &mut node);
|
||||
}
|
||||
mask(l, &mut node);
|
||||
match class {
|
||||
"artboard" | "frame" => {
|
||||
let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) };
|
||||
node.insert("clipsContent".into(), json!(clips));
|
||||
let background = (bool_field(l, "hasBackgroundColor") == Some(true)).then(|| l.get("backgroundColor").and_then(color)).flatten();
|
||||
if class == "artboard" || background.is_some() {
|
||||
let c = background.unwrap_or_else(|| json!({"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}));
|
||||
node.insert("fills".into(), json!([{"type": "SOLID", "color": c}]));
|
||||
}
|
||||
}
|
||||
"group" if ty == "FRAME" => {
|
||||
// `clippingBehavior`: 0 the default (frames clip), 1 clip, 2 don't.
|
||||
node.insert("clipsContent".into(), json!(l.get("clippingBehavior").and_then(Value::as_i64) != Some(2)));
|
||||
}
|
||||
"rectangle" => corner_radii(l, &mut node),
|
||||
"bitmap" => match l.get("image").and_then(|i| str_field(i, "_ref")) {
|
||||
Some(r) => {
|
||||
let mut paint = json!({"type": "IMAGE", "scaleMode": "STRETCH", "imageRef": r, "imageTransform": [[1, 0, 0], [0, 1, 0]]});
|
||||
if let Some(f) = super::style::color_controls(cx, l.get("style")) {
|
||||
paint["filters"] = f;
|
||||
}
|
||||
node.insert("fills".into(), json!([paint]));
|
||||
}
|
||||
None => cx.warn("images stored inside the JSON were skipped"),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
styles(cx, l, w, h, &mut node);
|
||||
extras(cx, l, h, &mut node);
|
||||
style_corners(l, &mut node);
|
||||
if class == "MSImmutableHotspotLayer" || class == "hotspot" {
|
||||
node.insert("clipsContent".into(), json!(false));
|
||||
node.remove("fills");
|
||||
}
|
||||
// A group's or graphic's fill tints its layers (a frame's is its background).
|
||||
let tinted;
|
||||
let gb = l.get("groupBehavior").and_then(Value::as_i64);
|
||||
let tint = if class == "group" { super::style::tint_of(l.get("style"), gb != Some(1)) } else { None };
|
||||
let l = match tint.filter(|_| spend_on_tint(cx, l)) {
|
||||
Some(t) => {
|
||||
// A plain group's legacy fill is all tint; a frame keeps its background fills.
|
||||
if gb != Some(1) {
|
||||
node.remove("fills");
|
||||
}
|
||||
let mut c = l.clone();
|
||||
if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) {
|
||||
super::style::tint_layers(kids, &t, 0);
|
||||
}
|
||||
tinted = c;
|
||||
&tinted
|
||||
}
|
||||
_ => l,
|
||||
};
|
||||
if matches!(ty, "FRAME" | "GROUP") {
|
||||
let kids = children(cx, l, world, (w, h), depth, false);
|
||||
node.insert("children".into(), Value::Array(kids));
|
||||
super::stack::apply(l, &mut node);
|
||||
}
|
||||
Some(fade(cx, l, node, world, w, h))
|
||||
}
|
||||
|
||||
/// Layer `node` with a progressive opacity (Fade: the style's `contextSettings` with
|
||||
/// `isProgressive` and a gradient whose stops' opacity is the layer's) as a group in its place
|
||||
/// holding an alpha mask of that gradient over it; FigCraft has no progressive opacity. Other
|
||||
/// layers as they are.
|
||||
fn fade(cx: &mut Cx, l: &Value, node: JsonMap<String, Value>, world: Affine, w: f64, h: f64) -> Value {
|
||||
let ctx = l.get("style").and_then(|s| s.get("contextSettings"));
|
||||
let gradient = ctx.filter(|c| c.get("isProgressive").and_then(Value::as_bool) == Some(true)).and_then(|c| c.get("gradient"));
|
||||
// A mask stays as it is: its group would stop masking the layers above it.
|
||||
let Some(gradient) = gradient.filter(|_| node.get("isMask").and_then(Value::as_bool) != Some(true)) else { return Value::Object(node) };
|
||||
let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "gradient": gradient});
|
||||
let fills = super::style::fills(cx, &json!({"fills": [fill]}), w, h);
|
||||
if fills.is_empty() {
|
||||
return Value::Object(node);
|
||||
}
|
||||
let id = str_field(l, "do_objectID").unwrap_or("layer");
|
||||
let identity = json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
|
||||
let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h));
|
||||
let abs = json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()});
|
||||
// The group takes the layer's id (stacks, links and overrides find it), the layer a new one.
|
||||
let mut inner = node;
|
||||
let mut group = JsonMap::new();
|
||||
for k in ["relativeTransform", "absoluteBoundingBox", "constraints", "visible", "locked"] {
|
||||
if let Some(v) = inner.get(k) {
|
||||
group.insert(k.into(), v.clone());
|
||||
}
|
||||
}
|
||||
group.insert("type".into(), json!("GROUP"));
|
||||
group.insert("id".into(), json!(id));
|
||||
group.insert("name".into(), inner.get("name").cloned().unwrap_or_else(|| json!("Fade")));
|
||||
group.insert("size".into(), json!({"x": w, "y": h}));
|
||||
group.insert("absoluteBoundingBox".into(), abs.clone());
|
||||
inner.insert("id".into(), json!(format!("{id}-faded")));
|
||||
inner.insert("relativeTransform".into(), identity.clone());
|
||||
inner.insert("absoluteBoundingBox".into(), abs.clone());
|
||||
inner.insert("visible".into(), json!(true));
|
||||
let mask = json!({"type": "RECTANGLE", "id": format!("{id}-fade-mask"), "name": "Fade", "visible": true, "size": {"x": w, "y": h},
|
||||
"relativeTransform": identity, "absoluteBoundingBox": abs, "fills": fills, "isMask": true, "maskType": "ALPHA"});
|
||||
group.insert("children".into(), json!([mask, Value::Object(inner)]));
|
||||
Value::Object(group)
|
||||
}
|
||||
|
||||
/// A Sketch 2025+ group that is a frame: it behaves as a frame or a graphic, or clips.
|
||||
pub(crate) fn is_frame(l: &Value) -> bool {
|
||||
matches!(l.get("groupBehavior").and_then(Value::as_i64), Some(1 | 2)) || l.get("clippingBehavior").and_then(Value::as_i64) == Some(1)
|
||||
}
|
||||
|
||||
/// Corners kept in the style (Sketch 2025+: one radius for all, or one per corner from the top
|
||||
/// left, clockwise) on shapes and frames that have corners; smooth corners keep their smoothing.
|
||||
pub(crate) fn style_corners(l: &Value, node: &mut JsonMap<String, Value>) {
|
||||
let Some(c) = l.get("style").and_then(|s| s.get("corners")) else { return };
|
||||
if !matches!(node.get("type").and_then(Value::as_str), Some("RECTANGLE" | "FRAME" | "COMPONENT" | "INSTANCE")) {
|
||||
return;
|
||||
}
|
||||
let radii: Vec<f64> = c
|
||||
.get("radii")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(Value::as_f64)
|
||||
.filter(|r| r.is_finite())
|
||||
.map(|r| r.clamp(0.0, MAX_SIZE))
|
||||
.take(4)
|
||||
.collect();
|
||||
match radii.as_slice() {
|
||||
[] => return,
|
||||
[r] => {
|
||||
node.insert("cornerRadius".into(), json!(r));
|
||||
node.remove("rectangleCornerRadii");
|
||||
}
|
||||
more => {
|
||||
// Values repeat to fill the four corners.
|
||||
let all: Vec<f64> = (0..4).map(|i| more.get(i % more.len()).copied().unwrap_or(0.0)).collect();
|
||||
node.insert("rectangleCornerRadii".into(), json!(all));
|
||||
}
|
||||
}
|
||||
// Style 1 is smooth: its smoothing applies.
|
||||
if c.get("style").and_then(Value::as_i64) == Some(1)
|
||||
&& let Some(sm) = c.get("smoothing").and_then(Value::as_f64).filter(|v| v.is_finite())
|
||||
{
|
||||
node.insert("cornerSmoothing".into(), json!(sm.clamp(0.0, 1.0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer `l`'s style into `node`, and its links to the styles made from its shared style.
|
||||
pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap<String, Value>) {
|
||||
// An artboard shows its background colour only; Sketch doesn't draw a style it carries.
|
||||
if str_field(l, "_class") == Some("artboard") {
|
||||
return;
|
||||
}
|
||||
if let Some(st) = l.get("style") {
|
||||
super::style::apply(cx, st, w, h, node);
|
||||
}
|
||||
cx.shared.link(&cx.style_defs, l, node);
|
||||
}
|
||||
|
||||
/// Layer `l`'s prototype link, export settings, layout grids (frames) and notes.
|
||||
pub(crate) fn extras(cx: &mut Cx, l: &Value, h: f64, node: &mut JsonMap<String, Value>) {
|
||||
super::proto::interactions(l, node);
|
||||
super::proto::export_settings(cx, l, node);
|
||||
if matches!(node.get("type").and_then(Value::as_str), Some("FRAME" | "COMPONENT" | "INSTANCE")) {
|
||||
super::proto::layout_grids(l, h, node);
|
||||
}
|
||||
super::proto::notes(cx, l);
|
||||
}
|
||||
|
||||
/// A clipping mask's flag and kind (outline or alpha).
|
||||
pub(crate) fn mask(l: &Value, node: &mut JsonMap<String, Value>) {
|
||||
if bool_field(l, "hasClippingMask") == Some(true) {
|
||||
node.insert("isMask".into(), json!(true));
|
||||
node.insert("maskType".into(), json!(if l.get("clippingMaskMode").and_then(Value::as_i64) == Some(1) { "ALPHA" } else { "VECTOR" }));
|
||||
}
|
||||
}
|
||||
|
||||
/// Count one more layer; false (with a warning) once the file's budget is spent.
|
||||
fn count(cx: &mut Cx) -> bool {
|
||||
if cx.nodes >= cx.max_nodes {
|
||||
cx.warn(format!("the file has more than {} layers; the rest were skipped", cx.max_nodes));
|
||||
return false;
|
||||
}
|
||||
cx.nodes += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// The properties every REST node of layer `l` has: its type, id, name, visibility, lock, size,
|
||||
/// place (`world`, relative to `parent`) and constraints.
|
||||
pub(crate) fn base(l: &Value, ty: &str, parent: Affine, world: Affine, w: f64, h: f64, root: bool) -> JsonMap<String, Value> {
|
||||
let mut node = JsonMap::new();
|
||||
node.insert("type".into(), json!(ty));
|
||||
if let Some(id) = str_field(l, "do_objectID") {
|
||||
node.insert("id".into(), json!(id));
|
||||
}
|
||||
let class = str_field(l, "_class").unwrap_or("Layer");
|
||||
node.insert("name".into(), json!(str_field(l, "name").unwrap_or(class)));
|
||||
node.insert("visible".into(), json!(bool_field(l, "isVisible").unwrap_or(true)));
|
||||
node.insert("locked".into(), json!(bool_field(l, "isLocked").unwrap_or(false)));
|
||||
node.insert("size".into(), json!({"x": w, "y": h}));
|
||||
let [a, b, c, d, e, f] = (parent.inverse() * world).as_coeffs();
|
||||
node.insert("relativeTransform".into(), json!([[a, c, e], [b, d, f]]));
|
||||
// The page places top-level layers by their transform; deeper ones also say where they end up,
|
||||
// which tells the importer their transform is relative to their group.
|
||||
if !root {
|
||||
let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h));
|
||||
node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()}));
|
||||
}
|
||||
node.insert("constraints".into(), constraints(pin_mask(l)));
|
||||
node
|
||||
}
|
||||
|
||||
/// A layer's size from its `frame` (finite, not negative, at most [`MAX_SIZE`]).
|
||||
pub(crate) fn size(l: &Value) -> (f64, f64) {
|
||||
let f = l.get("frame");
|
||||
let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_SIZE);
|
||||
(n("width"), n("height"))
|
||||
}
|
||||
|
||||
/// A layer's transform within its parent: its frame's position, then its rotation (degrees,
|
||||
/// counter-clockwise on screen) and flips about its centre. Sketch flips on screen, after rotating
|
||||
/// (a quarter-turned layer flipped left to right mirrors its own up and down).
|
||||
pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine {
|
||||
let f = l.get("frame");
|
||||
let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-MAX_COORD, MAX_COORD);
|
||||
let rot = l.get("rotation").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) % 360.0;
|
||||
let sx = if bool_field(l, "isFlippedHorizontal") == Some(true) { -1.0 } else { 1.0 };
|
||||
let sy = if bool_field(l, "isFlippedVertical") == Some(true) { -1.0 } else { 1.0 };
|
||||
Affine::translate((n("x") + w / 2.0, n("y") + h / 2.0))
|
||||
* Affine::scale_non_uniform(sx, sy)
|
||||
* Affine::rotate(-rot.to_radians())
|
||||
* Affine::translate((-w / 2.0, -h / 2.0))
|
||||
}
|
||||
|
||||
/// A layer's pins as a `resizingConstraint` mask. Sketch 2025 and newer keep them in
|
||||
/// `horizontalPins`/`verticalPins` (bits: 1 the start, 4 the end) and `horizontalSizing`/
|
||||
/// `verticalSizing` (fixed and fit keep the size; fill and relative don't), with
|
||||
/// `resizingConstraint` left at 63.
|
||||
pub(crate) fn pin_mask(l: &Value) -> i64 {
|
||||
if l.get("horizontalPins").is_none() && l.get("horizontalSizing").is_none() {
|
||||
return l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63);
|
||||
}
|
||||
let int = |k: &str| l.get(k).and_then(Value::as_i64).unwrap_or(0);
|
||||
let fixed = |k: &str| matches!(int(k), 0 | 1);
|
||||
let mut mask = 63;
|
||||
// A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32.
|
||||
for (on, bit) in [
|
||||
(int("horizontalPins") & 1 != 0, 4),
|
||||
(int("horizontalPins") & 4 != 0, 1),
|
||||
(fixed("horizontalSizing"), 2),
|
||||
(int("verticalPins") & 1 != 0, 32),
|
||||
(int("verticalPins") & 4 != 0, 8),
|
||||
(fixed("verticalSizing"), 16),
|
||||
] {
|
||||
if on {
|
||||
mask &= !bit;
|
||||
}
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
/// REST constraints from Sketch's `resizingConstraint`: six bits, a cleared bit pins that side or
|
||||
/// fixes that size (right 1, width 2, left 4, bottom 8, height 16, top 32). A layer pinned on
|
||||
/// neither side keeps its place in proportion (scale) unless its size is fixed (centre).
|
||||
fn constraints(mask: i64) -> Value {
|
||||
let pinned = |bit: i64| mask & bit == 0;
|
||||
let axis = |low: i64, size: i64, high: i64, low_name: &str, high_name: &str, both: &str| match (pinned(low), pinned(high)) {
|
||||
// Both sides and the size pinned can't all hold: Sketch scales such a layer.
|
||||
(true, true) if pinned(size) => "SCALE".into(),
|
||||
(true, true) => both.to_string(),
|
||||
(true, false) => low_name.to_string(),
|
||||
(false, true) => high_name.to_string(),
|
||||
(false, false) if pinned(size) => "CENTER".into(),
|
||||
(false, false) => "SCALE".into(),
|
||||
};
|
||||
json!({
|
||||
"horizontal": axis(4, 2, 1, "LEFT", "RIGHT", "LEFT_RIGHT"),
|
||||
"vertical": axis(32, 16, 8, "TOP", "BOTTOM", "TOP_BOTTOM"),
|
||||
})
|
||||
}
|
||||
|
||||
/// A rectangle's corner radii: per corner from its points (top left, top right, bottom right,
|
||||
/// bottom left), else its `fixedRadius`.
|
||||
fn corner_radii(l: &Value, node: &mut JsonMap<String, Value>) {
|
||||
let radius = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|r| r.is_finite()).map_or(0.0, |r| r.clamp(0.0, MAX_SIZE));
|
||||
let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
let each: Vec<f64> = pts.iter().map(|p| radius(p.get("cornerRadius"))).collect();
|
||||
if each.len() == 4 && each.iter().any(|r| *r > 0.0) {
|
||||
node.insert("rectangleCornerRadii".into(), json!(each));
|
||||
} else if radius(l.get("fixedRadius")) > 0.0 {
|
||||
node.insert("cornerRadius".into(), json!(radius(l.get("fixedRadius"))));
|
||||
}
|
||||
}
|
||||
|
||||
/// A Sketch colour (`red`, `green`, `blue`, `alpha` in 0–1) as a REST colour.
|
||||
pub(crate) fn color(c: &Value) -> Option<Value> {
|
||||
let n = |k: &str| c.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0));
|
||||
Some(json!({"r": n("red")?, "g": n("green")?, "b": n("blue")?, "a": n("alpha").unwrap_or(1.0)}))
|
||||
}
|
||||
|
||||
pub(crate) fn bool_field(v: &Value, key: &str) -> Option<bool> {
|
||||
v.get(key).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
/// At most 64 characters of a name read from the file, for warnings.
|
||||
fn clip(s: &str) -> String {
|
||||
s.chars().take(64).collect()
|
||||
}
|
||||
|
||||
/// The child layers of `l` (placed at `world`, `size`) as REST nodes. A clipping mask clips the
|
||||
/// layers after it up to one that breaks the chain (or the next mask); when that happens before
|
||||
/// the end, the mask and what it clips are wrapped in a group of their own, since a FigCraft mask
|
||||
/// clips everything above it.
|
||||
pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), depth: usize, root: bool) -> Vec<Value> {
|
||||
let built: Vec<(bool, Value)> = layers(l)
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let breaks = bool_field(c, "shouldBreakMaskChain") == Some(true) || bool_field(c, "hasClippingMask") == Some(true);
|
||||
layer(cx, c, world, depth + 1, root).map(|n| (breaks, n))
|
||||
})
|
||||
.flat_map(|(breaks, n)| match drawn_mask(&n) {
|
||||
// Sketch also draws a mask with its own style, under what it clips; a FigCraft mask
|
||||
// only clips, so a plain copy of it goes underneath.
|
||||
// The copy ends an earlier mask's chain as the mask itself would.
|
||||
Some(copy) => vec![(breaks, copy), (breaks, n)],
|
||||
None => vec![(breaks, n)],
|
||||
})
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some((_, n)) = built.get(i) {
|
||||
let end = if n.get("isMask").and_then(Value::as_bool) == Some(true) {
|
||||
built.iter().skip(i + 1).position(|(b, _)| *b).map(|k| i + 1 + k)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(end) = end else {
|
||||
out.push(n.clone());
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let masked: Vec<Value> = built.get(i..end).unwrap_or_default().iter().map(|(_, n)| n.clone()).collect();
|
||||
let mut g = JsonMap::new();
|
||||
g.insert("type".into(), json!("GROUP"));
|
||||
g.insert("name".into(), json!("Mask group"));
|
||||
g.insert("relativeTransform".into(), json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]));
|
||||
g.insert("size".into(), json!({"x": size.0, "y": size.1}));
|
||||
if !root {
|
||||
let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, size.0, size.1));
|
||||
g.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()}));
|
||||
}
|
||||
g.insert("children".into(), Value::Array(masked));
|
||||
out.push(Value::Object(g));
|
||||
i = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A plain copy of outline mask `n` when its style shows something (fills, borders or effects).
|
||||
/// An alpha mask only masks.
|
||||
fn drawn_mask(n: &Value) -> Option<Value> {
|
||||
if n.get("isMask").and_then(Value::as_bool) != Some(true) || n.get("maskType").and_then(Value::as_str) == Some("ALPHA") {
|
||||
return None;
|
||||
}
|
||||
let shows =
|
||||
|key: &str| n.get(key).and_then(Value::as_array).is_some_and(|l| l.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false)));
|
||||
if !(shows("fills") || shows("strokes") || shows("effects")) {
|
||||
return None;
|
||||
}
|
||||
let mut copy = n.clone();
|
||||
let m = copy.as_object_mut()?;
|
||||
m.remove("isMask");
|
||||
m.remove("maskType");
|
||||
m.remove("id");
|
||||
let name = m.get("name").and_then(Value::as_str).unwrap_or("Mask").to_string();
|
||||
m.insert("name".into(), json!(format!("{name} (drawn)")));
|
||||
Some(copy)
|
||||
}
|
||||
|
||||
/// A text layer whose string is a binary property list (Sketch 43–47).
|
||||
fn is_archived_text(l: &Value) -> bool {
|
||||
l.get("attributedString").is_some_and(|a| a.get("archivedAttributedString").is_some())
|
||||
}
|
||||
|
||||
/// The child layers of a page or layer.
|
||||
pub(crate) fn layers(l: &Value) -> &[Value] {
|
||||
l.get("layers").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn str_field<'v>(v: &'v Value, key: &str) -> Option<&'v str> {
|
||||
v.get(key).and_then(Value::as_str)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Sketch layers → document layers: kinds, positions, transforms, constraints, visibility.
|
||||
|
||||
use figcraft_doc::color::PaintKind;
|
||||
use figcraft_doc::{Document, HConstraint, NodeKind, VConstraint};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fixture::*;
|
||||
use super::import;
|
||||
|
||||
/// Import one page holding `layers`, with extra archive `files`.
|
||||
fn open_with(layers: Vec<Value>, files: &[(&str, &[u8])]) -> (Document, Vec<String>) {
|
||||
let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", layers)], files);
|
||||
import(&z, "f").unwrap()
|
||||
}
|
||||
|
||||
fn open(layers: Vec<Value>) -> (Document, Vec<String>) {
|
||||
open_with(layers, &[])
|
||||
}
|
||||
|
||||
fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node {
|
||||
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}"))
|
||||
}
|
||||
|
||||
fn solid(doc: &Document, name: &str) -> Option<[f32; 4]> {
|
||||
node(doc, name).fills.iter().find_map(|p| match &p.kind {
|
||||
PaintKind::Solid { color } => Some([color.r, color.g, color.b, p.opacity]),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboard_is_frame_with_white_fill_unless_background() {
|
||||
let plain = artboard("A", "Plain", 0.0, 0.0, 100.0, 50.0, vec![]);
|
||||
let red = with(artboard("B", "Red", 200.0, 0.0, 100.0, 50.0, vec![]), "hasBackgroundColor", json!(true));
|
||||
let red = with(red, "backgroundColor", json!({"_class": "color", "red": 1, "green": 0, "blue": 0, "alpha": 1}));
|
||||
let (doc, _) = open(vec![plain, red]);
|
||||
assert!(matches!(node(&doc, "Plain").kind, NodeKind::Frame));
|
||||
assert!(node(&doc, "Plain").clip);
|
||||
assert_eq!(solid(&doc, "Plain"), Some([1.0, 1.0, 1.0, 1.0]));
|
||||
assert_eq!(solid(&doc, "Red"), Some([1.0, 0.0, 0.0, 1.0]));
|
||||
let b = doc.world_bounds(node(&doc, "Red").id).unwrap();
|
||||
assert_eq!((b.x0, b.y0, b.width(), b.height()), (200.0, 0.0, 100.0, 50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_children_positions_accumulate() {
|
||||
let r = rect("R", 5.0, 5.0, 10.0, 10.0);
|
||||
let g = group("G", 10.0, 20.0, 15.0, 15.0, vec![r]);
|
||||
let (doc, _) = open(vec![artboard("A", "Board", 100.0, 100.0, 300.0, 300.0, vec![g])]);
|
||||
assert!(matches!(node(&doc, "G").kind, NodeKind::Group));
|
||||
let b = doc.world_bounds(node(&doc, "R").id).unwrap();
|
||||
assert!((b.x0 - 115.0).abs() < 1e-6 && (b.y0 - 125.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_and_flip() {
|
||||
let rotated = with(rect("Turned", 0.0, 0.0, 40.0, 20.0), "rotation", json!(90));
|
||||
let flipped = with(rect("Flipped", 100.0, 0.0, 40.0, 20.0), "isFlippedHorizontal", json!(true));
|
||||
let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![rotated, flipped])]);
|
||||
let b = doc.world_bounds(node(&doc, "Turned").id).unwrap();
|
||||
assert!((b.width() - 20.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}");
|
||||
assert!((b.center().x - 20.0).abs() < 1e-6 && (b.center().y - 10.0).abs() < 1e-6, "{b:?}");
|
||||
// Counter-clockwise on screen: the box's x axis points up.
|
||||
let t = doc.world_transform(node(&doc, "Turned").id).as_coeffs();
|
||||
assert!(t[1] < -0.99, "{t:?}");
|
||||
let t = doc.world_transform(node(&doc, "Flipped").id).as_coeffs();
|
||||
assert!(t[0] < -0.99, "{t:?}");
|
||||
let b = doc.world_bounds(node(&doc, "Flipped").id).unwrap();
|
||||
assert!((b.x0 - 100.0).abs() < 1e-6 && (b.width() - 40.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_constraint_bits() {
|
||||
use HConstraint as H;
|
||||
use VConstraint as V;
|
||||
// A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32.
|
||||
let cases = [
|
||||
(63, H::Scale, V::Scale),
|
||||
(63 - 4 - 32, H::Left, V::Top),
|
||||
(63 - 1 - 8, H::Right, V::Bottom),
|
||||
(63 - 1 - 4, H::LeftRight, V::Scale),
|
||||
(63 - 8 - 32, H::Scale, V::TopBottom),
|
||||
(63 - 2 - 16, H::Center, V::Center),
|
||||
(63 - 1 - 2, H::Right, V::Scale),
|
||||
// Everything pinned is over-constrained: Sketch scales it.
|
||||
(0, H::Scale, V::Scale),
|
||||
];
|
||||
let layers: Vec<Value> = cases
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (mask, _, _))| with(rect(&format!("R{i}"), 0.0, 0.0, 10.0, 10.0), "resizingConstraint", json!(mask)))
|
||||
.collect();
|
||||
let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, layers)]);
|
||||
for (i, (mask, h, v)) in cases.iter().enumerate() {
|
||||
let c = node(&doc, &format!("R{i}")).constraints;
|
||||
assert_eq!((c.horizontal, c.vertical), (*h, *v), "mask {mask}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_locked_named() {
|
||||
let r = with(with(rect("Hidden", 0.0, 0.0, 10.0, 10.0), "isVisible", json!(false)), "isLocked", json!(true));
|
||||
let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![r])]);
|
||||
let n = node(&doc, "Hidden");
|
||||
assert!(!n.visible && n.locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_numbers_are_sane() {
|
||||
let mut nan = rect("Junk", 0.0, 0.0, 10.0, 10.0);
|
||||
nan["frame"]["width"] = json!("NaN");
|
||||
nan["frame"]["x"] = json!(1e300);
|
||||
let mut neg = rect("Negative", 0.0, 0.0, 10.0, 10.0);
|
||||
neg["frame"]["height"] = json!(-5);
|
||||
neg["rotation"] = json!("x");
|
||||
let mut none = rect("Frameless", 0.0, 0.0, 10.0, 10.0);
|
||||
none.as_object_mut().unwrap().remove("frame");
|
||||
let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![nan, neg, none])]);
|
||||
for n in doc.nodes() {
|
||||
assert!(n.width.is_finite() && n.height.is_finite() && n.width >= 0.0 && n.height >= 0.0, "{}", n.name);
|
||||
assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{}", n.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_corner_radii() {
|
||||
let mut each = rect("Each", 0.0, 0.0, 40.0, 40.0);
|
||||
for (i, r) in [1, 2, 3, 4].iter().enumerate() {
|
||||
each["points"][i]["cornerRadius"] = json!(r);
|
||||
}
|
||||
let fixed = with(rect("Fixed", 50.0, 0.0, 40.0, 40.0), "fixedRadius", json!(8));
|
||||
let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![each, fixed])]);
|
||||
assert!(matches!(node(&doc, "Each").kind, NodeKind::Rectangle));
|
||||
assert_eq!(node(&doc, "Each").corners.radii, [1.0, 2.0, 3.0, 4.0]);
|
||||
assert_eq!(node(&doc, "Fixed").corners.radii, [8.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oval_slice_and_bitmap() {
|
||||
let mut oval = rect("Round", 0.0, 0.0, 30.0, 20.0);
|
||||
oval["_class"] = json!("oval");
|
||||
let mut slice = rect("Cut", 0.0, 0.0, 30.0, 20.0);
|
||||
slice["_class"] = json!("slice");
|
||||
let mut bitmap = rect("Photo", 0.0, 0.0, 30.0, 20.0);
|
||||
bitmap["_class"] = json!("bitmap");
|
||||
bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": "images/abc123.png"});
|
||||
let png = {
|
||||
let mut out = Vec::new();
|
||||
image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
};
|
||||
let (doc, _) = open_with(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![oval, slice, bitmap])], &[("images/abc123.png", &png)]);
|
||||
assert!(matches!(node(&doc, "Round").kind, NodeKind::Ellipse { .. }));
|
||||
assert!(matches!(node(&doc, "Cut").kind, NodeKind::Slice));
|
||||
let photo = node(&doc, "Photo");
|
||||
assert!(matches!(photo.kind, NodeKind::Rectangle));
|
||||
assert!(photo.fills.iter().any(|p| matches!(&p.kind, PaintKind::Image(_))));
|
||||
assert_eq!(doc.images.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_class_is_a_warning() {
|
||||
let mut odd = rect("Odd", 0.0, 0.0, 10.0, 10.0);
|
||||
odd["_class"] = json!("MSSomethingNew");
|
||||
let (doc, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![odd])]);
|
||||
assert!(doc.nodes().all(|n| n.name != "Odd"));
|
||||
assert!(warnings.iter().any(|w| w.contains("MSSomethingNew")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_2025_frames_and_graphics() {
|
||||
let mut frame = artboard("F", "Frame", 0.0, 0.0, 100.0, 100.0, vec![rect("Inside", 0.0, 0.0, 10.0, 10.0)]);
|
||||
frame["_class"] = json!("frame");
|
||||
frame["clipsContent"] = json!(false);
|
||||
let mut graphic = group("Graphic", 10.0, 10.0, 10.0, 10.0, vec![rect("Leaf", 0.0, 0.0, 10.0, 10.0)]);
|
||||
graphic["_class"] = json!("graphic");
|
||||
frame["layers"].as_array_mut().unwrap().push(graphic);
|
||||
let (doc, _) = open(vec![frame]);
|
||||
assert!(matches!(node(&doc, "Frame").kind, NodeKind::Frame));
|
||||
assert!(!node(&doc, "Frame").clip);
|
||||
assert!(matches!(node(&doc, "Graphic").kind, NodeKind::Group));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_distinct_warnings_are_capped() {
|
||||
let layers: Vec<Value> = (0..5000)
|
||||
.map(|i| {
|
||||
let mut l = rect(&format!("R{i}"), 0.0, 0.0, 1.0, 1.0);
|
||||
l["_class"] = json!(format!("MSOdd{i}"));
|
||||
l
|
||||
})
|
||||
.collect();
|
||||
let (_, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, layers)]);
|
||||
assert!(warnings.len() <= 250, "{}", warnings.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bomb_pages_are_skipped() {
|
||||
// 64 MB of `[0,0,…]` packs into a few hundred kilobytes.
|
||||
let bomb = format!("{{\"_class\":\"page\",\"do_objectID\":\"B\",\"name\":\"Bomb\",\"layers\":[],\"junk\":[{}0]}}", "0,".repeat(32 * 1024 * 1024));
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
z.start_file("document.json", o).unwrap();
|
||||
z.write_all(document(&["B", "P"]).to_string().as_bytes()).unwrap();
|
||||
z.start_file("meta.json", o).unwrap();
|
||||
z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": "50.2", "version": 103}).to_string().as_bytes()).unwrap();
|
||||
z.start_file("pages/B.json", o).unwrap();
|
||||
z.write_all(bomb.as_bytes()).unwrap();
|
||||
z.start_file("pages/P.json", o).unwrap();
|
||||
z.write_all(page("P", "Fine", vec![]).to_string().as_bytes()).unwrap();
|
||||
z.finish().unwrap();
|
||||
}
|
||||
let (doc, warnings) = import(&out, "f").unwrap();
|
||||
let names: Vec<String> = doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect();
|
||||
assert!(!names.contains(&"Bomb".to_string()) && names.contains(&"Fine".to_string()), "{names:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("too large")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_document_json_still_opens_the_pages() {
|
||||
let mut deep = json!({"_class": "symbolMaster"});
|
||||
for _ in 0..150 {
|
||||
deep = json!({"_class": "group", "layers": [deep]});
|
||||
}
|
||||
let mut d = document(&["P"]);
|
||||
d["foreignSymbols"] = json!([{"symbolMaster": deep}]);
|
||||
let z = sketch_zip("50.2", 103, d, &[page("P", "Kept", vec![])], &[]);
|
||||
let (doc, warnings) = import(&z, "f").unwrap();
|
||||
assert!(doc.pages.iter().any(|p| doc.node(*p).unwrap().name == "Kept"));
|
||||
assert!(!warnings.is_empty());
|
||||
}
|
||||
|
||||
/// Sketch flips a rotated layer on screen, after rotating it.
|
||||
#[test]
|
||||
fn flip_applies_after_rotation() {
|
||||
let r = with(with(rect("Turned", 0.0, 0.0, 40.0, 20.0), "rotation", json!(90)), "isFlippedHorizontal", json!(true));
|
||||
let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![r])]);
|
||||
let t = doc.world_transform(node(&doc, "Turned").id).as_coeffs();
|
||||
// Rotated a quarter turn counter-clockwise (x → up, y → right), then mirrored left to right.
|
||||
assert!(t[0].abs() < 1e-9 && (t[1] + 1.0).abs() < 1e-9 && (t[2] + 1.0).abs() < 1e-9 && t[3].abs() < 1e-9, "{t:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_shapes_keep_their_points() {
|
||||
let mut drop = rect("Drop", 0.0, 0.0, 20.0, 30.0);
|
||||
drop["_class"] = json!("oval");
|
||||
drop["edited"] = json!(true);
|
||||
drop["points"] = json!([point(0.5, 0.0), point(1.0, 0.7), point(0.5, 1.0), point(0.0, 0.7)]);
|
||||
let mut box_ = rect("Slanted", 50.0, 0.0, 20.0, 20.0);
|
||||
box_["edited"] = json!(true);
|
||||
box_["points"][1]["point"] = json!("{1, 0.3}");
|
||||
let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![drop, box_])]);
|
||||
let NodeKind::Vector { network } = &node(&doc, "Drop").kind else { panic!("not a vector") };
|
||||
assert_eq!((network.vertices[0].x, network.vertices[0].y), (10.0, 0.0));
|
||||
assert!(matches!(node(&doc, "Slanted").kind, NodeKind::Vector { .. }));
|
||||
}
|
||||
|
||||
/// Sketch draws an artboard's background colour only; a style it may carry (borders, shadows) isn't
|
||||
/// drawn. Sketch 2025 frames do draw theirs.
|
||||
#[test]
|
||||
fn artboards_ignore_their_style() {
|
||||
let border = json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": {"_class": "color", "red": 0, "green": 0, "blue": 0, "alpha": 1}, "position": 2, "thickness": 1}]);
|
||||
let mut a = artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![]);
|
||||
a["style"]["borders"] = border.clone();
|
||||
let mut f = artboard("F", "Frame", 200.0, 0.0, 100.0, 100.0, vec![]);
|
||||
f["_class"] = json!("frame");
|
||||
f["style"]["borders"] = border;
|
||||
let (doc, _) = open(vec![a, f]);
|
||||
assert!(node(&doc, "Board").strokes.is_empty());
|
||||
assert_eq!(node(&doc, "Frame").strokes.len(), 1);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The picture a saved file carries of itself: `thumbnail.png` inside a `.figcraft` or `.fig` zip
|
||||
//! (the home screen's recent files show it), and the colour of the page it sits on. Image files are
|
||||
//! The picture a saved file carries of itself: `thumbnail.png` inside a `.figcraft` or `.fig` zip,
|
||||
//! `previews/preview.png` inside a `.sketch` zip (the home screen's recent files show it), and the colour of the page it sits on. Image files are
|
||||
//! their own picture. Everything is read within caps: a missing, damaged or oversized picture gives
|
||||
//! `None`, never an error or a panic.
|
||||
|
||||
@@ -33,6 +33,12 @@ pub fn thumbnail_png<R: Read + Seek>(archive: R) -> Option<Vec<u8>> {
|
||||
crate::read_member(&mut z, "thumbnail.png", MAX_THUMBNAIL_BYTES).ok()
|
||||
}
|
||||
|
||||
/// The bytes of `previews/preview.png` in a `.sketch` zip; `None` as [`thumbnail_png`] gives it.
|
||||
pub fn sketch_preview_png<R: Read + Seek>(archive: R) -> Option<Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(archive).ok()?;
|
||||
crate::read_member(&mut z, "previews/preview.png", MAX_THUMBNAIL_BYTES).ok()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -59,6 +65,8 @@ pub enum FileKind {
|
||||
FigCraft,
|
||||
/// `.fig`
|
||||
Fig,
|
||||
/// `.sketch`
|
||||
Sketch,
|
||||
/// `.svg`
|
||||
Svg,
|
||||
/// A raster image (`.png`, `.jpg`, `.webp`, …).
|
||||
@@ -72,6 +80,7 @@ impl FileKind {
|
||||
match ext.as_str() {
|
||||
"figcraft" => FileKind::FigCraft,
|
||||
"fig" => FileKind::Fig,
|
||||
"sketch" => FileKind::Sketch,
|
||||
"svg" => FileKind::Svg,
|
||||
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tif" | "tiff" => FileKind::Image,
|
||||
_ => FileKind::Other,
|
||||
@@ -80,7 +89,7 @@ impl FileKind {
|
||||
}
|
||||
|
||||
/// The picture of the file at `path`, at most `fit` pixels on its longer side: the thumbnail a
|
||||
/// `.figcraft` or `.fig` file carries, or an image file itself (up to [`MAX_IMAGE_FILE_BYTES`]).
|
||||
/// `.figcraft`, `.fig` or `.sketch` file carries, or an image file itself (up to [`MAX_IMAGE_FILE_BYTES`]).
|
||||
/// `None` for anything else, and for a file that is missing, unreadable or damaged.
|
||||
pub fn file_thumbnail(path: &Path, fit: u32) -> Option<Thumbnail> {
|
||||
match FileKind::of(path) {
|
||||
@@ -89,6 +98,11 @@ pub fn file_thumbnail(path: &Path, fit: u32) -> Option<Thumbnail> {
|
||||
let png = thumbnail_png(std::io::BufReader::new(file))?;
|
||||
decode_thumbnail(&png, fit)
|
||||
}
|
||||
FileKind::Sketch => {
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let png = sketch_preview_png(std::io::BufReader::new(file))?;
|
||||
decode_thumbnail(&png, fit)
|
||||
}
|
||||
FileKind::Image => {
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
@@ -109,7 +123,7 @@ pub fn file_thumbnail(path: &Path, fit: u32) -> Option<Thumbnail> {
|
||||
pub struct Preview {
|
||||
pub page: Option<[f32; 4]>,
|
||||
pub picture: Option<Thumbnail>,
|
||||
/// A design file or a board (`.fig` files and images: design).
|
||||
/// A design file or a board (`.fig` and `.sketch` files and images: design).
|
||||
pub kind: figcraft_doc::FileKind,
|
||||
}
|
||||
|
||||
@@ -121,7 +135,8 @@ pub fn file_preview(path: &Path, fit: u32) -> Preview {
|
||||
let Ok(file) = std::fs::File::open(path) else { return Preview::default() };
|
||||
archive_preview(std::io::BufReader::new(file), fit)
|
||||
}
|
||||
FileKind::Image => Preview { picture: file_thumbnail(path, fit), ..Preview::default() },
|
||||
// A Sketch file's page colour isn't read: the preview is its picture alone, as for an image.
|
||||
FileKind::Sketch | FileKind::Image => Preview { picture: file_thumbnail(path, fit), ..Preview::default() },
|
||||
FileKind::Svg | FileKind::Other => Preview::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +117,14 @@ fn files_on_disk_by_kind() {
|
||||
let folder = d.join("folder.figcraft");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
assert_eq!(file_thumbnail(&folder, 640), None);
|
||||
let kinds: Vec<FileKind> =
|
||||
["a.figcraft", "a.FIG", "a.svg", "a.jpeg", "a.webp", "a.txt", "noext"].iter().map(|p| FileKind::of(std::path::Path::new(p))).collect();
|
||||
assert_eq!(kinds, [FileKind::FigCraft, FileKind::Fig, FileKind::Svg, FileKind::Image, FileKind::Image, FileKind::Other, FileKind::Other]);
|
||||
let kinds: Vec<FileKind> = ["a.figcraft", "a.FIG", "a.Sketch", "a.svg", "a.jpeg", "a.webp", "a.txt", "noext"]
|
||||
.iter()
|
||||
.map(|p| FileKind::of(std::path::Path::new(p)))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
[FileKind::FigCraft, FileKind::Fig, FileKind::Sketch, FileKind::Svg, FileKind::Image, FileKind::Image, FileKind::Other, FileKind::Other]
|
||||
);
|
||||
}
|
||||
|
||||
/// A document whose first page is `rgb`.
|
||||
@@ -181,3 +186,21 @@ fn previews_carry_the_first_pages_colour() {
|
||||
let p = file_preview(&image, 640);
|
||||
assert!(p.page.is_none() && p.picture.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_files_show_their_preview_png() {
|
||||
use crate::thumbnail::{Preview, file_preview};
|
||||
let d = dir("sketch");
|
||||
let sketch = d.join("screens.sketch");
|
||||
std::fs::write(&sketch, zip(&[("document.json", b"{}"), ("previews/preview.png", &png(8, 4))])).unwrap();
|
||||
assert_eq!(file_thumbnail(&sketch, 640).map(|t| (t.width, t.height)), Some((8, 4)));
|
||||
let p = file_preview(&sketch, 640);
|
||||
assert_eq!((p.page, p.picture.map(|t| (t.width, t.height)), p.kind), (None, Some((8, 4)), figcraft_doc::FileKind::Design));
|
||||
// Only `previews/preview.png` counts; a damaged file has no picture.
|
||||
let other = d.join("other.sketch");
|
||||
std::fs::write(&other, zip(&[("thumbnail.png", &png(8, 4))])).unwrap();
|
||||
assert_eq!(file_thumbnail(&other, 640), None);
|
||||
let junk = d.join("junk.sketch");
|
||||
std::fs::write(&junk, [0xFFu8; 64]).unwrap();
|
||||
assert_eq!(file_preview(&junk, 640), Preview::default());
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"open_file",
|
||||
"Open file",
|
||||
"Open a .figcraft or .json document (FigCraft or REST API file JSON), a .fig file (best effort, with `warnings` for what wasn't imported), an .svg (becomes vectors) or an image (becomes an image layer) as the active document. Returns the new document's summary.",
|
||||
"Open a .figcraft or .json document (FigCraft or REST API file JSON), a .fig or .sketch file (best effort, with `warnings` for what wasn't imported; .sketch from Sketch 48 or newer), an .svg (becomes vectors) or an image (becomes an image layer) as the active document. Returns the new document's summary.",
|
||||
obj(
|
||||
json!({
|
||||
"path": string("File path"),
|
||||
|
||||
+67
-12
@@ -199,28 +199,43 @@ pub fn faces(paths: &[(BezPath, bool)]) -> Result<Vec<ArrangementFace>, PathOpsE
|
||||
if paths.len() > MAX_ARRANGEMENT_INPUTS || paths.iter().any(|(p, _)| !finite(p)) {
|
||||
return Err(PathOpsError::BadInput);
|
||||
}
|
||||
let closed_paths: Vec<BezPath> = paths.iter().map(|(p, _)| closed(p)).collect();
|
||||
// Self-intersecting or self-overlapping inputs (a centred stroke outlined around a sharp
|
||||
// corner folds over itself and touches the shape's fill) leave the winding on each side of a
|
||||
// segment ambiguous where they touch, which made the sweep report overlapping faces. Reducing
|
||||
// every input to the simple contours of the area it fills first keeps the faces disjoint.
|
||||
let closed_paths: Vec<BezPath> = paths.iter().map(|(p, even_odd)| plain(&closed(p), *even_odd)).collect();
|
||||
if closed_paths.iter().all(|p| p.elements().is_empty()) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let bbox = closed_paths.iter().filter(|p| !p.elements().is_empty()).map(Shape::bounding_box).reduce(|a, b| a.union(b));
|
||||
let m = bbox.map(|b| b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs())).unwrap_or(0.0);
|
||||
let eps = (m * f64::EPSILON * 64.0).max(1e-6);
|
||||
let even_odd: Vec<bool> = paths.iter().map(|(_, e)| *e).collect();
|
||||
sweep(|| arrange(&closed_paths, &even_odd, eps))
|
||||
sweep(|| arrange(&closed_paths, eps))
|
||||
}
|
||||
|
||||
/// [`faces`] of the closed `paths`, swept with tolerance `eps`.
|
||||
fn arrange(closed_paths: &[BezPath], even_odd: &[bool], eps: f64) -> Result<Vec<ArrangementFace>, PathOpsError> {
|
||||
/// The area `path` fills under `even_odd` as simple contours (self-intersections and overlapping
|
||||
/// subpaths resolved), which both fill rules then cover the same. A path the sweep can't handle
|
||||
/// comes back unchanged, so [`faces`] still tries it as it is.
|
||||
fn plain(path: &BezPath, even_odd: bool) -> BezPath {
|
||||
if path.elements().is_empty() {
|
||||
return BezPath::new();
|
||||
}
|
||||
let rule = if even_odd { linesweeper::FillRule::EvenOdd } else { linesweeper::FillRule::NonZero };
|
||||
let swept = sweep(|| linesweeper::binary_op(path, &BezPath::new(), rule, linesweeper::BinaryOp::Union).map_err(|_| PathOpsError::BadInput));
|
||||
let Ok(contours) = swept else { return path.clone() };
|
||||
let mut out = BezPath::new();
|
||||
for c in contours.contours() {
|
||||
out.extend(c.path.elements().iter().copied());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`faces`] of the closed, simple `paths`, swept with tolerance `eps`.
|
||||
fn arrange(closed_paths: &[BezPath], eps: f64) -> Result<Vec<ArrangementFace>, PathOpsError> {
|
||||
let topo = linesweeper::topology::Topology::<Windings>::from_paths(closed_paths.iter().enumerate().map(|(i, p)| (p, i)), eps)
|
||||
.map_err(|_| PathOpsError::BadInput)?;
|
||||
let cover_of = |w: &Windings| -> Vec<usize> {
|
||||
w.0.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, n)| if even_odd.get(*i).copied().unwrap_or(false) { *n % 2 != 0 } else { **n != 0 })
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
};
|
||||
// Every path was reduced to simple contours, so either rule covers the same area: non-zero.
|
||||
let cover_of = |w: &Windings| -> Vec<usize> { w.0.iter().enumerate().filter(|(_, n)| **n != 0).map(|(i, _)| i).collect() };
|
||||
// Every coverage that occurs on either side of an output segment.
|
||||
let mut covers: std::collections::BTreeSet<Vec<usize>> = std::collections::BTreeSet::new();
|
||||
for seg in topo.segment_indices() {
|
||||
@@ -575,6 +590,46 @@ mod tests {
|
||||
p.area().abs()
|
||||
}
|
||||
|
||||
/// A five-pointed star with sharp tips, and its centred stroke, whose outline folds over
|
||||
/// itself at every tip (a self-intersecting non-zero input that touches the star's fill).
|
||||
fn star_and_stroke() -> (BezPath, BezPath) {
|
||||
let mut star = BezPath::new();
|
||||
for i in 0..10 {
|
||||
let a = -std::f64::consts::FRAC_PI_2 + i as f64 * std::f64::consts::PI / 5.0;
|
||||
let r = if i % 2 == 0 { 100.0 } else { 40.0 };
|
||||
let p = (150.0 + r * a.cos(), 150.0 + r * a.sin());
|
||||
if i == 0 {
|
||||
star.move_to(p);
|
||||
} else {
|
||||
star.line_to(p);
|
||||
}
|
||||
}
|
||||
star.close_path();
|
||||
(star.clone(), stroke_center(&star, 8.0, &StrokeProps::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_intersecting_inputs_make_disjoint_faces() {
|
||||
let (star, ring) = star_and_stroke();
|
||||
// The stroke outline really is self-intersecting: its winding is 2 at the tips.
|
||||
assert_eq!(ring.winding(Point::new(150.0, 56.0)), 2);
|
||||
let f = faces(&[(star.clone(), false), (ring.clone(), false)]).unwrap();
|
||||
let mut got: Vec<Vec<usize>> = f.iter().map(|x| x.cover.clone()).collect();
|
||||
got.sort();
|
||||
assert_eq!(got, vec![vec![0], vec![0, 1], vec![1]], "the star's inside, its stroke's inner half, its outer half");
|
||||
// Faces never overlap: no face's interior point is inside another face.
|
||||
for (i, a) in f.iter().enumerate() {
|
||||
let Some(p) = figcraft_doc::vector_faces::interior_point(&a.path) else { continue };
|
||||
for (j, b) in f.iter().enumerate() {
|
||||
assert!(i == j || b.path.winding(p) == 0, "faces {i} and {j} overlap at {p:?}");
|
||||
}
|
||||
}
|
||||
// They cover exactly what the two shapes paint together.
|
||||
let painted = boolean(&[star, ring], BoolOp::Union).unwrap();
|
||||
let all = union_faces(f.iter().map(|x| &x.path)).unwrap();
|
||||
assert!((all.area().abs() - painted.area().abs()).abs() < 0.5, "{} vs {}", all.area().abs(), painted.area().abs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_in_the_sweep_is_bad_input() {
|
||||
let r: Result<(), PathOpsError> = sweep(|| panic!("sweep bug"));
|
||||
|
||||
@@ -16,6 +16,8 @@ pub use widget::{WidgetKind, WidgetNode, WidgetRender};
|
||||
#[cfg(test)]
|
||||
mod bundle_tests;
|
||||
#[cfg(test)]
|
||||
mod save_file_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod v2_tests;
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde_json::{Map, Value};
|
||||
/// The manifest's file name.
|
||||
pub const MANIFEST_FILE: &str = "figcraft-plugin.json";
|
||||
/// The plugin API version this build implements (`api` in the manifest).
|
||||
pub const API_VERSION: u32 = 3;
|
||||
pub const API_VERSION: u32 = 4;
|
||||
/// Largest manifest read.
|
||||
pub const MAX_MANIFEST_BYTES: usize = 256 * 1024;
|
||||
|
||||
@@ -43,7 +43,7 @@ pub struct Manifest {
|
||||
/// The WebAssembly module, relative to the manifest (`plugin.wasm`).
|
||||
pub main: String,
|
||||
/// The plugin API version the plugin was written for (1 when not given; panels and settings
|
||||
/// need 2, widgets 3).
|
||||
/// need 2, widgets 3, `saveFile` 4).
|
||||
#[serde(default = "api_version", deserialize_with = "api_from_number_or_string")]
|
||||
pub api: u32,
|
||||
#[serde(default)]
|
||||
@@ -90,6 +90,10 @@ pub struct Permissions {
|
||||
/// command its access allows.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub commands: Vec<String>,
|
||||
/// API 4: the plugin may hand the host a file to save. The host asks the user where; the plugin
|
||||
/// never sees a path.
|
||||
#[serde(default, rename = "saveFile", skip_serializing_if = "std::ops::Not::not")]
|
||||
pub save_file: bool,
|
||||
}
|
||||
|
||||
impl Permissions {
|
||||
@@ -333,6 +337,9 @@ impl Manifest {
|
||||
if self.widget && self.api < 3 {
|
||||
return Err("a widget needs plugin API 3 (`\"api\": 3`)".into());
|
||||
}
|
||||
if self.permissions.save_file && self.api < 4 {
|
||||
return Err("saveFile needs plugin API 4 (`\"api\": 4`)".into());
|
||||
}
|
||||
if self.commands.is_empty() && !self.widget {
|
||||
self.commands.push(PluginCommand { id: "run".into(), label: "Run".into(), params: Vec::new() });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! memory cap, and sees the outside world only through the host functions below. There is no WASI:
|
||||
//! no file system, network, clock or randomness.
|
||||
//!
|
||||
//! # ABI (version 3; version 1 is the first seven imports, version 2 adds panels and settings)
|
||||
//! # ABI (version 4; version 1 is the first seven imports, version 2 adds panels and settings,
|
||||
//! version 3 widgets, version 4 `save_file`)
|
||||
//!
|
||||
//! The module exports its `memory` and `figcraft_run(input_len: i32) -> i32`. Host functions are
|
||||
//! imported from the module `figcraft`; strings are UTF-8 `(ptr, len)` pairs in the plugin's memory.
|
||||
@@ -22,6 +23,7 @@
|
||||
//! | `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> i32` | Stores JSON under the key. 1: refused, the reply says why. |
|
||||
//! | `setting_delete` | `(key_ptr, key_len)` | Forgets a setting. |
|
||||
//! | `widget_render` | `(ptr, len) -> i32` | Draws the widget being called back: a [`WidgetRender`] as JSON. 1: invalid, the reply says why. |
|
||||
//! | `save_file` | `(name_ptr, name_len, mime_ptr, mime_len, data_ptr, data_len) -> i32` | Offers a file to save (needs `permissions.saveFile`). The host asks the user where it goes; the plugin never gets a path. 0: accepted, 1: refused, the reply says why. |
|
||||
//!
|
||||
//! When `figcraft_run` starts, the reply holds the input JSON (`input_len` bytes):
|
||||
//! `{"api", "command", "params", "selection"}`, plus `event` and `values` when a panel calls the
|
||||
@@ -58,6 +60,7 @@ const IMPORTS: &[(&str, usize, usize)] = &[
|
||||
("setting_set", 4, 1),
|
||||
("setting_delete", 2, 0),
|
||||
("widget_render", 2, 1),
|
||||
("save_file", 6, 1),
|
||||
];
|
||||
|
||||
/// What a run may use.
|
||||
@@ -136,6 +139,11 @@ pub trait Host {
|
||||
fn set_setting(&mut self, _key: &str, _value: Option<&str>) -> Result<(), String> {
|
||||
Err("settings aren't available here".into())
|
||||
}
|
||||
/// Offer the file `name` (MIME type `mime`) for saving, or say why not. The host picks the
|
||||
/// destination; the plugin never learns it.
|
||||
fn save_file(&mut self, _name: &str, _mime: &str, _data: &[u8]) -> Result<(), String> {
|
||||
Err("saving files isn't available here".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// What a run asked of the panel.
|
||||
@@ -172,6 +180,7 @@ enum Request {
|
||||
SettingGet(String),
|
||||
SettingSet(String, String),
|
||||
SettingDelete(String),
|
||||
SaveFile(String, String, Vec<u8>),
|
||||
}
|
||||
|
||||
struct State {
|
||||
@@ -186,6 +195,12 @@ struct State {
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Make `message` the reply and return status 1 (refused).
|
||||
fn refuse(&mut self, message: String) -> i32 {
|
||||
self.set_reply(message.into_bytes());
|
||||
1
|
||||
}
|
||||
|
||||
/// Make `bytes` the reply; returns its length.
|
||||
fn set_reply(&mut self, bytes: Vec<u8>) -> i32 {
|
||||
let max = self.limits.max_message;
|
||||
@@ -237,6 +252,13 @@ impl State {
|
||||
let _ = host.set_setting(&key, None);
|
||||
None
|
||||
}
|
||||
Request::SaveFile(name, mime, data) => Some(match host.save_file(&name, &mime, &data) {
|
||||
Ok(()) => 0,
|
||||
Err(m) => {
|
||||
self.set_reply(m.into_bytes());
|
||||
1
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,5 +597,18 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
|
||||
Err(ask(&mut c, Request::SettingDelete(key)))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "save_file", |mut c: Ctx<'_>, np: i32, nl: i32, mp: i32, ml: i32, dp: i32, dl: i32| -> Result<i32, wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let name = read_str(&mut c, np, nl)?;
|
||||
let mime = read_str(&mut c, mp, ml)?;
|
||||
// Too much data is a refusal the plugin can handle, not a limit that fails the run.
|
||||
let (len, max) = (dl as u32 as usize, c.data().limits.max_message);
|
||||
if len > max {
|
||||
return Ok(c.data_mut().refuse(format!("a file to save is limited to {max} bytes, this one has {len}")));
|
||||
}
|
||||
let data = read_bytes(&mut c, dp, dl)?;
|
||||
Err(ask(&mut c, Request::SaveFile(name, mime, data)))
|
||||
})
|
||||
.map_err(err)?;
|
||||
Ok(l)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! `save_file` (API 4): the manifest permission, the host call with its bytes, and the limits.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Host, Instance, Limits, Manifest, PluginError};
|
||||
|
||||
/// A host that records the files offered to it (and refuses names starting with `no`).
|
||||
#[derive(Default)]
|
||||
struct Saves(Vec<(String, String, Vec<u8>)>);
|
||||
|
||||
impl Host for Saves {
|
||||
fn call(&mut self, _command: &str, _params: &str) -> Result<String, String> {
|
||||
Ok("{}".into())
|
||||
}
|
||||
fn selection(&mut self) -> String {
|
||||
"[]".into()
|
||||
}
|
||||
fn notify(&mut self, _message: &str) {}
|
||||
fn save_file(&mut self, name: &str, mime: &str, data: &[u8]) -> Result<(), String> {
|
||||
if name.starts_with("no") {
|
||||
return Err("nope".into());
|
||||
}
|
||||
self.0.push((name.into(), mime.into(), data.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A host that keeps the default `save_file`.
|
||||
struct Plain;
|
||||
|
||||
impl Host for Plain {
|
||||
fn call(&mut self, _command: &str, _params: &str) -> Result<String, String> {
|
||||
Ok("{}".into())
|
||||
}
|
||||
fn selection(&mut self) -> String {
|
||||
"[]".into()
|
||||
}
|
||||
fn notify(&mut self, _message: &str) {}
|
||||
}
|
||||
|
||||
/// Memory: name at 0, MIME at 100, data at 200. `call` is the arguments of `save_file`; its status
|
||||
/// is the run's status, and its reply is the output.
|
||||
fn module(name: &str, mime: &str, data: &[u8], call: &str) -> Vec<u8> {
|
||||
let bytes = |b: &[u8]| b.iter().map(|b| format!("\\{b:02x}")).collect::<String>();
|
||||
let wat = format!(
|
||||
r#"(module
|
||||
(import "figcraft" "save_file" (func $save_file (param i32 i32 i32 i32 i32 i32) (result i32)))
|
||||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||
(memory (export "memory") 1)
|
||||
(data (i32.const 0) "{}") (data (i32.const 100) "{}") (data (i32.const 200) "{}")
|
||||
(func (export "figcraft_run") (param $n i32) (result i32) (local $st i32)
|
||||
(local.set $st {call})
|
||||
(call $set_output (i32.const 1000) (call $reply_read (i32.const 1000) (i32.const 100)))
|
||||
(local.get $st)))"#,
|
||||
bytes(name.as_bytes()),
|
||||
bytes(mime.as_bytes()),
|
||||
bytes(data)
|
||||
);
|
||||
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
|
||||
}
|
||||
|
||||
fn call(name: &str, mime: &str, data: &[u8]) -> String {
|
||||
format!(
|
||||
"(call $save_file (i32.const 0) (i32.const {}) (i32.const 100) (i32.const {}) (i32.const 200) (i32.const {}))",
|
||||
name.len(),
|
||||
mime.len(),
|
||||
data.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn manifest(api: u32, save_file: bool) -> String {
|
||||
json!({"id": "org.test.save", "name": "Save", "main": "p.wasm", "api": api, "permissions": {"saveFile": save_file}}).to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_gets_the_name_the_type_and_the_exact_bytes() {
|
||||
let data = [0u8, 1, 2, 255, 0x80, b'x'];
|
||||
let wasm = module("out.jsx", "text/plain", &data, &call("out.jsx", "text/plain", &data));
|
||||
let mut host = Saves::default();
|
||||
Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut host).unwrap();
|
||||
assert_eq!(host.0, vec![("out.jsx".to_string(), "text/plain".to_string(), data.to_vec())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_returns_status_one_with_the_reason() {
|
||||
let wasm = module("no.txt", "text/plain", b"x", &call("no.txt", "text/plain", b"x"));
|
||||
// The module returns the status as its own, so the run fails with the reason as its message.
|
||||
let err = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut Saves::default()).unwrap_err();
|
||||
assert_eq!(err, PluginError::Failed("nope".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_without_saving_refuses_by_default() {
|
||||
let wasm = module("a.txt", "text/plain", b"x", &call("a.txt", "text/plain", b"x"));
|
||||
let err = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut Plain).unwrap_err();
|
||||
assert_eq!(err, PluginError::Failed("saving files isn't available here".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_much_data_is_refused_and_bad_utf8_a_trap() {
|
||||
let limits = Limits { max_message: 64, ..Limits::default() };
|
||||
let data = [b'x'; 65];
|
||||
let wasm = module("a.txt", "text/plain", &data, &call("a.txt", "text/plain", &data));
|
||||
let mut host = Saves::default();
|
||||
// Status 1 with the reason, which the module passes on as its own failure.
|
||||
let err = Instance::new(&wasm, &limits).unwrap().call("{}", &mut host).unwrap_err();
|
||||
assert_eq!(err, PluginError::Failed("a file to save is limited to 64 bytes, this one has 65".into()));
|
||||
assert!(host.0.is_empty());
|
||||
|
||||
// The name points at a lone 0xff byte: not UTF-8.
|
||||
let wasm = module(
|
||||
"a",
|
||||
"text/plain",
|
||||
&[0xff],
|
||||
"(call $save_file (i32.const 200) (i32.const 1) (i32.const 100) (i32.const 1) (i32.const 200) (i32.const 1))",
|
||||
);
|
||||
let err = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut host).unwrap_err();
|
||||
assert!(matches!(err, PluginError::Trap(_)), "{err:?}");
|
||||
assert!(host.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_signature_is_rejected() {
|
||||
let wat = r#"(module (import "figcraft" "save_file" (func (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#;
|
||||
let wasm = wat::parse_str(wat).unwrap();
|
||||
assert!(matches!(crate::check_module(&wasm, &Limits::default()), Err(PluginError::InvalidModule(m)) if m.contains("wrong signature")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_permission_needs_api_four_and_survives_a_round_trip() {
|
||||
let err = Manifest::parse(&manifest(3, true)).unwrap_err();
|
||||
assert!(err.contains("saveFile needs plugin API 4"), "{err}");
|
||||
let m = Manifest::parse(&manifest(4, true)).unwrap();
|
||||
assert!(m.permissions.save_file);
|
||||
let again = Manifest::parse(&serde_json::to_string(&m).unwrap()).unwrap();
|
||||
assert_eq!(m, again);
|
||||
assert!(serde_json::to_string(&m).unwrap().contains("\"saveFile\":true"));
|
||||
// Older manifests stay valid and don't grow the key.
|
||||
let old = Manifest::parse(&manifest(3, false)).unwrap();
|
||||
assert!(!old.permissions.save_file);
|
||||
assert!(!serde_json::to_string(&old).unwrap().contains("saveFile"));
|
||||
}
|
||||
@@ -441,7 +441,7 @@ fn bad_manifests_are_refused() {
|
||||
json!({"main": "C:\\plugin.wasm"}),
|
||||
json!({"main": "plugin.js"}),
|
||||
json!({"main": "a//b.wasm"}),
|
||||
json!({"api": 4}),
|
||||
json!({"api": 5}),
|
||||
json!({"api": 0}),
|
||||
json!({"permissions": {"access": "root"}}),
|
||||
json!({"permissions": {"commands": ["*.x"]}}),
|
||||
@@ -468,6 +468,6 @@ fn api_may_be_a_version_string() {
|
||||
assert_eq!(manifest(json!({"api": "1.0.0"})).unwrap().api, 1);
|
||||
assert_eq!(manifest(json!({"api": "2"})).unwrap().api, 2);
|
||||
assert_eq!(manifest(json!({"api": 2})).unwrap().api, 2);
|
||||
assert!(manifest(json!({"api": "4.0.0"})).is_err());
|
||||
assert!(manifest(json!({"api": "5.0.0"})).is_err());
|
||||
assert!(manifest(json!({"api": "x"})).is_err());
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ fn widgets_need_api_3() {
|
||||
assert!(m(2).unwrap_err().contains("API 3"));
|
||||
let ok = m(3).unwrap();
|
||||
assert!(ok.widget && ok.commands.is_empty(), "a widget-only plugin has no menu command");
|
||||
assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 4})).is_err());
|
||||
assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 5})).is_err());
|
||||
let v2 = Manifest::from_value(&json!({"id": "p", "name": "P", "main": "p.wasm", "api": 2})).unwrap();
|
||||
assert!(!v2.widget && v2.commands.len() == 1, "v2 manifests are unchanged");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! A dot grid on the page, under its layers (the board canvas): square dots on the device-pixel
|
||||
//! grid, blended into the image right after the page colour so the layers cover them. Each dot's
|
||||
//! pixels are computed from its page position alone, so bands and partial renders line up. Nothing
|
||||
//! is allocated per dot.
|
||||
|
||||
use figcraft_color::Color;
|
||||
use tiny_skia::Pixmap;
|
||||
|
||||
use crate::View;
|
||||
|
||||
/// Dots every `step` page units from the page origin, `size` page units square (from one to 64
|
||||
/// device pixels): those on every second line both ways in `color`, the others (the ones a grid
|
||||
/// twice as coarse leaves out) in `faded`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Dots {
|
||||
pub step: f64,
|
||||
pub size: f64,
|
||||
pub color: Color,
|
||||
pub faded: Color,
|
||||
}
|
||||
|
||||
/// The most dots one render draws: a step far too small for the view draws none.
|
||||
const MAX_DOTS: f64 = 4_000_000.0;
|
||||
|
||||
/// `c` as premultiplied 8-bit RGBA.
|
||||
fn premultiplied(c: Color) -> [u32; 4] {
|
||||
let [r, g, b, a] = c.to_rgba8().map(u32::from);
|
||||
[r * a / 255, g * a / 255, b * a / 255, a]
|
||||
}
|
||||
|
||||
/// Draw `d` into `pix`, the render of `view`.
|
||||
pub(crate) fn draw(pix: &mut Pixmap, view: &View, d: &Dots) {
|
||||
let s = view.scale;
|
||||
let ok = |v: f64| v.is_finite() && v > 0.0;
|
||||
if !(ok(d.step) && ok(d.size) && ok(s) && view.origin.0.is_finite() && view.origin.1.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (i64::from(pix.width()), i64::from(pix.height()));
|
||||
let (x0, y0) = view.origin;
|
||||
let (i0, i1) = ((x0 / d.step).floor() - 1.0, ((x0 + w as f64 / s) / d.step).ceil() + 1.0);
|
||||
let (j0, j1) = ((y0 / d.step).floor() - 1.0, ((y0 + h as f64 / s) / d.step).ceil() + 1.0);
|
||||
let count = (i1 - i0 + 1.0) * (j1 - j0 + 1.0);
|
||||
if !(count.is_finite() && count <= MAX_DOTS) {
|
||||
return;
|
||||
}
|
||||
// Each dot on the device pixel nearest its point, from the view's top-left in device pixels.
|
||||
let (ox, oy) = (x0 * s, y0 * s);
|
||||
let side = ((d.size * s).round() as i64).clamp(1, 64);
|
||||
let lead = side / 2;
|
||||
let (strong, faded) = (premultiplied(d.color), premultiplied(d.faded));
|
||||
let row = w as usize * 4;
|
||||
let data = pix.data_mut();
|
||||
for j in j0 as i64..=j1 as i64 {
|
||||
let top = ((j as f64 * d.step * s - oy).round() as i64).saturating_sub(lead);
|
||||
let (ya, yb) = (top.max(0), top.saturating_add(side).min(h));
|
||||
if ya >= yb {
|
||||
continue;
|
||||
}
|
||||
for i in i0 as i64..=i1 as i64 {
|
||||
let left = ((i as f64 * d.step * s - ox).round() as i64).saturating_sub(lead);
|
||||
let (xa, xb) = (left.max(0), left.saturating_add(side).min(w));
|
||||
if xa >= xb {
|
||||
continue;
|
||||
}
|
||||
let rgba = if i % 2 == 0 && j % 2 == 0 { strong } else { faded };
|
||||
blend_square(data, row, (xa, xb), (ya, yb), rgba);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend premultiplied `rgba` source-over into the pixels `xs` × `ys` of `data`, rows `row` bytes
|
||||
/// long.
|
||||
fn blend_square(data: &mut [u8], row: usize, (xa, xb): (i64, i64), (ya, yb): (i64, i64), rgba: [u32; 4]) {
|
||||
let keep = 255 - rgba[3];
|
||||
if rgba[3] == 0 {
|
||||
return;
|
||||
}
|
||||
for y in ya..yb {
|
||||
let start = y as usize * row;
|
||||
let Some(line) = data.get_mut(start + xa as usize * 4..start + xb as usize * 4) else { continue };
|
||||
for px in line.chunks_exact_mut(4) {
|
||||
for (dst, src) in px.iter_mut().zip(rgba) {
|
||||
*dst = (src + u32::from(*dst) * keep / 255).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! The board canvas's dot grid: on the page colour, under the layers, the same pixels whichever
|
||||
//! part of the view a render covers (bands, retained patches), never in exports, and nothing
|
||||
//! drawn (or panicking) for spacings and sizes it can't draw.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
|
||||
use crate::{Dots, Options, Retained, View, render_view, render_view_single};
|
||||
|
||||
fn dots() -> Dots {
|
||||
Dots { step: 24.0, size: 2.0, color: Color::new(0.0, 0.0, 0.0, 0.5), faded: Color::new(0.0, 0.0, 0.0, 0.25) }
|
||||
}
|
||||
|
||||
fn page_with_square() -> (Document, figcraft_doc::NodeId) {
|
||||
let mut d = Document::new("dots");
|
||||
let page = d.first_page().unwrap();
|
||||
let r = d.create(page, NodeKind::Rectangle, "Square", 40.0, 40.0, 60.0, 60.0).unwrap();
|
||||
d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::from_hex("2D6CDF").unwrap())];
|
||||
(d, page)
|
||||
}
|
||||
|
||||
fn rgba(p: &crate::Pixmap, x: u32, y: u32) -> [u8; 4] {
|
||||
let c = p.pixel(x, y).unwrap();
|
||||
[c.red(), c.green(), c.blue(), c.alpha()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dots_sit_on_the_page_under_the_layers() {
|
||||
let (d, page) = page_with_square();
|
||||
let view = View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 };
|
||||
let pix = render_view_single(&d, page, &view, &Options { dots: Some(dots()), ..Default::default() }).unwrap();
|
||||
let plain = render_view_single(&d, page, &view, &Options::default()).unwrap();
|
||||
// A strong dot at (0, 0) (every second line both ways), a fainter one at (24, 0).
|
||||
let (strong, faint, empty) = (rgba(&pix, 0, 0), rgba(&pix, 24, 0), rgba(&pix, 12, 0));
|
||||
assert_eq!(empty, rgba(&plain, 12, 0), "nothing between the dots");
|
||||
assert!(strong[0] < faint[0] && faint[0] < empty[0], "{strong:?} {faint:?} {empty:?}");
|
||||
assert_eq!(rgba(&pix, 48, 48), rgba(&plain, 48, 48), "the square covers the dot at (48, 48)");
|
||||
// Exports and thumbnails (no dots asked for) are unchanged.
|
||||
assert_ne!(pix.data(), plain.data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dots_line_up_across_bands_and_patches() {
|
||||
let (d, page) = page_with_square();
|
||||
let opts = Options { dots: Some(dots()), ..Default::default() };
|
||||
// On a whole-pixel origin, as the canvas renders.
|
||||
let full = View { origin: (-212.0, -140.0), scale: 1.5, width: 900, height: 700 };
|
||||
let a = render_view_single(&d, page, &full, &opts).unwrap();
|
||||
// Banded in parallel (desktop) or in one piece: the same pixels.
|
||||
let b = render_view(&d, page, &full, &opts).unwrap();
|
||||
assert_eq!(a.data(), b.data());
|
||||
// A view on the same pixel grid, a whole number of pixels further on, shows the same dots.
|
||||
let shifted = View { origin: (full.origin.0 + 101.0 / 1.5, full.origin.1 + 37.0 / 1.5), ..full.clone() };
|
||||
let c = render_view_single(&d, page, &shifted, &opts).unwrap();
|
||||
for (x, y) in [(0, 0), (150, 90), (400, 300), (700, 500)] {
|
||||
assert_eq!(rgba(&c, x, y), rgba(&a, x + 101, y + 37), "at {x}, {y}");
|
||||
}
|
||||
// An edit redraws only around the layer, and the patched image matches a fresh render.
|
||||
let mut retained = Retained::default();
|
||||
let doc = Arc::new(d.clone());
|
||||
retained.render(&doc, page, &full, &opts).unwrap();
|
||||
let mut e = d;
|
||||
let sq = e.get(page).unwrap().children[0];
|
||||
e.get_mut(sq).unwrap().fills = vec![Paint::solid(Color::from_hex("E8505B").unwrap())];
|
||||
let e = Arc::new(e);
|
||||
let (snapped, patched, _) = retained.render(&e, page, &full, &opts).unwrap();
|
||||
let fresh = render_view_single(&e, page, &snapped, &opts).unwrap();
|
||||
assert_eq!(patched.data(), fresh.data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dots_it_cant_draw_draw_nothing() {
|
||||
let (d, page) = page_with_square();
|
||||
let view = View { origin: (-1e12, 1e12), scale: 1.0, width: 64, height: 64 };
|
||||
let plain = render_view_single(&d, page, &view, &Options::default()).unwrap();
|
||||
for step in [0.0, -24.0, f64::NAN, f64::INFINITY, 1e-9] {
|
||||
let opts = Options { dots: Some(Dots { step, ..dots() }), ..Default::default() };
|
||||
assert_eq!(render_view_single(&d, page, &view, &opts).unwrap().data(), plain.data(), "step {step}");
|
||||
}
|
||||
for size in [f64::NAN, 1e308, -1.0] {
|
||||
let opts = Options { dots: Some(Dots { size, ..dots() }), ..Default::default() };
|
||||
let _ = render_view_single(&d, page, &view, &opts);
|
||||
}
|
||||
let far = View { origin: (f64::MAX / 2.0, -f64::MAX / 2.0), scale: 1e10, width: 16, height: 16 };
|
||||
let _ = render_view_single(&d, page, &far, &Options { dots: Some(dots()), ..Default::default() });
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod backdrop;
|
||||
pub mod blur;
|
||||
pub mod boolean;
|
||||
pub mod dots;
|
||||
pub mod draw;
|
||||
mod export_depth;
|
||||
pub mod fx;
|
||||
@@ -21,6 +22,7 @@ pub mod pattern;
|
||||
pub mod retained;
|
||||
pub mod shadow;
|
||||
|
||||
pub use dots::Dots;
|
||||
pub use draw::{node_outline, paint_bounds, stroke_fill, subtree_outline, ts_path};
|
||||
pub use export_depth::{ExportDepthError, MAX_EXPORT_DEPTH, check_export_depth};
|
||||
pub use retained::{Retained, Work};
|
||||
@@ -79,6 +81,9 @@ pub struct Options {
|
||||
/// detail, see [`lod`]. Off for everything that must be exact (exports, thumbnails); no
|
||||
/// effect in outline mode.
|
||||
pub lod: bool,
|
||||
/// A dot grid over the background, under the layers (a board's canvas; drawn only where the
|
||||
/// background is).
|
||||
pub dots: Option<Dots>,
|
||||
}
|
||||
|
||||
fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
|
||||
@@ -168,6 +173,9 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
|
||||
});
|
||||
if let Some(bg) = bg {
|
||||
pix.fill(paint::ts_color(bg));
|
||||
if let Some(d) = &opts.dots {
|
||||
dots::draw(&mut pix, view, d);
|
||||
}
|
||||
}
|
||||
let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE;
|
||||
let bounds = draw::Bounds::default();
|
||||
@@ -440,6 +448,8 @@ mod boolean_cache_tests;
|
||||
#[cfg(test)]
|
||||
mod bounds_memo_tests;
|
||||
#[cfg(test)]
|
||||
mod dots_tests;
|
||||
#[cfg(test)]
|
||||
mod effect_order_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_behaviour_tests;
|
||||
|
||||
@@ -48,6 +48,7 @@ struct Frame {
|
||||
skip: Vec<NodeId>,
|
||||
solo: Option<NodeId>,
|
||||
background: Option<figcraft_color::Color>,
|
||||
dots: Option<crate::Dots>,
|
||||
scale: u64,
|
||||
/// The view's top-left in device pixels (page origin × scale).
|
||||
px: (i64, i64),
|
||||
@@ -159,6 +160,7 @@ impl Retained {
|
||||
&& last.skip == opts.skip
|
||||
&& last.solo == opts.solo
|
||||
&& last.background == opts.background
|
||||
&& last.dots == opts.dots
|
||||
&& last.scale == snapped.scale.to_bits()
|
||||
&& last.pix.width() == snapped.width
|
||||
&& last.pix.height() == snapped.height
|
||||
@@ -203,6 +205,7 @@ impl Retained {
|
||||
skip: opts.skip.clone(),
|
||||
solo: opts.solo,
|
||||
background: opts.background,
|
||||
dots: opts.dots,
|
||||
scale: view.scale.to_bits(),
|
||||
px,
|
||||
fonts,
|
||||
|
||||
@@ -321,7 +321,7 @@ fn symbols_come_from_a_symbol_font_when_one_is_installed() {
|
||||
#[test]
|
||||
fn break_opportunities() {
|
||||
// (before, after, may break between, derivation)
|
||||
let cases: [(char, char, bool, &str); 22] = [
|
||||
let cases: [(char, char, bool, &str); 32] = [
|
||||
('a', 'b', false, "inside a Latin word: never"),
|
||||
(' ', 'b', true, "after a space"),
|
||||
('a', ' ', false, "before a space: the space hangs at the line's end"),
|
||||
@@ -344,6 +344,16 @@ fn break_opportunities() {
|
||||
('。', '日', true, "after a full stop"),
|
||||
('日', '「', true, "before an opening bracket"),
|
||||
('\u{3000}', '日', true, "after an ideographic space"),
|
||||
('キ', 'ャ', false, "kinsoku: nor a halfwidth small kana"),
|
||||
('本', 'ッ', false, "kinsoku: nor the halfwidth small tsu"),
|
||||
('キ', '゙', false, "kinsoku: nor the halfwidth voiced sound mark"),
|
||||
('キ', '゚', false, "kinsoku: nor the halfwidth semi-voiced sound mark"),
|
||||
('本', '゛', false, "kinsoku: nor the spacing voiced sound mark"),
|
||||
('本', '゜', false, "kinsoku: nor the spacing semi-voiced sound mark"),
|
||||
('本', '〱', false, "kinsoku: nor a kana iteration mark"),
|
||||
('本', '〳', false, "kinsoku: nor a vertical kana repeat mark"),
|
||||
('本', '⦆', false, "kinsoku: nor a halfwidth closing parenthesis"),
|
||||
('⦅', '日', false, "kinsoku: a halfwidth opening parenthesis never ends a line"),
|
||||
];
|
||||
for (a, b, want, why) in cases {
|
||||
assert_eq!(break_between(a, b), want, "{a}{b}: {why}");
|
||||
@@ -357,13 +367,14 @@ fn cjk_lines_wrap_between_ideographs_with_kinsoku() {
|
||||
}
|
||||
// Box width cut right after char `fit` (chars 0..fit fit, the next one doesn't): the first line
|
||||
// and how the second starts. (text, fit, first line, second line starts with, derivation)
|
||||
let cases: [(&str, usize, &str, &str, &str); 6] = [
|
||||
let cases: [(&str, usize, &str, &str, &str); 7] = [
|
||||
("一二三四五六", 3, "一二三", "四", "ideographs break anywhere"),
|
||||
("一二三。四五", 3, "一二", "三。", "。 can't start a line: 三 moves down with it"),
|
||||
("一二「三」", 3, "一二", "「三", "「 can't end a line: it moves down"),
|
||||
("日本語abc def", 5, "日本語", "abc", "no break inside a Latin word: before it"),
|
||||
("abc 日本語です", 6, "abc 日本", "語", "a space or an ideograph is a break"),
|
||||
("한국어 텍스트입니다", 5, "한국어 텍", "스", "Korean breaks between syllables (documentation silent)"),
|
||||
("一二キャッド", 3, "一二", "キャ", "a halfwidth small kana stays off the line start: キ moves down with it"),
|
||||
];
|
||||
for (s, fit, first, second, why) in cases {
|
||||
let t = text(s, TextAutoResize::Height);
|
||||
@@ -377,6 +388,17 @@ fn cjk_lines_wrap_between_ideographs_with_kinsoku() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_manual_newline_may_start_a_line_with_a_prohibited_character() {
|
||||
if face().is_none() {
|
||||
return;
|
||||
}
|
||||
// The author's own line breaks are kept: 禁則 only applies to automatic wrapping.
|
||||
let t = text("き\nっく", TextAutoResize::Height);
|
||||
let l = layout(&t, None, None);
|
||||
assert_eq!(line_texts(&t, &l), ["き\n", "っく"], "a manual break is not pushed out by kinsoku");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_width_space_hangs_and_breaks() {
|
||||
if face().is_none() {
|
||||
|
||||
@@ -23,12 +23,13 @@ enum Class {
|
||||
Glue,
|
||||
}
|
||||
|
||||
/// Opening brackets and quotes (line-end prohibited).
|
||||
const OPEN: &str = "([{‘“〈《「『【〔〖〘〚〝([{「";
|
||||
/// Closing brackets, quotes and punctuation, iteration marks, small kana and the prolonged sound
|
||||
/// mark (line-start prohibited; the small katakana extensions U+31F0..U+31FF are a range below).
|
||||
const CLOSE: &str =
|
||||
")]},.:;!?’”‥…‼⁇⁈⁉、。々〉》」』】〕〗〙〛〜〞〟〻ぁぃぅぇぉっゃゅょゎゕゖゝゞ゠ァィゥェォッャュョヮヵヶ・ーヽヾ!),.:;?]}。」、・ー";
|
||||
/// Opening brackets and quotes (line-end prohibited, JIS X 4051's 行末禁則).
|
||||
const OPEN: &str = "([{‘“〈《「『【〔〖〘〚〝([{「⦅";
|
||||
/// Closing brackets, quotes and punctuation, iteration marks, small kana, the prolonged sound mark
|
||||
/// and the (semi-)voiced sound marks (line-start prohibited, JIS X 4051's 行頭禁則). Two blocks
|
||||
/// are ranges in [`class`] instead: the small katakana extensions U+31F0..U+31FF and the halfwidth
|
||||
/// small kana U+FF67..U+FF6F plus the halfwidth voicing marks U+FF9E/U+FF9F.
|
||||
const CLOSE: &str = ")]},.:;!?’”‥…‼⁇⁈⁉、。々〉》」』】〕〗〙〛〜〞〟〻ぁぃぅぇぉっゃゅょゎゕゖゝゞ゠ァィゥェォッャュョヮヵヶ・ーヽヾ!),.:;?]}。」、・ー⦆゛゜〱〲〳〴〵";
|
||||
|
||||
fn class(c: char) -> Class {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
@@ -37,7 +38,7 @@ fn class(c: char) -> Class {
|
||||
if OPEN.contains(c) {
|
||||
return Class::Open;
|
||||
}
|
||||
if CLOSE.contains(c) || ('\u{31F0}'..='\u{31FF}').contains(&c) {
|
||||
if CLOSE.contains(c) || ('\u{31F0}'..='\u{31FF}').contains(&c) || ('\u{FF67}'..='\u{FF6F}').contains(&c) || matches!(c, '\u{FF9E}' | '\u{FF9F}') {
|
||||
return Class::Close;
|
||||
}
|
||||
match c {
|
||||
|
||||
+162
-24
@@ -86,6 +86,8 @@ pub struct Face {
|
||||
pub id: u32,
|
||||
pub family: String,
|
||||
pub style: String,
|
||||
/// The face's PostScript name ("HelveticaNeue-Bold").
|
||||
pub postscript: Option<String>,
|
||||
pub weight: u16,
|
||||
pub italic: bool,
|
||||
pub bytes: FontBytes,
|
||||
@@ -106,6 +108,8 @@ pub struct Face {
|
||||
/// Variation axes (empty for static fonts) and named instances.
|
||||
pub axes: Vec<Axis>,
|
||||
pub instances: Vec<NamedInstance>,
|
||||
/// The `trak` table's normal track: (size in points, tracking in font units), by size.
|
||||
track: Vec<(f64, f64)>,
|
||||
charmap: HashMap<u32, u32>,
|
||||
outlines: Mutex<HashMap<(u32, Coords), Arc<BezPath>>>,
|
||||
advances: Mutex<HashMap<(u32, Coords), f64>>,
|
||||
@@ -123,6 +127,29 @@ impl std::fmt::Debug for Face {
|
||||
}
|
||||
|
||||
impl Face {
|
||||
/// The tracking the font's `trak` table gives text of `size` (pixels per glyph; 0 without a
|
||||
/// table): Apple's platform text engine adds it on its own, FigCraft's doesn't.
|
||||
pub fn tracking(&self, size: f64) -> f64 {
|
||||
let (Some(first), Some(last)) = (self.track.first(), self.track.last()) else { return 0.0 };
|
||||
if !size.is_finite() || self.upem <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let units = if size <= first.0 {
|
||||
first.1
|
||||
} else if size >= last.0 {
|
||||
last.1
|
||||
} else {
|
||||
self.track
|
||||
.windows(2)
|
||||
.find_map(|w| match w {
|
||||
[a, b] if size >= a.0 && size <= b.0 && b.0 > a.0 => Some(a.1 + (b.1 - a.1) * (size - a.0) / (b.0 - a.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(0.0)
|
||||
};
|
||||
units * size / self.upem
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: FontBytes, index: u32, family_override: Option<&str>, style_override: Option<&str>) -> Option<Face> {
|
||||
let data = bytes.data();
|
||||
let f = skrifa::FontRef::from_index(data, index).ok()?;
|
||||
@@ -147,6 +174,7 @@ impl Face {
|
||||
let charmap: HashMap<u32, u32> = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect();
|
||||
let (axes, instances) = variation_info(&f);
|
||||
let has_table = |tag: &[u8; 4]| f.data_for_tag(skrifa::Tag::new(tag)).is_some();
|
||||
let track = f.data_for_tag(skrifa::Tag::new(b"trak")).map(|t| normal_track(t.as_bytes())).unwrap_or_default();
|
||||
let bitmap_strikes = has_table(b"sbix") || has_table(b"CBDT") || has_table(b"EBDT");
|
||||
let colr = has_table(b"COLR");
|
||||
let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06));
|
||||
@@ -159,6 +187,7 @@ impl Face {
|
||||
id: NEXT_FACE.fetch_add(1, Ordering::Relaxed),
|
||||
italic: !matches!(attrs.style, skrifa::attribute::Style::Normal) || style.to_ascii_lowercase().contains("italic"),
|
||||
weight,
|
||||
postscript: name_string(&f, StringId::POSTSCRIPT_NAME),
|
||||
family,
|
||||
style,
|
||||
index,
|
||||
@@ -173,6 +202,7 @@ impl Face {
|
||||
strike_pos: strike,
|
||||
shaper,
|
||||
gsub_features,
|
||||
track,
|
||||
axes,
|
||||
instances,
|
||||
charmap,
|
||||
@@ -398,6 +428,31 @@ fn name_string(f: &skrifa::FontRef, id: StringId) -> Option<String> {
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
/// The normal (0) horizontal track of a `trak` table: (size, tracking in font units) by size. Empty
|
||||
/// when the table is missing, damaged or has no normal track.
|
||||
fn normal_track(t: &[u8]) -> Vec<(f64, f64)> {
|
||||
let u16_at = |o: usize| t.get(o..o + 2).map(|b| u16::from_be_bytes([b[0], b[1]]));
|
||||
let i16_at = |o: usize| t.get(o..o + 2).map(|b| i16::from_be_bytes([b[0], b[1]]));
|
||||
let fixed_at = |o: usize| t.get(o..o + 4).map(|b| f64::from(i32::from_be_bytes([b[0], b[1], b[2], b[3]])) / 65536.0);
|
||||
let Some(horiz) = u16_at(6).map(usize::from).filter(|o| *o > 0) else { return Vec::new() };
|
||||
let (Some(tracks), Some(sizes)) = (u16_at(horiz).map(usize::from), u16_at(horiz + 2).map(usize::from)) else { return Vec::new() };
|
||||
let Some(size_table) = t.get(horiz + 4..horiz + 8).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize) else { return Vec::new() };
|
||||
let sizes = sizes.min(256);
|
||||
for k in 0..tracks.min(64) {
|
||||
let entry = horiz + 8 + k * 8;
|
||||
if fixed_at(entry) != Some(0.0) {
|
||||
continue;
|
||||
}
|
||||
let Some(values) = u16_at(entry + 6).map(usize::from) else { return Vec::new() };
|
||||
let out: Vec<(f64, f64)> = (0..sizes)
|
||||
.filter_map(|i| Some((fixed_at(size_table + i * 4)?, f64::from(i16_at(values + i * 2)?))))
|
||||
.filter(|(s, _)| s.is_finite())
|
||||
.collect();
|
||||
return if out.windows(2).all(|w| w[0].0 < w[1].0) { out } else { Vec::new() };
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// (family, style) preferring the typographic names.
|
||||
fn names(f: &skrifa::FontRef) -> (Option<String>, Option<String>) {
|
||||
let family = name_string(f, StringId::TYPOGRAPHIC_FAMILY_NAME).or_else(|| name_string(f, StringId::FAMILY_NAME));
|
||||
@@ -446,6 +501,8 @@ struct Pending {
|
||||
path: std::path::PathBuf,
|
||||
index: u32,
|
||||
style: String,
|
||||
/// The face's PostScript name.
|
||||
postscript: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -474,6 +531,8 @@ pub struct Rescan {
|
||||
static DB: OnceLock<RwLock<FontDb>> = OnceLock::new();
|
||||
/// Set by [`scan_system_fonts_on_demand`].
|
||||
static ON_DEMAND: AtomicBool = AtomicBool::new(false);
|
||||
/// Set by [`opt_out_of_system_fonts`].
|
||||
static OPTED_OUT: AtomicBool = AtomicBool::new(false);
|
||||
/// Done once per process: concurrent callers wait for the one scan.
|
||||
static SCANNED: OnceLock<()> = OnceLock::new();
|
||||
/// Directory walks done (at most one).
|
||||
@@ -523,6 +582,16 @@ impl FontDb {
|
||||
(0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None)).map(|f| self.add(f)).collect()
|
||||
}
|
||||
|
||||
/// The family and style of the face whose PostScript name is `ps` (ignoring case), loaded or
|
||||
/// only scanned.
|
||||
pub fn postscript_face(&self, ps: &str) -> Option<(String, String)> {
|
||||
let is = |p: &Option<String>| p.as_deref().is_some_and(|p| p.eq_ignore_ascii_case(ps));
|
||||
if let Some(f) = self.faces.iter().find(|f| is(&f.postscript)) {
|
||||
return Some((f.family.clone(), f.style.clone()));
|
||||
}
|
||||
self.pending.values().find_map(|(display, entries)| entries.iter().find(|e| is(&e.postscript)).map(|e| (display.clone(), e.style.clone())))
|
||||
}
|
||||
|
||||
/// Family display names, sorted (loaded and known system families).
|
||||
pub fn families(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.families.values().map(|(d, _)| d.clone()).collect();
|
||||
@@ -539,7 +608,7 @@ impl FontDb {
|
||||
/// Style names of a family, by weight then italic.
|
||||
pub fn styles(&mut self, family: &str) -> Vec<String> {
|
||||
self.load_family(family, None);
|
||||
let Some((_, idx)) = self.families.get(&family.to_lowercase()) else { return vec![] };
|
||||
let Some((_, idx)) = self.families.get(&self.key(family)) else { return vec![] };
|
||||
// (weight, italic, order in the font, name): named instances keep the font's order.
|
||||
let mut v: Vec<(u16, bool, usize, String)> = Vec::new();
|
||||
for f in idx.iter().filter_map(|i| self.faces.get(*i)) {
|
||||
@@ -560,15 +629,31 @@ impl FontDb {
|
||||
v.into_iter().map(|x| x.3).collect()
|
||||
}
|
||||
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
/// The key `family` is known under: itself, or for a name designs use for Apple's system font
|
||||
/// (SF Pro, SF UI Text, …) the system font when no font of that name is installed.
|
||||
pub fn key(&self, family: &str) -> String {
|
||||
let k = family.to_lowercase();
|
||||
let known = |k: &str| self.families.contains_key(k) || self.pending.contains_key(k);
|
||||
if known(&k) {
|
||||
return k;
|
||||
}
|
||||
match apple_alias(&k) {
|
||||
Some(target) if known(target) => target.to_string(),
|
||||
_ => k,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
let k = self.key(family);
|
||||
self.families.contains_key(&k) || self.pending.contains_key(&k)
|
||||
}
|
||||
|
||||
/// Read a system family's faces from disk; with `only`, just the face nearest that style (the
|
||||
/// rest stay pending).
|
||||
fn load_family(&mut self, family: &str, only: Option<&str>) {
|
||||
let key = family.to_lowercase();
|
||||
let key = self.key(family);
|
||||
// Faces load under the family name the scan gave them (a hidden family's public name).
|
||||
let display = self.pending.get(&key).map(|(d, _)| d.clone());
|
||||
let entries = match only {
|
||||
None => match self.pending.remove(&key) {
|
||||
Some((_, entries)) => entries,
|
||||
@@ -602,7 +687,7 @@ impl FontDb {
|
||||
Err(_) => continue,
|
||||
},
|
||||
};
|
||||
if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) {
|
||||
if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, display.as_deref(), Some(&e.style)) {
|
||||
self.add(face);
|
||||
self.loaded_files.insert((e.path, e.index));
|
||||
}
|
||||
@@ -619,7 +704,7 @@ impl FontDb {
|
||||
/// `style`: a fallback for missing characters needs one face, not every file of a CJK family
|
||||
/// (10–20 MB each). The family's other faces load when it is used by name.
|
||||
pub fn find_one(&mut self, family: &str, style: &str) -> Option<Arc<Face>> {
|
||||
if !self.families.contains_key(&family.to_lowercase()) {
|
||||
if !self.families.contains_key(&self.key(family)) {
|
||||
self.load_family(family, Some(style));
|
||||
}
|
||||
self.best(family, style)
|
||||
@@ -627,7 +712,7 @@ impl FontDb {
|
||||
|
||||
/// The loaded face of `family` nearest `style`.
|
||||
fn best(&self, family: &str, style: &str) -> Option<Arc<Face>> {
|
||||
let (_, idx) = self.families.get(&family.to_lowercase())?;
|
||||
let (_, idx) = self.families.get(&self.key(family))?;
|
||||
let faces: Vec<&Arc<Face>> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
|
||||
let want = norm_style(style);
|
||||
if let Some(f) = faces.iter().find(|f| norm_style(&f.style) == want).or_else(|| faces.iter().find(|f| f.instance(style).is_some())) {
|
||||
@@ -731,13 +816,26 @@ impl FontDb {
|
||||
return;
|
||||
}
|
||||
self.system_scanned = true;
|
||||
let mut found = BTreeMap::new();
|
||||
for dir in system_font_dirs() {
|
||||
scan_dir(&dir, 0, &mut self.pending);
|
||||
scan_dir(&dir, 0, &mut found);
|
||||
}
|
||||
// Families already embedded win.
|
||||
let embedded: Vec<String> = self.families.keys().cloned().collect();
|
||||
for k in embedded {
|
||||
self.pending.remove(&k);
|
||||
self.merge_scan(found);
|
||||
}
|
||||
|
||||
/// Offer the families of the first walk of the font folders. A family already embedded keeps
|
||||
/// its faces, which win, and the installed styles it lacks wait beside them (#1246: release
|
||||
/// builds embed Poppins Regular, Italic and Bold, so an installed Poppins SemiBold went unseen).
|
||||
fn merge_scan(&mut self, found: BTreeMap<String, (String, Vec<Pending>)>) {
|
||||
for (k, (display, mut entries)) in found {
|
||||
if let Some((_, idx)) = self.families.get(&k) {
|
||||
let have: Vec<&Arc<Face>> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
|
||||
entries.retain(|e| !have.iter().any(|f| norm_style(&f.style) == norm_style(&e.style) || f.instance(&e.style).is_some()));
|
||||
if entries.is_empty() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.pending.entry(k).or_insert((display, entries));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -842,8 +940,13 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
|
||||
continue;
|
||||
}
|
||||
let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue };
|
||||
for (index, family, style) in faces_in(&bytes) {
|
||||
out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style });
|
||||
for (index, family, style, postscript) in faces_in(&bytes) {
|
||||
out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending {
|
||||
path: path.clone(),
|
||||
index,
|
||||
style,
|
||||
postscript,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -895,9 +998,9 @@ fn index_bytes(path: &std::path::Path, len: usize) -> Option<Vec<u8>> {
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// (index, family, style) of each face in a font file: outline fonts and colour fonts (Apple Color
|
||||
/// (index, family, style, PostScript name) of each face in a font file: outline fonts and colour fonts (Apple Color
|
||||
/// Emoji draws most emoji from `sbix`, Noto Color Emoji has no outlines at all).
|
||||
fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> {
|
||||
fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String, Option<String>)> {
|
||||
let count = match FileRef::new(bytes) {
|
||||
Ok(FileRef::Collection(c)) => c.len(),
|
||||
Ok(FileRef::Font(_)) => 1,
|
||||
@@ -911,13 +1014,45 @@ fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> {
|
||||
.any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some())
|
||||
.then_some(())?;
|
||||
let (family, style) = names(&f);
|
||||
let family = family.filter(|f| !f.starts_with('.'))?;
|
||||
Some((i, family, style.unwrap_or_else(|| "Regular".into())))
|
||||
let family = public_family(&family?)?;
|
||||
Some((i, family, style.unwrap_or_else(|| "Regular".into()), name_string(&f, StringId::POSTSCRIPT_NAME)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The name a font's family is offered under: its own, a public name for Apple's hidden system
|
||||
/// families that designs use (`.SF Compact` → SF Compact), none for other hidden (`.`) families.
|
||||
fn public_family(family: &str) -> Option<String> {
|
||||
let Some(hidden) = family.strip_prefix('.') else { return Some(family.to_string()) };
|
||||
match hidden {
|
||||
"SF Compact" | "SF Compact Rounded" => Some(hidden.to_string()),
|
||||
"SF NS Rounded" => Some("SF Pro Rounded".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The family Apple's system fonts are installed under, for the names designs use for them
|
||||
/// (lowercase): SF Pro and SF UI are the variable `SFNS.ttf` ("System Font"), its text and display
|
||||
/// cuts its optical sizes.
|
||||
fn apple_alias(key: &str) -> Option<&'static str> {
|
||||
Some(match key.trim_start_matches('.') {
|
||||
"sf pro" | "sf pro text" | "sf pro display" | "sf ui text" | "sf ui display" | "sf ns" | "sf ns text" | "sf ns display" | "san francisco" => {
|
||||
"system font"
|
||||
}
|
||||
"sf compact text" | "sf compact display" => "sf compact",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convenience: find a face with the global db.
|
||||
/// The family and style of the installed face whose PostScript name is `ps`.
|
||||
pub fn postscript_face(ps: &str) -> Option<(String, String)> {
|
||||
if ps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
db().read().unwrap_or_else(std::sync::PoisonError::into_inner).postscript_face(ps)
|
||||
}
|
||||
|
||||
pub fn find_face(family: &str, style: &str) -> Option<Arc<Face>> {
|
||||
let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
db.find(family, style).or_else(|| db.default_face())
|
||||
@@ -952,11 +1087,7 @@ pub fn scan_system_fonts() {
|
||||
}
|
||||
let mut w = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
w.system_scanned = true;
|
||||
for (k, v) in found {
|
||||
if !w.families.contains_key(&k) {
|
||||
w.pending.entry(k).or_insert(v);
|
||||
}
|
||||
}
|
||||
w.merge_scan(found);
|
||||
drop(w);
|
||||
crate::clear_layout_cache();
|
||||
}
|
||||
@@ -1012,10 +1143,17 @@ pub fn load_font_bytes(bytes: Vec<u8>) -> Vec<String> {
|
||||
families
|
||||
}
|
||||
|
||||
/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts.
|
||||
/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`), or the process opted out
|
||||
/// ([`opt_out_of_system_fonts`]): renders use only the embedded fonts.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_fonts_opted_out() -> bool {
|
||||
std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0")
|
||||
OPTED_OUT.load(Ordering::Relaxed) || std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0")
|
||||
}
|
||||
|
||||
/// Use only the embedded fonts for the rest of the process, as `FIGCRAFT_NO_SYSTEM_FONTS` does
|
||||
/// (headless runs with `--no-system-fonts`): no call scans the system fonts after this.
|
||||
pub fn opt_out_of_system_fonts() {
|
||||
OPTED_OUT.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Every font text can use is known: the system fonts were indexed, or there are none to index
|
||||
|
||||
@@ -2,7 +2,8 @@ use super::*;
|
||||
|
||||
fn pending(styles: &[&str]) -> FontDb {
|
||||
let mut db = FontDb::default();
|
||||
let entries = styles.iter().map(|s| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: (*s).into() }).collect();
|
||||
let entries =
|
||||
styles.iter().map(|s| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: (*s).into(), postscript: None }).collect();
|
||||
db.pending.insert("cjk ui".into(), ("CJK UI".into(), entries));
|
||||
db
|
||||
}
|
||||
@@ -263,7 +264,7 @@ fn the_scan_reads_font_files_without_an_extension() {
|
||||
/// A rescan offers new families and the styles installed since, and never a file twice.
|
||||
#[test]
|
||||
fn a_rescan_offers_fonts_installed_since() {
|
||||
let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() };
|
||||
let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into(), postscript: None };
|
||||
let mut db = pending(&["Regular"]);
|
||||
let found = |fams: &[(&str, &[&str])]| -> BTreeMap<String, (String, Vec<Pending>)> {
|
||||
fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect()
|
||||
@@ -289,6 +290,39 @@ fn a_rescan_offers_fonts_installed_since() {
|
||||
assert_eq!(waiting(&db), ["Black"]);
|
||||
}
|
||||
|
||||
/// #1246: the first scan offers the installed styles an embedded family lacks (release builds embed
|
||||
/// Poppins Regular, Italic and Bold; an installed SemiBold and Medium went unseen). Embedded styles win.
|
||||
#[test]
|
||||
fn the_scan_offers_installed_styles_an_embedded_family_lacks() {
|
||||
// Any embedded or installed font stands in for the embedded face (none without craft-fonts on CI).
|
||||
let stand_in = CRAFT_FONTS.first().map(|f| f.bytes.to_vec()).or_else(|| {
|
||||
let mut installed = BTreeMap::new();
|
||||
for dir in system_font_dirs() {
|
||||
scan_dir(&dir, 0, &mut installed);
|
||||
}
|
||||
installed.values().flat_map(|(_, v)| v).find_map(|p| std::fs::read(&p.path).ok())
|
||||
});
|
||||
let Some(mut face) = stand_in.and_then(|b| Face::from_bytes(FontBytes::Shared(Arc::new(b)), 0, Some("Brand Sans"), Some("Regular"))) else {
|
||||
eprintln!("skipping: no font to stand in for an embedded one");
|
||||
return;
|
||||
};
|
||||
// A variable stand-in's named instances aren't this family's styles.
|
||||
face.instances.clear();
|
||||
let mut db = FontDb::default();
|
||||
db.add(face);
|
||||
let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into(), postscript: None };
|
||||
let found =
|
||||
|family: &str, styles: &[&str]| BTreeMap::from([(family.to_lowercase(), (family.to_string(), styles.iter().map(|s| entry(s)).collect()))]);
|
||||
db.merge_scan(found("Brand Sans", &["Regular"]));
|
||||
assert!(!db.pending.contains_key("brand sans"), "an embedded style isn't read from disk again");
|
||||
db.merge_scan(found("Brand Sans", &["Regular", "SemiBold", "Medium"]));
|
||||
let waiting: Vec<String> = db.pending.get("brand sans").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect()).unwrap_or_default();
|
||||
assert_eq!(waiting, ["SemiBold", "Medium"]);
|
||||
assert_eq!(db.families(), ["Brand Sans"]);
|
||||
db.merge_scan(found("CJK UI", &["Regular", "Bold"]));
|
||||
assert_eq!(left(&db), ["Regular", "Bold"]);
|
||||
}
|
||||
|
||||
/// Font file bytes add their faces once; junk adds nothing.
|
||||
#[test]
|
||||
fn font_bytes_load_into_the_db() {
|
||||
@@ -306,6 +340,95 @@ fn font_bytes_load_into_the_db() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apple's system font answers to the names designs use for it (SF Pro, SF UI Text, …), unless a
|
||||
/// font of that name is installed.
|
||||
#[test]
|
||||
fn apple_system_font_names_find_the_system_font() {
|
||||
let mut db = FontDb::default();
|
||||
db.pending.insert(
|
||||
"system font".into(),
|
||||
("System Font".into(), vec![Pending { path: "no-such-dir/SFNS.ttf".into(), index: 0, style: "Regular".into(), postscript: None }]),
|
||||
);
|
||||
for name in ["SF Pro", "SF Pro Text", "SF Pro Display", "SF UI Text", "SF UI Display", ".SF NS Text", "San Francisco"] {
|
||||
assert!(db.has_family(name), "{name}");
|
||||
}
|
||||
assert!(!db.has_family("SF Mono"));
|
||||
db.pending.insert("sf pro".into(), ("SF Pro".into(), Vec::new()));
|
||||
assert_eq!(db.key("SF Pro"), "sf pro", "an installed SF Pro is itself");
|
||||
assert_eq!(db.key("SF UI Text"), "system font");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_apple_families_take_public_names() {
|
||||
assert_eq!(public_family(".SF Compact").as_deref(), Some("SF Compact"));
|
||||
assert_eq!(public_family(".SF Compact Rounded").as_deref(), Some("SF Compact Rounded"));
|
||||
assert_eq!(public_family(".SF NS Rounded").as_deref(), Some("SF Pro Rounded"));
|
||||
assert_eq!(public_family(".Keyboard"), None);
|
||||
assert_eq!(public_family("Inter").as_deref(), Some("Inter"));
|
||||
}
|
||||
|
||||
/// A named style keeps its own optical size whatever the text size (existing documents render as
|
||||
/// before); a custom `opsz` value (what a `.sketch` import sets) wins. Needs a variable font with an
|
||||
/// optical size axis and named instances: macOS's or Windows' system font.
|
||||
#[test]
|
||||
fn named_styles_keep_their_optical_size() {
|
||||
let Some(bytes) = ["/System/Library/Fonts/SFNS.ttf", "C:/Windows/Fonts/SegUIVar.ttf"].iter().find_map(|p| std::fs::read(p).ok()) else {
|
||||
return;
|
||||
};
|
||||
let face = Face::from_bytes(FontBytes::Shared(std::sync::Arc::new(bytes)), 0, None, None).unwrap();
|
||||
let opsz = face.axes.iter().position(|a| a.tag == "opsz").unwrap();
|
||||
let inst = face.instances.first().unwrap();
|
||||
let mut style = figcraft_doc::TextStyle { font_style: inst.name.clone(), ..Default::default() };
|
||||
for size in [1.0, 12.0, 40.0, 200.0] {
|
||||
style.font_size = size;
|
||||
assert_eq!(face.axis_values(&style)[opsz], inst.coords[opsz], "{} at {size}", inst.name);
|
||||
}
|
||||
style.variations.insert("opsz".into(), 1e9);
|
||||
assert_eq!(face.axis_values(&style)[opsz], face.axes[opsz].max);
|
||||
}
|
||||
|
||||
/// The tracking a font's `trak` table gives at a size (Apple's fonts carry one; the platform text
|
||||
/// engine applies it on its own), in pixels, interpolated between the sizes the table lists.
|
||||
#[test]
|
||||
fn trak_tracking_by_size() {
|
||||
let Ok(bytes) = std::fs::read("/System/Library/Fonts/SFNS.ttf") else { return };
|
||||
let face = Face::from_bytes(FontBytes::Shared(std::sync::Arc::new(bytes)), 0, None, None).unwrap();
|
||||
// Track 0 of SF: −12 units at 13 pt, 0 at 12 pt, −40 at 16 pt (2048 units per em).
|
||||
assert!((face.tracking(13.0) - (-12.0 * 13.0 / 2048.0)).abs() < 1e-9, "{}", face.tracking(13.0));
|
||||
assert!(face.tracking(12.0).abs() < 1e-9);
|
||||
assert!((face.tracking(12.5) - (-6.0 * 12.5 / 2048.0)).abs() < 1e-9);
|
||||
// Past the table's ends: its last values.
|
||||
assert!(face.tracking(500.0).is_finite());
|
||||
let plain = crate::fontdb::db().write().unwrap().default_face();
|
||||
if let Some(f) = plain.filter(|f| f.family != "System Font") {
|
||||
let _ = f.tracking(12.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A face is found by its PostScript name (ignoring case), loaded or only scanned: the family and
|
||||
/// style it is offered under.
|
||||
#[test]
|
||||
fn faces_by_postscript_name() {
|
||||
let mut db = FontDb::default();
|
||||
db.pending.insert(
|
||||
"brand sans".into(),
|
||||
(
|
||||
"Brand Sans".into(),
|
||||
vec![Pending { path: "no-such-dir/b.ttf".into(), index: 0, style: "Bold".into(), postscript: Some("BrandSans-Bold".into()) }],
|
||||
),
|
||||
);
|
||||
assert_eq!(db.postscript_face("brandsans-bold"), Some(("Brand Sans".to_string(), "Bold".to_string())));
|
||||
assert_eq!(db.postscript_face("BrandSans-Light"), None);
|
||||
if let Some(f) = CRAFT_FONTS.first()
|
||||
&& let Some(face) = Face::from_bytes(FontBytes::Static(f.bytes), 0, Some(f.family), Some(f.style))
|
||||
&& let Some(ps) = face.postscript.clone()
|
||||
{
|
||||
db.add(face);
|
||||
assert_eq!(db.postscript_face(&ps), Some((f.family.to_string(), f.style.to_string())));
|
||||
}
|
||||
assert!(postscript_face("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_platform_has_a_thai_font_in_the_fallbacks() {
|
||||
// Thai typed into a Latin layer (new text starts in Inter) is drawn by the first fallback that
|
||||
|
||||
@@ -17,8 +17,8 @@ pub use breaks::{backspace_step, grapheme_step};
|
||||
pub use color::{ColorBitmap, ColorInk, ColorLayer};
|
||||
pub use fontdb::{
|
||||
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face,
|
||||
has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand,
|
||||
system_fonts_scanned,
|
||||
has_family, installed_fonts_known, load_font_bytes, no_coords, opt_out_of_system_fonts, rescan_system_fonts, scan_system_fonts,
|
||||
scan_system_fonts_on_demand, system_fonts_scanned,
|
||||
};
|
||||
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
|
||||
pub use onpath::OnPath;
|
||||
|
||||
@@ -37,6 +37,7 @@ status = "draft — needs native review"
|
||||
"Add a description" = "একটি বিবরণ যোগ করুন"
|
||||
"Add a frame (F) or a shape (R) to get started" = "শুরু করতে একটি ফ্রেম (F) বা শেপ (R) যোগ করুন"
|
||||
"Add a layer or component to tile first" = "প্রথমে টাইল করার জন্য একটি লেয়ার বা কম্পোনেন্ট যোগ করুন"
|
||||
"Add a minute" = "এক মিনিট যোগ করুন"
|
||||
"Add a note for developers" = "ডেভেলপারদের জন্য একটি নোট যোগ করুন"
|
||||
"Add action" = "অ্যাকশন যোগ করুন"
|
||||
"Add actions for when no condition holds" = "কোনো শর্ত পূর্ণ না হলে তার জন্য অ্যাকশন যোগ করুন"
|
||||
@@ -94,6 +95,7 @@ status = "draft — needs native review"
|
||||
"All comments" = "সব মন্তব্য"
|
||||
"All components" = "সব কম্পোনেন্ট"
|
||||
"All fonts" = "সব ফন্ট"
|
||||
"All objects" = "সব অবজেক্ট"
|
||||
"All supported properties" = "সমর্থিত সব প্রপার্টি"
|
||||
"All the fonts this file uses are installed" = "এই ফাইলে ব্যবহৃত সব ফন্ট ইনস্টল করা আছে"
|
||||
"All variables" = "সব ভেরিয়েবল"
|
||||
@@ -214,6 +216,8 @@ status = "draft — needs native review"
|
||||
"Checking the server…" = "সার্ভার চেক করা হচ্ছে..."
|
||||
"Chevron" = "শেভরন"
|
||||
"Clear from this list" = "এই তালিকা থেকে মুছে ফেলুন"
|
||||
"Clear results" = "ফলাফল মুছুন"
|
||||
"Click objects to vote; click your badge to take a vote back" = "ভোট দিতে অবজেক্টে ক্লিক করুন; ভোট ফেরাতে আপনার ব্যাজে ক্লিক করুন"
|
||||
"Code or invite link" = "কোড বা আমন্ত্রণ লিংক"
|
||||
"Color profile: {name}" = "কালার প্রোফাইল: {name}"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "রঙ ও টেক্সট স্টাইল, লাইট এবং ডার্ক ভেরিয়েবল, বোতাম, ইনপুট এবং কার্ড"
|
||||
@@ -241,7 +245,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "প্রধান (main)-এ পরিবর্তন"
|
||||
"Changes on the branch" = "ব্রাঞ্চে পরিবর্তন"
|
||||
"Changes unknown" = "পরিবর্তন অজ্ঞাত"
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, SwiftUI বা Compose বেছে নিন।"
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, SwiftUI, Compose বা ArkUI বেছে নিন।"
|
||||
"Choose another profile to change it." = "পরিবর্তন করতে অন্য একটি প্রোফাইল বেছে নিন।"
|
||||
"Choose collection" = "সংগ্রহ (collection) বেছে নিন"
|
||||
"Choose image" = "ছবি বেছে নিন"
|
||||
@@ -332,6 +336,7 @@ status = "draft — needs native review"
|
||||
"Couldn't download the variables" = "ভেরিয়েবলগুলো ডাউনলোড করা যায়নি"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server}-এ পৌঁছানো যায়নি: এই কম্পিউটার যা মনে রেখেছে তা দেখানো হচ্ছে।"
|
||||
"Couldn't save automatically: {error}" = "স্বয়ংক্রিয়ভাবে সেভ করা যায়নি: {error}"
|
||||
"Couldn't save the file" = "ফাইলটি সেভ করা যায়নি"
|
||||
"Count" = "গণনা"
|
||||
"Create" = "তৈরি করুন"
|
||||
"Create a variable collection first" = "প্রথমে একটি ভেরিয়েবল সংগ্রহ তৈরি করুন"
|
||||
@@ -429,7 +434,7 @@ status = "draft — needs native review"
|
||||
"Draw" = "আঁকুন"
|
||||
"Draw a frame" = "একটি ফ্রেম আঁকুন"
|
||||
"Drawing tools" = "অঙ্কন টুলস"
|
||||
"Drop a .figcraft, .fig, .json, .svg or image file." = "একটি .figcraft, .fig, .json, .svg বা ছবির ফাইল ড্রপ করুন।"
|
||||
"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "একটি .figcraft, .fig, .sketch, .json, .svg বা ছবির ফাইল ড্রপ করুন।"
|
||||
"Drop to import" = "ইম্পোর্ট করতে ড্রপ করুন"
|
||||
"Drop to open" = "খুলতে ড্রপ করুন"
|
||||
"Drop shadow" = "ড্রপ শ্যাডো"
|
||||
@@ -453,6 +458,7 @@ status = "draft — needs native review"
|
||||
"Effect settings" = "ইফেক্ট সেটিংস"
|
||||
"Effect styles" = "ইফেক্ট স্টাইল"
|
||||
"Effects" = "ইফেক্টসমূহ"
|
||||
"Curved" = "বাঁকা"
|
||||
"Elbow" = "কোণযুক্ত"
|
||||
"Ellipse" = "উপবৃত্ত"
|
||||
"Ellipses" = "উপবৃত্তসমূহ"
|
||||
@@ -462,6 +468,7 @@ status = "draft — needs native review"
|
||||
"Empty" = "খালি"
|
||||
"End" = "শেষ"
|
||||
"End point" = "শেষ বিন্দু"
|
||||
"End vote" = "ভোট শেষ করুন"
|
||||
"Ends the session; everyone keeps their copy" = "সেশন শেষ করে; সবাই তাদের কপি নিজের কাছে রাখে"
|
||||
"Enter" = "এন্টার"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "একটি figcraft-সার্ভারের ঠিকানা দিন এবং এটি যে প্লাগইনগুলো অফার করে তা দেখতে ব্রাউজ টিপুন।"
|
||||
@@ -793,6 +800,7 @@ status = "draft — needs native review"
|
||||
"Min H" = "সর্বনিম্ন উচ্চতা"
|
||||
"Min W" = "সর্বনিম্ন প্রস্থ"
|
||||
"Minimize" = "ছোট করুন"
|
||||
"Minutes" = "মিনিট"
|
||||
"Mirror angle" = "মিরর কোণ"
|
||||
"Mirror angle and length" = "মিরর কোণ ও দৈর্ঘ্য"
|
||||
"Mirroring" = "মিররিং"
|
||||
@@ -890,6 +898,7 @@ status = "draft — needs native review"
|
||||
"No text styles yet. Click + to create one from the selected text." = "এখনো কোনো টেক্সট স্টাইল নেই। নির্বাচিত টেক্সট থেকে একটি তৈরি করতে + এ ক্লিক করুন।"
|
||||
"No variables fit here." = "এখানে কোনো ভেরিয়েবল মানানসই নয়।"
|
||||
"No variables yet. Create them in Local variables." = "এখনো কোনো ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।"
|
||||
"No votes yet" = "এখনও কোনো ভোট নেই"
|
||||
"Nobody else is here yet" = "এখানে আর কেউ নেই"
|
||||
"Noise" = "নয়েজ"
|
||||
"None" = "কিছুই না"
|
||||
@@ -976,6 +985,7 @@ status = "draft — needs native review"
|
||||
"Page" = "পেজ"
|
||||
"Pages" = "পেজসমূহ"
|
||||
"Pages added to the document" = "ডকুমেন্টে পেজ যোগ করা হয়েছে"
|
||||
"Pause" = "বিরতি"
|
||||
"Pink" = "গোলাপি"
|
||||
"Placed on the current page" = "বর্তমান পেজে রাখা হয়েছে"
|
||||
"Pages: {pages}" = "পেজসমূহ: {pages}"
|
||||
@@ -1160,6 +1170,8 @@ status = "draft — needs native review"
|
||||
"Restore this version" = "এই সংস্করণ পুনরুদ্ধার করুন"
|
||||
"Restored “{name}”" = "“{name}” পুনরুদ্ধার করা হয়েছে"
|
||||
"Retrospective" = "রেট্রোস্পেক্টিভ"
|
||||
"Results" = "ফলাফল"
|
||||
"Resume" = "আবার চালু করুন"
|
||||
"Reverse the order" = "ক্রম উল্টে দিন"
|
||||
"Reversed triangle" = "উল্টানো ত্রিভুজ"
|
||||
"Review and merge" = "পর্যালোচনা ও মার্জ করুন"
|
||||
@@ -1185,6 +1197,7 @@ status = "draft — needs native review"
|
||||
"Rulers" = "রুলার"
|
||||
"Run" = "চালান"
|
||||
"Run {label}" = "চালান {label}"
|
||||
"Save file" = "ফাইল সেভ করুন"
|
||||
"Save files automatically" = "স্বয়ংক্রিয়ভাবে ফাইল সেভ করুন"
|
||||
"Search templates" = "টেমপ্লেট খুঁজুন"
|
||||
"See all templates" = "সব টেমপ্লেট দেখুন"
|
||||
@@ -1337,17 +1350,20 @@ status = "draft — needs native review"
|
||||
"Start point" = "শুরুর বিন্দু"
|
||||
"Start sharing" = "শেয়ার করা শুরু করুন"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "শেয়ার বোতাম দিয়ে শেয়ার করা শুরু করুন, তারপর এটি যে কোড দেয় তা দিন"
|
||||
"Start vote" = "ভোট শুরু করুন"
|
||||
"Starting to share…" = "শেয়ার করা শুরু হচ্ছে..."
|
||||
"Status" = "স্ট্যাটাস"
|
||||
"Sticky" = "স্টিকি"
|
||||
"Sticky note" = "স্টিকি নোট"
|
||||
"Sticky note colors" = "স্টিকি নোটের রং"
|
||||
"Sticky notes" = "স্টিকি নোট"
|
||||
"Sticky notes, shapes and connectors" = "স্টিকি নোট, আকৃতি ও সংযোগকারী"
|
||||
"Stop" = "থামুন"
|
||||
"Stop following {name}" = "{name}-কে অনুসরণ করা বন্ধ করুন"
|
||||
"Stop session" = "সেশন বন্ধ করুন"
|
||||
"Stop sharing" = "শেয়ার করা বন্ধ করুন"
|
||||
"Stop spotlighting" = "স্পটলাইটিং বন্ধ করুন"
|
||||
"Stop timer" = "টাইমার থামান"
|
||||
"Stop {n} {hex}" = "স্টপ {n} {hex}"
|
||||
"Straight" = "সোজা"
|
||||
"Stretch" = "টেনে বড় করা (Stretch)"
|
||||
@@ -1433,6 +1449,7 @@ status = "draft — needs native review"
|
||||
"Tidy up" = "পরিপাটি করুন"
|
||||
"Tile" = "টাইল"
|
||||
"Tile type" = "টাইল টাইপ"
|
||||
"Timer" = "টাইমার"
|
||||
"Tint" = "টিন্ট"
|
||||
"Title" = "শিরোনাম"
|
||||
"Title case" = "টাইটেল কেস"
|
||||
@@ -1528,6 +1545,8 @@ status = "draft — needs native review"
|
||||
"Visibility" = "দৃশ্যমানতা"
|
||||
"Visible" = "দৃশ্যমান"
|
||||
"Vivid" = "ভিভিড"
|
||||
"Votes per person" = "জনপ্রতি ভোট"
|
||||
"Voting session" = "ভোটিং সেশন"
|
||||
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND সেট করা আছে এবং ব্যবহারের জন্য গ্রাফিক্স API-এর নাম দেয়; এটি আনসেট করলে FigCraft কে বেছে নিতে দেয়।"
|
||||
"Warm" = "উষ্ণ"
|
||||
"Watch" = "ওয়াচ"
|
||||
@@ -1588,6 +1607,7 @@ status = "draft — needs native review"
|
||||
"call any command its access allows" = "এর অ্যাক্সেস অনুমতি দেয় এমন যেকোনো কমান্ড কল করুন"
|
||||
"call only: {commands}" = "শুধুমাত্র কল করুন: {commands}"
|
||||
"can edit the document" = "ডকুমেন্ট এডিট করতে পারেন"
|
||||
"can save files" = "ফাইল সেভ করতে পারে"
|
||||
"conditional prototype actions nested too deeply were skipped" = "শর্তসাপেক্ষ প্রোটোটাইপ অ্যাকশনগুলো খুব গভীরভাবে নেস্ট করার কারণে বাদ দেওয়া হয়েছে"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "মূল্যায়ন করা যায় না এমন শর্তযুক্ত প্রোটোটাইপ অ্যাকশনগুলো বাদ দেওয়া হয়েছে"
|
||||
"connector label background was skipped: style data is missing" = "কানেক্টর লেবেলের ব্যাকগ্রাউন্ড বাদ দেওয়া হয়েছে: স্টাইল ডেটা অনুপস্থিত"
|
||||
@@ -1647,6 +1667,7 @@ status = "draft — needs native review"
|
||||
"nobody else here yet" = "এখানে আর কেউ নেই"
|
||||
"non-default slot settings are not represented and were skipped" = "অ-ডিফল্ট স্লট সেটিংস উপস্থাপিত হয়নি এবং বাদ দেওয়া হয়েছে"
|
||||
"now" = "এখন"
|
||||
"offer files to save if it asks to (you choose where each one goes)" = "চাইলে সেভ করার জন্য ফাইল দিতে (প্রতিটি কোথায় যাবে, আপনি বেছে নেন)"
|
||||
"only in a text style or variable" = "শুধুমাত্র টেক্সট স্টাইল বা ভেরিয়েবলে"
|
||||
"or" = "অথবা"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "পয়েন্ট স্ট্রোক জয়েন্ট সংরক্ষিত হয়েছে; ড্যাশড বা পরিবর্তনশীল প্রস্থের স্ট্রোক লেয়ার জয়েন্ট দিয়ে রেন্ডার হয়"
|
||||
@@ -1745,6 +1766,7 @@ status = "draft — needs native review"
|
||||
"{n} layers selected" = { one = "{n}টি লেয়ার নির্বাচিত", other = "{n}টি লেয়ার নির্বাচিত" }
|
||||
"{n} layers · {parts}" = { one = "{n}টি লেয়ার · {parts}", other = "{n}টি লেয়ার · {parts}" }
|
||||
"{n} matches" = { one = "{n}টি মিল", other = "{n}টি মিল" }
|
||||
"{n} min" = "{n} মিনিট"
|
||||
"{n} minutes ago" = { one = "{n} মিনিট আগে", other = "{n} মিনিট আগে" }
|
||||
"{n} months ago" = { one = "{n} মাস আগে", other = "{n} মাস আগে" }
|
||||
"{n} more notes omitted from this report" = { one = "এই রিপোর্ট থেকে আরও {n}টি নোট বাদ দেওয়া হয়েছে", other = "এই রিপোর্ট থেকে আরও {n}টি নোট বাদ দেওয়া হয়েছে" }
|
||||
@@ -1758,6 +1780,8 @@ status = "draft — needs native review"
|
||||
"{n} styles" = { one = "{n}টি স্টাইল", other = "{n}টি স্টাইল" }
|
||||
"{n} text layers" = { one = "{n}টি টেক্সট লেয়ার", other = "{n}টি টেক্সট লেয়ার" }
|
||||
"{n} variables" = { one = "{n}টি ভেরিয়েবল", other = "{n}টি ভেরিয়েবল" }
|
||||
"{n} votes" = { one = "{n}টি ভোট", other = "{n}টি ভোট" }
|
||||
"{n} votes left" = { one = "{n}টি ভোট বাকি", other = "{n}টি ভোট বাকি" }
|
||||
"{n} weeks ago" = { one = "{n} সপ্তাহ আগে", other = "{n} সপ্তাহ আগে" }
|
||||
"{n} years ago" = { one = "{n} বছর আগে", other = "{n} বছর আগে" }
|
||||
"{n}d" = "{n}d"
|
||||
@@ -1790,6 +1814,7 @@ status = "draft — needs native review"
|
||||
"board.ink" = "মার্কারের টান"
|
||||
"board.label" = "সংযোগকারীর লেবেল"
|
||||
"board.nextSticky" = "পরের স্টিকি নোট"
|
||||
"board.reattach" = "সংযোগকারী পুনরায় যুক্ত করুন"
|
||||
"board.setColor" = "বোর্ডের রং"
|
||||
"board.setStyle" = "বোর্ডের শৈলী"
|
||||
"board.shape" = "লেখাসহ আকৃতি"
|
||||
@@ -1799,6 +1824,10 @@ status = "draft — needs native review"
|
||||
"board.tableEdit" = "টেবিল সম্পাদনা"
|
||||
"board.template" = "বোর্ড টেমপ্লেট"
|
||||
"board.templates" = "বোর্ড টেমপ্লেটের তালিকা"
|
||||
"board.timer" = "টাইমার"
|
||||
"board.timerState" = "টাইমারের অবস্থা"
|
||||
"board.vote" = "ভোটিং সেশন"
|
||||
"board.voteResults" = "ভোটের ফলাফল"
|
||||
"devMode.addMeasurement" = "মাপ যোগ করুন"
|
||||
"devMode.measurements" = "মাপের তালিকা"
|
||||
"devMode.removeMeasurement" = "মাপ সরান"
|
||||
|
||||
@@ -33,6 +33,7 @@ status = "draft — needs native review"
|
||||
"Add a description" = "Přidat popis"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Začněte přidáním rámce (F) nebo tvaru (R)"
|
||||
"Add a layer or component to tile first" = "Nejprve přidejte vrstvu nebo komponentu k opakování"
|
||||
"Add a minute" = "Přidat minutu"
|
||||
"Add a note for developers" = "Přidat poznámku pro vývojáře"
|
||||
"Add action" = "Přidat akci"
|
||||
"Add actions for when no condition holds" = "Přidat akce pro případ, kdy neplatí žádná podmínka"
|
||||
@@ -89,6 +90,7 @@ status = "draft — needs native review"
|
||||
"All comments" = "Všechny komentáře"
|
||||
"All components" = "Všechny komponenty"
|
||||
"All fonts" = "Všechna písma"
|
||||
"All objects" = "Všechny objekty"
|
||||
"All supported properties" = "Všechny podporované vlastnosti"
|
||||
"All variables" = "Všechny proměnné"
|
||||
"Along the bottom" = "Podél dolní hrany"
|
||||
@@ -207,6 +209,8 @@ status = "draft — needs native review"
|
||||
"Checking the server…" = "Kontrola serveru…"
|
||||
"Chevron" = "Lomená šipka"
|
||||
"Clear from this list" = "Vymazat z tohoto seznamu"
|
||||
"Clear results" = "Vymazat výsledky"
|
||||
"Click objects to vote; click your badge to take a vote back" = "Kliknutím na objekty hlasujete; kliknutím na svůj odznak hlas vrátíte"
|
||||
"Code or invite link" = "Kód nebo odkaz s pozvánkou"
|
||||
"Color profile: {name}" = "Barevný profil: {name}"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Barevné a textové styly, proměnné pro světlý a tmavý režim, tlačítka, vstupní pole a karty"
|
||||
@@ -231,7 +235,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "Změny v hlavním souboru"
|
||||
"Changes on the branch" = "Změny ve větvi"
|
||||
"Changes unknown" = "Změny nejsou známy"
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Pro propojení této komponenty s jejím kódem vyberte nahoře React, Leptos, SwiftUI nebo Compose."
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Pro propojení této komponenty s jejím kódem vyberte nahoře React, Leptos, SwiftUI, Compose nebo ArkUI."
|
||||
"Choose another profile to change it." = "Pro změnu vyberte jiný profil."
|
||||
"Choose collection" = "Vybrat kolekci"
|
||||
"Choose image" = "Vybrat obrázek"
|
||||
@@ -322,6 +326,7 @@ status = "draft — needs native review"
|
||||
"Corner smoothing" = "Vyhlazení rohů"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Server {server} není dostupný: zobrazuje se stav uložený v tomto počítači."
|
||||
"Couldn't read {name}." = "Soubor {name} se nepodařilo přečíst."
|
||||
"Couldn't save the file" = "Soubor se nepodařilo uložit"
|
||||
"Count" = "Počet"
|
||||
"Create" = "Vytvořit"
|
||||
"Create a variable collection first" = "Nejprve vytvořte kolekci proměnných"
|
||||
@@ -440,6 +445,7 @@ status = "draft — needs native review"
|
||||
"Effect settings" = "Nastavení efektu"
|
||||
"Effect styles" = "Styly efektů"
|
||||
"Effects" = "Efekty"
|
||||
"Curved" = "Zakřivená"
|
||||
"Elbow" = "Lomená"
|
||||
"Ellipse" = "Elipsa"
|
||||
"Ellipses" = "Elipsy"
|
||||
@@ -449,6 +455,7 @@ status = "draft — needs native review"
|
||||
"Empty" = "Prázdné"
|
||||
"End" = "Konec"
|
||||
"End point" = "Koncový bod"
|
||||
"End vote" = "Ukončit hlasování"
|
||||
"Ends the session; everyone keeps their copy" = "Ukončí relaci; každý si ponechá svou kopii"
|
||||
"Enter" = "Enter"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Zadejte adresu serveru figcraft-server a stisknutím Procházet zobrazte nabízené pluginy."
|
||||
@@ -778,6 +785,7 @@ status = "draft — needs native review"
|
||||
"Min H" = "Min. V"
|
||||
"Min W" = "Min. Š"
|
||||
"Minimize" = "Minimalizovat"
|
||||
"Minutes" = "Minuty"
|
||||
"Mirror angle" = "Zrcadlit úhel"
|
||||
"Mirror angle and length" = "Zrcadlit úhel a délku"
|
||||
"Mirroring" = "Zrcadlení"
|
||||
@@ -872,6 +880,7 @@ status = "draft — needs native review"
|
||||
"No text styles yet. Click + to create one from the selected text." = "Zatím žádné textové styly. Kliknutím na + vytvoříte styl z vybraného textu."
|
||||
"No variables fit here." = "Žádné vhodné proměnné."
|
||||
"No variables yet. Create them in Local variables." = "Zatím žádné proměnné. Vytvořte je v Místních proměnných."
|
||||
"No votes yet" = "Zatím žádné hlasy"
|
||||
"Nobody else is here yet" = "Zatím tu není nikdo další"
|
||||
"Noise" = "Šum"
|
||||
"None" = "Žádné"
|
||||
@@ -957,6 +966,7 @@ status = "draft — needs native review"
|
||||
"Page deleted" = "Stránka smazána"
|
||||
"Pages" = "Stránky"
|
||||
"Pages added to the document" = "Stránky přidány do dokumentu"
|
||||
"Pause" = "Pozastavit"
|
||||
"Pink" = "Růžová"
|
||||
"Placed on the current page" = "Umístěno na aktuální stránku"
|
||||
"Pages: {pages}" = "Stránky: {pages}"
|
||||
@@ -1138,6 +1148,8 @@ status = "draft — needs native review"
|
||||
"Restore this version" = "Obnovit tuto verzi"
|
||||
"Restored “{name}”" = "Obnoveno: „{name}“"
|
||||
"Retrospective" = "Retrospektiva"
|
||||
"Results" = "Výsledky"
|
||||
"Resume" = "Pokračovat"
|
||||
"Reverse the order" = "Obrátit pořadí"
|
||||
"Reversed triangle" = "Obrácený trojúhelník"
|
||||
"Review and merge" = "Zkontrolovat a sloučit"
|
||||
@@ -1163,6 +1175,7 @@ status = "draft — needs native review"
|
||||
"Rulers" = "Pravítka"
|
||||
"Run" = "Spustit"
|
||||
"Run {label}" = "Spustit {label}"
|
||||
"Save file" = "Uložit soubor"
|
||||
"Search templates" = "Hledat šablony"
|
||||
"See all templates" = "Zobrazit všechny šablony"
|
||||
"Select layers and they space themselves as their content changes" = "Vyberte vrstvy a jejich rozestupy se přizpůsobí změnám obsahu"
|
||||
@@ -1312,17 +1325,20 @@ status = "draft — needs native review"
|
||||
"Start point" = "Počáteční bod"
|
||||
"Start sharing" = "Zahájit sdílení"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "Zahajte sdílení tlačítkem Sdílet a předejte získaný kód"
|
||||
"Start vote" = "Zahájit hlasování"
|
||||
"Starting to share…" = "Zahajování sdílení…"
|
||||
"Status" = "Stav"
|
||||
"Sticky" = "Přichycené"
|
||||
"Sticky note" = "Lepicí poznámka"
|
||||
"Sticky note colors" = "Barvy lepicích poznámek"
|
||||
"Sticky notes" = "Lepicí poznámky"
|
||||
"Sticky notes, shapes and connectors" = "Lepicí poznámky, tvary a spojnice"
|
||||
"Stop" = "Zastavit"
|
||||
"Stop following {name}" = "Přestat sledovat: {name}"
|
||||
"Stop session" = "Ukončit relaci"
|
||||
"Stop sharing" = "Zastavit sdílení"
|
||||
"Stop spotlighting" = "Ukončit sdílení pohledu"
|
||||
"Stop timer" = "Zastavit časovač"
|
||||
"Stop {n} {hex}" = "Zarážka {n} {hex}"
|
||||
"Straight" = "Rovná"
|
||||
"Stretch" = "Roztáhnout"
|
||||
@@ -1404,6 +1420,7 @@ status = "draft — needs native review"
|
||||
"Tidy up" = "Uspořádat"
|
||||
"Tile" = "Dlaždice"
|
||||
"Tile type" = "Typ dlaždic"
|
||||
"Timer" = "Časovač"
|
||||
"Tint" = "Tónování"
|
||||
"Title" = "Název"
|
||||
"Title case" = "Velká počáteční písmena slov"
|
||||
@@ -1496,6 +1513,8 @@ status = "draft — needs native review"
|
||||
"Visibility" = "Viditelnost"
|
||||
"Visible" = "Viditelné"
|
||||
"Vivid" = "Živé"
|
||||
"Votes per person" = "Hlasů na osobu"
|
||||
"Voting session" = "Hlasování"
|
||||
"Warm" = "Teplé"
|
||||
"Watch" = "Hodinky"
|
||||
"Wavy" = "Vlnitý"
|
||||
@@ -1554,6 +1573,7 @@ status = "draft — needs native review"
|
||||
"call any command its access allows" = "volat libovolný příkaz povolený jeho oprávněními"
|
||||
"call only: {commands}" = "volat pouze: {commands}"
|
||||
"can edit the document" = "může upravovat dokument"
|
||||
"can save files" = "může ukládat soubory"
|
||||
"conditional prototype actions nested too deeply were skipped" = "podmíněné akce prototypu vnořené příliš hluboko byly přeskočeny"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "podmíněné akce prototypu s podmínkami, které nelze vyhodnotit, byly přeskočeny"
|
||||
"connector label background was skipped: style data is missing" = "pozadí popisku spojnice bylo přeskočeno: chybí data stylu"
|
||||
@@ -1613,6 +1633,7 @@ status = "draft — needs native review"
|
||||
"nobody else here yet" = "zatím tu není nikdo další"
|
||||
"non-default slot settings are not represented and were skipped" = "jiná než výchozí nastavení slotů nelze znázornit a byla přeskočena"
|
||||
"now" = "nyní"
|
||||
"offer files to save if it asks to (you choose where each one goes)" = "nabízet soubory k uložení, pokud o to požádá (kam který půjde, vyberete vy)"
|
||||
"or" = "nebo"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "spojení tahu v bodech byla zachována; přerušované tahy a tahy s proměnnou šířkou se kreslí se spojením vrstvy"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "klíče preferovaných komponent slotů nelze přiřadit k vrstvám souboru, byly přeskočeny"
|
||||
@@ -1710,6 +1731,7 @@ status = "draft — needs native review"
|
||||
"{n} layers selected" = { one = "Vybrána {n} vrstva", few = "Vybrány {n} vrstvy", other = "Vybráno {n} vrstev" }
|
||||
"{n} layers · {parts}" = { one = "{n} vrstva · {parts}", few = "{n} vrstvy · {parts}", other = "{n} vrstev · {parts}" }
|
||||
"{n} matches" = { one = "{n} shoda", few = "{n} shody", other = "{n} shod" }
|
||||
"{n} min" = "{n} min"
|
||||
"{n} minutes ago" = { one = "Před {n} minutou", few = "Před {n} minutami", other = "Před {n} minutami" }
|
||||
"{n} months ago" = { one = "Před {n} měsícem", few = "Před {n} měsíci", other = "Před {n} měsíci" }
|
||||
"{n} more notes omitted from this report" = { one = "{n} další poznámka v této zprávě vynechána", few = "{n} další poznámky v této zprávě vynechány", other = "{n} dalších poznámek v této zprávě vynecháno" }
|
||||
@@ -1722,6 +1744,8 @@ status = "draft — needs native review"
|
||||
"{n} rows" = { one = "{n} řádek", few = "{n} řádky", other = "{n} řádků" }
|
||||
"{n} styles" = { one = "{n} styl", few = "{n} styly", other = "{n} stylů" }
|
||||
"{n} variables" = { one = "{n} proměnná", few = "{n} proměnné", other = "{n} proměnných" }
|
||||
"{n} votes" = { one = "{n} hlas", few = "{n} hlasy", other = "{n} hlasů" }
|
||||
"{n} votes left" = { one = "Zbývá {n} hlas", few = "Zbývají {n} hlasy", other = "Zbývá {n} hlasů" }
|
||||
"{n} weeks ago" = { one = "Před {n} týdnem", few = "Před {n} týdny", other = "Před {n} týdny" }
|
||||
"{n} years ago" = { one = "Před {n} rokem", few = "Před {n} lety", other = "Před {n} lety" }
|
||||
"{n}d" = "{n} d"
|
||||
@@ -1778,6 +1802,7 @@ status = "draft — needs native review"
|
||||
"board.ink" = "Tah fixem"
|
||||
"board.label" = "Popisek spojnice"
|
||||
"board.nextSticky" = "Další lepicí poznámka"
|
||||
"board.reattach" = "Připojit spojnici jinam"
|
||||
"board.setColor" = "Barva na tabuli"
|
||||
"board.setStyle" = "Styl na tabuli"
|
||||
"board.shape" = "Tvar s textem"
|
||||
@@ -1787,6 +1812,10 @@ status = "draft — needs native review"
|
||||
"board.tableEdit" = "Upravit tabulku"
|
||||
"board.template" = "Šablona tabule"
|
||||
"board.templates" = "Seznam šablon tabule"
|
||||
"board.timer" = "Časovač"
|
||||
"board.timerState" = "Stav časovače"
|
||||
"board.vote" = "Hlasování"
|
||||
"board.voteResults" = "Výsledky hlasování"
|
||||
"bool.exclude" = "Vyloučit výběr"
|
||||
"bool.flatten" = "Sloučit booleovskou skupinu do vektoru"
|
||||
"bool.intersect" = "Průnik výběru"
|
||||
|
||||
@@ -34,6 +34,7 @@ status = "draft — needs native review"
|
||||
"Add a description" = "Beschreibung hinzufügen"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Fügen Sie zum Start einen Rahmen (F) oder eine Form (R) hinzu"
|
||||
"Add a layer or component to tile first" = "Fügen Sie zuerst eine Ebene oder Komponente zum Kacheln hinzu"
|
||||
"Add a minute" = "Eine Minute hinzufügen"
|
||||
"Add a note for developers" = "Notiz für Entwickler hinzufügen"
|
||||
"Add action" = "Aktion hinzufügen"
|
||||
"Add actions for when no condition holds" = "Aktionen für den Fall hinzufügen, dass keine Bedingung zutrifft"
|
||||
@@ -91,6 +92,7 @@ status = "draft — needs native review"
|
||||
"All comments" = "Alle Kommentare"
|
||||
"All components" = "Alle Komponenten"
|
||||
"All fonts" = "Alle Schriften"
|
||||
"All objects" = "Alle Objekte"
|
||||
"All supported properties" = "Alle unterstützten Eigenschaften"
|
||||
"All variables" = "Alle Variablen"
|
||||
"Along the bottom" = "Entlang der Unterkante"
|
||||
@@ -203,6 +205,8 @@ status = "draft — needs native review"
|
||||
"Checking the server…" = "Server wird geprüft…"
|
||||
"Chevron" = "Chevron"
|
||||
"Clear from this list" = "Aus dieser Liste entfernen"
|
||||
"Clear results" = "Ergebnisse löschen"
|
||||
"Click objects to vote; click your badge to take a vote back" = "Klicken Sie auf Objekte, um abzustimmen; klicken Sie auf Ihr Abzeichen, um eine Stimme zurückzunehmen"
|
||||
"Code or invite link" = "Code oder Einladungslink"
|
||||
"Color profile: {name}" = "Farbprofil: {name}"
|
||||
"CSS custom properties…" = "Benutzerdefinierte CSS-Eigenschaften…"
|
||||
@@ -226,7 +230,7 @@ status = "draft — needs native review"
|
||||
"Changes on main" = "Änderungen in der Hauptdatei"
|
||||
"Changes on the branch" = "Änderungen im Branch"
|
||||
"Changes unknown" = "Änderungen unbekannt"
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Wählen Sie oben React, Leptos, SwiftUI oder Compose, um diese Komponente mit ihrem Code zu verbinden."
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Wählen Sie oben React, Leptos, SwiftUI, Compose oder ArkUI, um diese Komponente mit ihrem Code zu verbinden."
|
||||
"Choose another profile to change it." = "Wählen Sie ein anderes Profil, um es zu ändern."
|
||||
"Choose collection" = "Sammlung wählen"
|
||||
"Choose image" = "Bild wählen"
|
||||
@@ -320,6 +324,7 @@ status = "draft — needs native review"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat."
|
||||
"Couldn't read {name}." = "{name} konnte nicht gelesen werden."
|
||||
"Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}"
|
||||
"Couldn't save the file" = "Die Datei konnte nicht gespeichert werden"
|
||||
"Count" = "Anzahl"
|
||||
"Create" = "Erstellen"
|
||||
"Create a variable collection first" = "Erstellen Sie zuerst eine Variablensammlung"
|
||||
@@ -412,7 +417,7 @@ status = "draft — needs native review"
|
||||
"Drag the stroke to add a width point" = "Ziehen Sie an der Kontur, um einen Breitenpunkt hinzuzufügen"
|
||||
"Draw" = "Zeichnen"
|
||||
"Drawing tools" = "Zeichenwerkzeuge"
|
||||
"Drop a .figcraft, .fig, .json, .svg or image file." = "Legen Sie eine .figcraft-, .fig-, .json-, .svg- oder Bilddatei ab."
|
||||
"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Legen Sie eine .figcraft-, .fig-, .sketch-, .json-, .svg- oder Bilddatei ab."
|
||||
"Drop to import" = "Zum Importieren ablegen"
|
||||
"Drop to open" = "Zum Öffnen ablegen"
|
||||
"Drop shadow" = "Schlagschatten"
|
||||
@@ -435,6 +440,7 @@ status = "draft — needs native review"
|
||||
"Effect settings" = "Effekteinstellungen"
|
||||
"Effect styles" = "Effektstile"
|
||||
"Effects" = "Effekte"
|
||||
"Curved" = "Gebogen"
|
||||
"Elbow" = "Gewinkelt"
|
||||
"Ellipse" = "Ellipse"
|
||||
"Ellipses" = "Ellipsen"
|
||||
@@ -444,6 +450,7 @@ status = "draft — needs native review"
|
||||
"Empty" = "Leer"
|
||||
"End" = "Ende"
|
||||
"End point" = "Endpunkt"
|
||||
"End vote" = "Abstimmung beenden"
|
||||
"Ends the session; everyone keeps their copy" = "Beendet die Sitzung; alle behalten ihre Kopie"
|
||||
"Enter" = "Eingabe"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Geben Sie die Adresse eines figcraft-server ein und drücken Sie auf Durchsuchen, um die angebotenen Plugins zu sehen."
|
||||
@@ -755,6 +762,7 @@ status = "draft — needs native review"
|
||||
"Min H" = "Min. H"
|
||||
"Min W" = "Min. B"
|
||||
"Minimize" = "Minimieren"
|
||||
"Minutes" = "Minuten"
|
||||
"Mirror angle" = "Winkel spiegeln"
|
||||
"Mirror angle and length" = "Winkel und Länge spiegeln"
|
||||
"Mirroring" = "Spiegelung"
|
||||
@@ -846,6 +854,7 @@ status = "draft — needs native review"
|
||||
"No text styles yet. Click + to create one from the selected text." = "Noch keine Textstile. Klicken Sie auf +, um einen aus dem ausgewählten Text zu erstellen."
|
||||
"No variables fit here." = "Hier passen keine Variablen."
|
||||
"No variables yet. Create them in Local variables." = "Noch keine Variablen. Erstellen Sie sie unter Lokale Variablen."
|
||||
"No votes yet" = "Noch keine Stimmen"
|
||||
"Nobody else is here yet" = "Noch niemand sonst hier"
|
||||
"Noise" = "Rauschen"
|
||||
"None" = "Keine"
|
||||
@@ -929,6 +938,7 @@ status = "draft — needs native review"
|
||||
"Page deleted" = "Seite gelöscht"
|
||||
"Pages" = "Seiten"
|
||||
"Pages added to the document" = "Seiten zum Dokument hinzugefügt"
|
||||
"Pause" = "Pausieren"
|
||||
"Pink" = "Rosa"
|
||||
"Placed on the current page" = "Auf der aktuellen Seite platziert"
|
||||
"Pages: {pages}" = "Seiten: {pages}"
|
||||
@@ -1108,6 +1118,8 @@ status = "draft — needs native review"
|
||||
"Restore this version" = "Diese Version wiederherstellen"
|
||||
"Restored “{name}”" = "„{name}“ wiederhergestellt"
|
||||
"Retrospective" = "Retrospektive"
|
||||
"Results" = "Ergebnisse"
|
||||
"Resume" = "Fortsetzen"
|
||||
"Reverse the order" = "Reihenfolge umkehren"
|
||||
"Reversed triangle" = "Umgekehrtes Dreieck"
|
||||
"Review and merge" = "Prüfen und zusammenführen"
|
||||
@@ -1139,6 +1151,7 @@ status = "draft — needs native review"
|
||||
"Save" = "Speichern"
|
||||
"Save as" = "Speichern unter"
|
||||
"Save as preset…" = "Als Vorgabe speichern…"
|
||||
"Save file" = "Datei speichern"
|
||||
"Save files automatically" = "Dateien automatisch speichern"
|
||||
"Save version {key}" = "Version speichern {key}"
|
||||
"Saved" = "Gespeichert"
|
||||
@@ -1269,17 +1282,20 @@ status = "draft — needs native review"
|
||||
"Start on the Home screen" = "Mit Start beginnen"
|
||||
"Start point" = "Startpunkt"
|
||||
"Start sharing" = "Teilen starten"
|
||||
"Start vote" = "Abstimmung starten"
|
||||
"Starting to share…" = "Teilen wird gestartet…"
|
||||
"Status" = "Status"
|
||||
"Sticky" = "Fixiert"
|
||||
"Sticky note" = "Haftnotiz"
|
||||
"Sticky note colors" = "Haftnotizfarben"
|
||||
"Sticky notes" = "Haftnotizen"
|
||||
"Sticky notes, shapes and connectors" = "Haftnotizen, Formen und Verbinder"
|
||||
"Stop" = "Beenden"
|
||||
"Stop following {name}" = "{name} nicht mehr folgen"
|
||||
"Stop session" = "Sitzung beenden"
|
||||
"Stop sharing" = "Teilen beenden"
|
||||
"Stop spotlighting" = "Spotlight beenden"
|
||||
"Stop timer" = "Timer stoppen"
|
||||
"Stop {n} {hex}" = "Farbunterbrechung {n} {hex}"
|
||||
"Straight" = "Gerade"
|
||||
"Stretch" = "Strecken"
|
||||
@@ -1366,6 +1382,7 @@ status = "draft — needs native review"
|
||||
"Tidy up" = "Aufräumen"
|
||||
"Tile" = "Kachel"
|
||||
"Tile type" = "Kacheltyp"
|
||||
"Timer" = "Timer"
|
||||
"Tint" = "Tönung"
|
||||
"Title" = "Titel"
|
||||
"Title case" = "Großschreibung am Wortanfang"
|
||||
@@ -1457,6 +1474,8 @@ status = "draft — needs native review"
|
||||
"Visibility" = "Sichtbarkeit"
|
||||
"Visible" = "Sichtbar"
|
||||
"Vivid" = "Lebhaft"
|
||||
"Votes per person" = "Stimmen pro Person"
|
||||
"Voting session" = "Abstimmung"
|
||||
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND ist gesetzt und legt die Grafik-API fest; ohne diese Variable wählt FigCraft selbst."
|
||||
"Warm" = "Warm"
|
||||
"Watch" = "Smartwatch"
|
||||
@@ -1514,6 +1533,7 @@ status = "draft — needs native review"
|
||||
"call any command its access allows" = "jeden Befehl aufrufen, den sein Zugriff erlaubt"
|
||||
"call only: {commands}" = "nur aufrufen: {commands}"
|
||||
"can edit the document" = "kann das Dokument bearbeiten"
|
||||
"can save files" = "kann Dateien speichern"
|
||||
"conditional prototype actions nested too deeply were skipped" = "zu tief verschachtelte bedingte Prototyp-Aktionen wurden übersprungen"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "bedingte Prototyp-Aktionen mit nicht auswertbaren Bedingungen wurden übersprungen"
|
||||
"connector label background was skipped: style data is missing" = "Hintergrund der Verbinderbeschriftung wurde übersprungen: Stildaten fehlen"
|
||||
@@ -1572,6 +1592,7 @@ status = "draft — needs native review"
|
||||
"nobody else here yet" = "noch niemand sonst hier"
|
||||
"non-default slot settings are not represented and were skipped" = "vom Standard abweichende Slot-Einstellungen werden nicht abgebildet und wurden übersprungen"
|
||||
"now" = "jetzt"
|
||||
"offer files to save if it asks to (you choose where each one goes)" = "Dateien zum Speichern anbieten, falls es darum bittet (Sie wählen, wohin jede kommt)"
|
||||
"or" = "oder"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "die Konturecken der Punkte wurden übernommen; gestrichelte Konturen und Konturen mit variabler Breite werden mit der Ecke der Ebene gezeichnet"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "bevorzugte Komponentenschlüssel von Slots lassen sich keinen Ebenen der Datei zuordnen und wurden übersprungen"
|
||||
@@ -1667,6 +1688,7 @@ status = "draft — needs native review"
|
||||
"{n} layers selected" = { one = "{n} Ebene ausgewählt", other = "{n} Ebenen ausgewählt" }
|
||||
"{n} layers · {parts}" = { one = "{n} Ebene · {parts}", other = "{n} Ebenen · {parts}" }
|
||||
"{n} matches" = { one = "{n} Treffer", other = "{n} Treffer" }
|
||||
"{n} min" = "{n} Min."
|
||||
"{n} minutes ago" = { one = "vor {n} Minute", other = "vor {n} Minuten" }
|
||||
"{n} months ago" = { one = "vor {n} Monat", other = "vor {n} Monaten" }
|
||||
"{n} more notes omitted from this report" = { one = "{n} weitere Notiz in diesem Bericht nicht aufgeführt", other = "{n} weitere Notizen in diesem Bericht nicht aufgeführt" }
|
||||
@@ -1679,6 +1701,8 @@ status = "draft — needs native review"
|
||||
"{n} rows" = { one = "{n} Zeile", other = "{n} Zeilen" }
|
||||
"{n} styles" = { one = "{n} Stil", other = "{n} Stile" }
|
||||
"{n} variables" = { one = "{n} Variable", other = "{n} Variablen" }
|
||||
"{n} votes" = { one = "{n} Stimme", other = "{n} Stimmen" }
|
||||
"{n} votes left" = { one = "Noch {n} Stimme", other = "Noch {n} Stimmen" }
|
||||
"{n} weeks ago" = { one = "vor {n} Woche", other = "vor {n} Wochen" }
|
||||
"{n} years ago" = { one = "vor {n} Jahr", other = "vor {n} Jahren" }
|
||||
"{n}d" = "{n} T."
|
||||
@@ -1735,6 +1759,7 @@ status = "draft — needs native review"
|
||||
"board.ink" = "Markerstrich"
|
||||
"board.label" = "Verbinderbeschriftung"
|
||||
"board.nextSticky" = "Nächste Haftnotiz"
|
||||
"board.reattach" = "Verbinder neu verbinden"
|
||||
"board.setColor" = "Whiteboard-Farbe"
|
||||
"board.setStyle" = "Whiteboard-Stil"
|
||||
"board.shape" = "Form mit Text"
|
||||
@@ -1744,6 +1769,10 @@ status = "draft — needs native review"
|
||||
"board.tableEdit" = "Tabelle bearbeiten"
|
||||
"board.template" = "Whiteboard-Vorlage"
|
||||
"board.templates" = "Whiteboard-Vorlagen auflisten"
|
||||
"board.timer" = "Timer"
|
||||
"board.timerState" = "Timer-Status"
|
||||
"board.vote" = "Abstimmung"
|
||||
"board.voteResults" = "Abstimmungsergebnisse"
|
||||
"bool.exclude" = "Auswahl ausschließen"
|
||||
"bool.flatten" = "Boolesche Gruppe zusammenfügen"
|
||||
"bool.intersect" = "Schnittmenge der Auswahl"
|
||||
|
||||
@@ -36,6 +36,7 @@ name = "English"
|
||||
"Add a description" = "Add a description"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Add a frame (F) or a shape (R) to get started"
|
||||
"Add a layer or component to tile first" = "Add a layer or component to tile first"
|
||||
"Add a minute" = "Add a minute"
|
||||
"Add a note for developers" = "Add a note for developers"
|
||||
"Add action" = "Add action"
|
||||
"Add actions for when no condition holds" = "Add actions for when no condition holds"
|
||||
@@ -93,6 +94,7 @@ name = "English"
|
||||
"All comments" = "All comments"
|
||||
"All components" = "All components"
|
||||
"All fonts" = "All fonts"
|
||||
"All objects" = "All objects"
|
||||
"All supported properties" = "All supported properties"
|
||||
"All the fonts this file uses are installed" = "All the fonts this file uses are installed"
|
||||
"All variables" = "All variables"
|
||||
@@ -217,6 +219,8 @@ name = "English"
|
||||
"Checking the server…" = "Checking the server…"
|
||||
"Chevron" = "Chevron"
|
||||
"Clear from this list" = "Clear from this list"
|
||||
"Clear results" = "Clear results"
|
||||
"Click objects to vote; click your badge to take a vote back" = "Click objects to vote; click your badge to take a vote back"
|
||||
"Code or invite link" = "Code or invite link"
|
||||
"Color profile: {name}" = "Color profile: {name}"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards"
|
||||
@@ -244,7 +248,7 @@ name = "English"
|
||||
"Changes on main" = "Changes on main"
|
||||
"Changes on the branch" = "Changes on the branch"
|
||||
"Changes unknown" = "Changes unknown"
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code."
|
||||
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code."
|
||||
"Choose another profile to change it." = "Choose another profile to change it."
|
||||
"Choose collection" = "Choose collection"
|
||||
"Choose image" = "Choose image"
|
||||
@@ -328,7 +332,10 @@ name = "English"
|
||||
"Copy as PNG" = "Copy as PNG"
|
||||
"Copy as SVG" = "Copy as SVG"
|
||||
"Copy code" = "Copy code"
|
||||
"Copy effect" = "Copy effect"
|
||||
"Copy fill" = "Copy fill"
|
||||
"Copy link" = "Copy link"
|
||||
"Copy stroke" = "Copy stroke"
|
||||
"Copy the {label}" = "Copy the {label}"
|
||||
"Copy {value}" = "Copy {value}"
|
||||
"Corner radius" = "Corner radius"
|
||||
@@ -339,6 +346,7 @@ name = "English"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers."
|
||||
"Couldn't read {name}." = "Couldn't read {name}."
|
||||
"Couldn't save automatically: {error}" = "Couldn't save automatically: {error}"
|
||||
"Couldn't save the file" = "Couldn't save the file"
|
||||
"Count" = "Count"
|
||||
"Create" = "Create"
|
||||
"Create a variable collection first" = "Create a variable collection first"
|
||||
@@ -438,7 +446,7 @@ name = "English"
|
||||
"Draw" = "Draw"
|
||||
"Draw a frame" = "Draw a frame"
|
||||
"Drawing tools" = "Drawing tools"
|
||||
"Drop a .figcraft, .fig, .json, .svg or image file." = "Drop a .figcraft, .fig, .json, .svg or image file."
|
||||
"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Drop a .figcraft, .fig, .sketch, .json, .svg or image file."
|
||||
"Drop to import" = "Drop to import"
|
||||
"Drop to open" = "Drop to open"
|
||||
"Drop shadow" = "Drop shadow"
|
||||
@@ -462,6 +470,7 @@ name = "English"
|
||||
"Effect settings" = "Effect settings"
|
||||
"Effect styles" = "Effect styles"
|
||||
"Effects" = "Effects"
|
||||
"Curved" = "Curved"
|
||||
"Elbow" = "Elbow"
|
||||
"Ellipse" = "Ellipse"
|
||||
"Ellipses" = "Ellipses"
|
||||
@@ -471,6 +480,7 @@ name = "English"
|
||||
"Empty" = "Empty"
|
||||
"End" = "End"
|
||||
"End point" = "End point"
|
||||
"End vote" = "End vote"
|
||||
"Ends the session; everyone keeps their copy" = "Ends the session; everyone keeps their copy"
|
||||
"Enter" = "Enter"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Enter the address of a figcraft-server and press Browse to see the plugins it offers."
|
||||
@@ -565,6 +575,7 @@ name = "English"
|
||||
"Font size" = "Font size"
|
||||
"Font style" = "Font style"
|
||||
"Forget on this computer" = "Forget on this computer"
|
||||
"Format" = "Format"
|
||||
"Four screens of a trail app with components and a prototype flow" = "Four screens of a trail app with components and a prototype flow"
|
||||
"Fractions" = "Fractions"
|
||||
"Frame" = "Frame"
|
||||
@@ -807,6 +818,7 @@ name = "English"
|
||||
"Min H" = "Min H"
|
||||
"Min W" = "Min W"
|
||||
"Minimize" = "Minimize"
|
||||
"Minutes" = "Minutes"
|
||||
"Mirror angle" = "Mirror angle"
|
||||
"Mirror angle and length" = "Mirror angle and length"
|
||||
"Mirroring" = "Mirroring"
|
||||
@@ -904,6 +916,7 @@ name = "English"
|
||||
"No text styles yet. Click + to create one from the selected text." = "No text styles yet. Click + to create one from the selected text."
|
||||
"No variables fit here." = "No variables fit here."
|
||||
"No variables yet. Create them in Local variables." = "No variables yet. Create them in Local variables."
|
||||
"No votes yet" = "No votes yet"
|
||||
"Nobody else is here yet" = "Nobody else is here yet"
|
||||
"Noise" = "Noise"
|
||||
"None" = "None"
|
||||
@@ -993,6 +1006,7 @@ name = "English"
|
||||
"Page deleted" = "Page deleted"
|
||||
"Pages" = "Pages"
|
||||
"Pages added to the document" = "Pages added to the document"
|
||||
"Pause" = "Pause"
|
||||
"Pink" = "Pink"
|
||||
"Placed on the current page" = "Placed on the current page"
|
||||
"Pages: {pages}" = "Pages: {pages}"
|
||||
@@ -1007,7 +1021,10 @@ name = "English"
|
||||
"Pass through" = "Pass through"
|
||||
"Paste a link (https://…)" = "Paste a link (https://…)"
|
||||
"Paste a team library link (a view link)" = "Paste a team library link (a view link)"
|
||||
"Paste effect" = "Paste effect"
|
||||
"Paste fill" = "Paste fill"
|
||||
"Paste here" = "Paste here"
|
||||
"Paste stroke" = "Paste stroke"
|
||||
"Paste to replace" = "Paste to replace"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { one = "Pasted design layers; {n} kind of content was left out", other = "Pasted design layers; {n} kinds of content were left out" }
|
||||
"Pattern" = "Pattern"
|
||||
@@ -1180,6 +1197,8 @@ name = "English"
|
||||
"Restore this version" = "Restore this version"
|
||||
"Restored “{name}”" = "Restored “{name}”"
|
||||
"Retrospective" = "Retrospective"
|
||||
"Results" = "Results"
|
||||
"Resume" = "Resume"
|
||||
"Reverse the order" = "Reverse the order"
|
||||
"Reversed triangle" = "Reversed triangle"
|
||||
"Review and merge" = "Review and merge"
|
||||
@@ -1206,6 +1225,7 @@ name = "English"
|
||||
"Rulers" = "Rulers"
|
||||
"Run" = "Run"
|
||||
"Run {label}" = "Run {label}"
|
||||
"Save file" = "Save file"
|
||||
"Save files automatically" = "Save files automatically"
|
||||
"Search templates" = "Search templates"
|
||||
"See all templates" = "See all templates"
|
||||
@@ -1264,6 +1284,7 @@ name = "English"
|
||||
"Server address (ws://host:47900/)" = "Server address (ws://host:47900/)"
|
||||
"Server address" = "Server address"
|
||||
"Server for online sharing" = "Server for online sharing"
|
||||
"Server key" = "Server key"
|
||||
"Set as default" = "Set as default"
|
||||
"Set corner smoothing to 60%" = "Set corner smoothing to 60%"
|
||||
"Set variable" = "Set variable"
|
||||
@@ -1361,17 +1382,20 @@ name = "English"
|
||||
"Start point" = "Start point"
|
||||
"Start sharing" = "Start sharing"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "Start sharing with the Share button, then pass on the code it gives"
|
||||
"Start vote" = "Start vote"
|
||||
"Starting to share…" = "Starting to share…"
|
||||
"Status" = "Status"
|
||||
"Sticky" = "Sticky"
|
||||
"Sticky note" = "Sticky note"
|
||||
"Sticky note colors" = "Sticky note colors"
|
||||
"Sticky notes" = "Sticky notes"
|
||||
"Sticky notes, shapes and connectors" = "Sticky notes, shapes and connectors"
|
||||
"Stop" = "Stop"
|
||||
"Stop following {name}" = "Stop following {name}"
|
||||
"Stop session" = "Stop session"
|
||||
"Stop sharing" = "Stop sharing"
|
||||
"Stop spotlighting" = "Stop spotlighting"
|
||||
"Stop timer" = "Stop timer"
|
||||
"Stop {n} {hex}" = "Stop {n} {hex}"
|
||||
"Straight" = "Straight"
|
||||
"Stretch" = "Stretch"
|
||||
@@ -1463,6 +1487,7 @@ name = "English"
|
||||
"Tidy up" = "Tidy up"
|
||||
"Tile" = "Tile"
|
||||
"Tile type" = "Tile type"
|
||||
"Timer" = "Timer"
|
||||
"Tint" = "Tint"
|
||||
"Title" = "Title"
|
||||
"Title case" = "Title case"
|
||||
@@ -1561,6 +1586,8 @@ name = "English"
|
||||
"Visibility" = "Visibility"
|
||||
"Visible" = "Visible"
|
||||
"Vivid" = "Vivid"
|
||||
"Votes per person" = "Votes per person"
|
||||
"Voting session" = "Voting session"
|
||||
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose."
|
||||
"Warm" = "Warm"
|
||||
"Watch" = "Watch"
|
||||
@@ -1623,6 +1650,7 @@ name = "English"
|
||||
"call any command its access allows" = "call any command its access allows"
|
||||
"call only: {commands}" = "call only: {commands}"
|
||||
"can edit the document" = "can edit the document"
|
||||
"can save files" = "can save files"
|
||||
"conditional prototype actions nested too deeply were skipped" = "conditional prototype actions nested too deeply were skipped"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "conditional prototype actions with conditions that can't be evaluated were skipped"
|
||||
"connector label background was skipped: style data is missing" = "connector label background was skipped: style data is missing"
|
||||
@@ -1684,6 +1712,7 @@ name = "English"
|
||||
"nobody else here yet" = "nobody else here yet"
|
||||
"non-default slot settings are not represented and were skipped" = "non-default slot settings are not represented and were skipped"
|
||||
"now" = "now"
|
||||
"offer files to save if it asks to (you choose where each one goes)" = "offer files to save if it asks to (you choose where each one goes)"
|
||||
"only in a text style or variable" = "only in a text style or variable"
|
||||
"or" = "or"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join"
|
||||
@@ -1783,6 +1812,7 @@ name = "English"
|
||||
"{n} layers selected" = { one = "{n} layer selected", other = "{n} layers selected" }
|
||||
"{n} layers · {parts}" = { one = "{n} layer · {parts}", other = "{n} layers · {parts}" }
|
||||
"{n} matches" = { one = "{n} match", other = "{n} matches" }
|
||||
"{n} min" = "{n} min"
|
||||
"{n} minutes ago" = { one = "{n} minute ago", other = "{n} minutes ago" }
|
||||
"{n} months ago" = { one = "{n} month ago", other = "{n} months ago" }
|
||||
"{n} more notes omitted from this report" = { one = "{n} more note omitted from this report", other = "{n} more notes omitted from this report" }
|
||||
@@ -1796,6 +1826,8 @@ name = "English"
|
||||
"{n} styles" = { one = "{n} style", other = "{n} styles" }
|
||||
"{n} text layers" = { one = "{n} text layer", other = "{n} text layers" }
|
||||
"{n} variables" = { one = "{n} variable", other = "{n} variables" }
|
||||
"{n} votes" = { one = "{n} vote", other = "{n} votes" }
|
||||
"{n} votes left" = { one = "{n} vote left", other = "{n} votes left" }
|
||||
"{n} weeks ago" = { one = "{n} week ago", other = "{n} weeks ago" }
|
||||
"{n} years ago" = { one = "{n} year ago", other = "{n} years ago" }
|
||||
"{n}d" = "{n}d"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user