M7: export and inspect behaviour suite and fixes
A behaviour suite for export and inspect, written from public documentation of design tools'
export settings and developer handoff modes (`crates/engine/src/export_behaviour_tests.rs`,
`crates/engine/src/inspect_behaviour_tests.rs`: 19 tests, 139 table rows and asserts, each with a
one-line derivation; rows where the docs are silent say so and pin today's behaviour). 47 cases
failed on `main`; this PR fixes them in shared code.
**Fixes**
- Export bounds are what a layer paints (`Document::world_render_bounds`, used by every export):
a drop shadow adds room only in the direction it falls (it was a symmetric margin of blur +
largest offset on every side); strokes on rectangles, frames and ellipses reach their real width
(the mitre factor made a centre stroke of 4 add 8 a side); hidden children take no room; a mask
group exports the mask's area.
- Slices export everything visible in their area (they exported an empty image).
- Slashes in layer names export into folders (`icons/close` → `icons/close.png`, folders created;
`.` and `..` segments dropped).
- Copy as PNG copies the whole selection (only the first layer was copied), now from shared code
(`cmd::file::copy_png`).
- Inspect positions are measured from the frame around a layer; groups aren't frames.
**Added (documented options that were missing)**
- Export settings: contents only (off draws every layer showing in the area) and include bounding
box (the layer's own box); SVG: outline text (off writes `<text>` with a `<tspan>` per line),
id attributes (unique), simplified strokes (stroke attributes for centred strokes, and for inside
and outside strokes on boxes and ellipses). Stored per export setting, taken by `file.export`,
`export.add` / `export.update`, `file.exportLayers` and MCP `export`; a "…" popover on each row of
the Export section.
- Inspect: colour formats hex / RGB / CSS / HSL / HSB (`inspect.properties {colorFormat}`, a Color
format dropdown), a Blend row, CSS in rem (`inspect.code {unit, rootFontSize}`, px/rem dropdown),
CSS for rotation, blend modes, gradient directions and kinds (linear angle, radial, conic), text
alignment, case and decoration; icons and images in the asset list (`kind`), exported with
`file.exportLayers {assets: true}`.
This commit is contained in:
parent
df6bc0a319
commit
8b2607de8e
16
ROADMAP.md
16
ROADMAP.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~170–305 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (.fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
|
|
@ -17,7 +17,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
|||
| Feature breadth | **~82%** (core editor ~93%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
|
||||
| Ready for real work | **~51%** | estimated, same doc (written dimension weights and scores) |
|
||||
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Mainstream practitioner | **~49%** | estimated: weekly areas 70.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Mainstream practitioner | **~49%** | estimated: weekly areas 71.0% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Remaining to beta | **~170–305 h** one agent | estimated |
|
||||
| Remaining to full parity | **~307–596 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ Figma's own languages on the landed localization layer (15–25 h).
|
|||
|---|---|---|---|
|
||||
| Features (depth) | 61% (core editor 70%) | 47–95 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats | 58% (`.fig` read best effort with editable vectors, no write) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| File formats | 59% (`.fig` read best effort with editable vectors, no write) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
|
|
@ -85,8 +85,8 @@ Figma's own languages on the landed localization layer (15–25 h).
|
|||
| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) |
|
||||
| Styles & variables | 94% | 76% | 3–5 | [components-parity](docs/components-parity.md) |
|
||||
| Prototyping | 90% | 74% | 3–6 | [prototyping-parity](docs/prototyping-parity.md) |
|
||||
| Import/export | 82% | 54% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Dev Mode / inspect | 90% | 64% | 1–3 | [dev-mode-parity](docs/dev-mode-parity.md) |
|
||||
| Import/export | 84% | 57% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Dev Mode / inspect | 93% | 70% | 1–3 | [dev-mode-parity](docs/dev-mode-parity.md) |
|
||||
| Comments, versions, misc | 80% | 58% | 4–8 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Plugins & widgets | 45% | 28% | 15–40 | [plugins](docs/plugins.md) |
|
||||
|
|
@ -128,6 +128,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-11** · Export and inspect behaviour suite (gap 4): exports cover what a layer paints (shadows
|
||||
only where they fall, true stroke widths, no room for hidden layers, a mask group's mask), slices export what's under
|
||||
them, layer names with slashes export into folders; export settings gained contents only, include bounding box and
|
||||
the SVG options (outline text, ids, simplified strokes); Inspect shows colours in hex, RGB, CSS, HSL or HSB, CSS in
|
||||
rem, blend modes and positions from the frame, and lists icons and images as assets. Dev Mode 70%.
|
||||
- **2026-10-11** · `.fig` import's tail (gap 1): vectors open as editable vector networks with their
|
||||
corner radii, handle mirroring and end caps (outlines when the network is missing or damaged); sticky notes, shapes
|
||||
with text, connectors, tables and similar layers import as frames, text and vectors instead of being dropped;
|
||||
|
|
@ -204,6 +209,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes `<text>`), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53% (the row still showed the figures from before `.fig` import); Dev Mode 90%/64% → 93%/70%; mainstream weekly areas 68.1% → 68.4%; percentages and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
|
||||
| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation and its fixes: rounded vector points are drawn and exported, a boolean group looks like its top layer (a subtraction like the bottom one), Alt draws lines from the centre, stars keep to 3–60 points, delete and heal follows the curve, Shift-click on a point at a corner of the points' box deselects it, outline stroke keeps the stroke's colour style, opacity and effects, the miter field shows degrees, individual corners only for rectangles and frames, Option/Alt+Shift+F flattens, MCP `key` reaches the edit tools. Added: corner radius and star ratio handles, Count and Ratio fields, cut drags that divide shapes into layers, paint bucket drags. Shapes & vector 97%/68% → 98%/72%, 1–3 h → 2–4 h. Totals recomputed from the area table, which earlier rows had left stale: feature breadth ~80% → ~82% (core ~91% → ~93%), Features (depth) 57% → 61% (core 68% → 69%), 60–115 h → 56–109 h, ready for real work ~49% → ~51%, mainstream ~46% → ~48%; full parity ~321–618 h → ~319–615 h (the selection suite's −3/−5 h hadn't reached the totals) |
|
||||
| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation and its fixes: Normal groups and frames isolate their children and clipping frames don't (canvas, SVG, PDF); plus darker drawn on the canvas and in PNG exports; drop shadows hidden behind what the layer covers unless shown behind transparent areas, SVG included, with filter regions that reach as far as the effects; Fill images cover the box to its edges; "Normal" names Normal in commands and the blend menu. Paint 85%/66% → 87%/70% (breadth from the GIF/TIFF row), 5–9 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h |
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use figcraft_doc::{Document, ExportFormat, NodeId, NodeKind, PrintSettings};
|
||||
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -117,7 +117,12 @@ fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult<Ve
|
|||
let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) {
|
||||
figcraft_engine::cmd::file::frames_pdf(doc, page, print).map(|(bytes, _)| bytes)
|
||||
} else {
|
||||
figcraft_engine::cmd::file::export_bytes_with(doc, &children(doc, page), ExportFormat::Pdf, 1.0, print)
|
||||
figcraft_engine::cmd::file::export_bytes_with(
|
||||
doc,
|
||||
&children(doc, page),
|
||||
&ExportSetting { format: ExportFormat::Pdf, print: *print, ..Default::default() },
|
||||
1.0,
|
||||
)
|
||||
};
|
||||
r.map_err(|e| CliError::Failed(e.to_string()))
|
||||
}
|
||||
|
|
@ -201,8 +206,10 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
|||
};
|
||||
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
|
||||
let single = image_format(&ext(out)).is_some();
|
||||
let bytes_of =
|
||||
|ids: &[NodeId]| figcraft_engine::cmd::file::export_bytes_with(&doc, ids, fmt, scale, print).map_err(|e| CliError::Failed(e.to_string()));
|
||||
let bytes_of = |ids: &[NodeId]| {
|
||||
let setting = ExportSetting { format: fmt, print: *print, ..Default::default() };
|
||||
figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))
|
||||
};
|
||||
if single {
|
||||
let bytes = match target {
|
||||
Some(t) => bytes_of(&[t])?,
|
||||
|
|
|
|||
|
|
@ -664,19 +664,40 @@ impl Document {
|
|||
Some(figcraft_geom::transform_rect_bbox(self.world_transform(id), n.local_rect()))
|
||||
}
|
||||
|
||||
/// Bounds of everything painted by a node, effects and strokes included (page coordinates).
|
||||
/// Bounds of everything painted by a node, effects and strokes included (page coordinates):
|
||||
/// a clipping frame's own area, else that and what its visible children show (masked
|
||||
/// children only within their mask). A group is what its children show, grown by its effects.
|
||||
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
|
||||
let n = self.node(id)?;
|
||||
let reach = n.paint_reach();
|
||||
let mut r = figcraft_geom::transform_rect_bbox(self.world_transform(id), n.local_rect().inflate(reach, reach));
|
||||
if !(n.clip && n.kind.is_frame_like()) || n.kind.bounds_follow_children() {
|
||||
for (c, _) in self.box_children(n) {
|
||||
if let Some(cr) = self.world_render_bounds(c) {
|
||||
r = r.union(cr);
|
||||
}
|
||||
}
|
||||
let own = n.own_paint_bounds(self.world_transform(id));
|
||||
if n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children() {
|
||||
return Some(own);
|
||||
}
|
||||
Some(r)
|
||||
Some(match (self.children_render_bounds(n), &n.kind) {
|
||||
(Some(kids), NodeKind::Group) => n.grow_by_effects(kids),
|
||||
(Some(kids), _) => own.union(kids),
|
||||
(None, _) => own,
|
||||
})
|
||||
}
|
||||
|
||||
/// What `n`'s visible children show, together: each mask's run within the mask's bounds.
|
||||
fn children_render_bounds(&self, n: &Node) -> Option<Rect> {
|
||||
let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask);
|
||||
let mut out: Option<Rect> = None;
|
||||
for run in self.paint_runs(n) {
|
||||
let ids = n.children.get(run).unwrap_or(&[]);
|
||||
let mask = ids.first().copied().filter(|m| self.node(*m).is_some_and(|c| c.is_mask && c.visible));
|
||||
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.world_render_bounds(*i)).reduce(|a, b| a.union(b));
|
||||
let r = match mask {
|
||||
Some(m) => r.zip(self.world_render_bounds(m)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
|
||||
None => r,
|
||||
};
|
||||
out = match (out, r) {
|
||||
(Some(a), Some(b)) => Some(a.union(b)),
|
||||
(a, b) => a.or(b),
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Union of the top-level nodes' bounds on a page.
|
||||
|
|
|
|||
|
|
@ -702,6 +702,37 @@ impl Node {
|
|||
|
||||
/// Maximum distance (in local units) painting can reach outside the node box.
|
||||
pub fn paint_reach(&self) -> f64 {
|
||||
let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max);
|
||||
self.outline_reach() + fx + self.spread_miter_reach()
|
||||
}
|
||||
|
||||
/// The page-space area this node paints itself under `world` (its children aside): its box
|
||||
/// grown by its strokes and glyphs, then by its effects (see [`Node::grow_by_effects`]).
|
||||
pub fn own_paint_bounds(&self, world: Affine) -> figcraft_geom::Rect {
|
||||
let o = self.outline_reach();
|
||||
self.grow_by_effects(figcraft_geom::transform_rect_bbox(world, self.local_rect().inflate(o, o)))
|
||||
}
|
||||
|
||||
/// Page-space area `b` grown by what this node's visible effects paint around it: blurs all
|
||||
/// round, drop shadows towards their offset (in page units, as the renderer draws them), so a
|
||||
/// shadow falling down adds room below and none above.
|
||||
pub fn grow_by_effects(&self, b: figcraft_geom::Rect) -> figcraft_geom::Rect {
|
||||
let miter = self.spread_miter_reach();
|
||||
self.effects.iter().filter(|e| e.visible).fold(b, |r, e| match &e.kind {
|
||||
EffectKind::DropShadow(s) => {
|
||||
let g = s.radius.abs() * 1.5 + s.spread.max(0.0) + miter;
|
||||
let (dx, dy) = s.offset;
|
||||
if g.is_finite() && dx.is_finite() && dy.is_finite() { r.union(b.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy)) } else { r }
|
||||
}
|
||||
_ => {
|
||||
let g = e.reach();
|
||||
if g.is_finite() && g > 0.0 { r.union(b.inflate(g, g)) } else { r }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// How far (local units) strokes and glyphs reach outside the box.
|
||||
fn outline_reach(&self) -> f64 {
|
||||
let stroke = if self.has_visible_stroke() {
|
||||
let w = self.stroke.sides.map(|s| s.iter().copied().fold(0.0, f64::max)).unwrap_or_else(|| self.stroke.max_weight());
|
||||
let base = match self.stroke.align {
|
||||
|
|
@ -711,13 +742,15 @@ impl Node {
|
|||
};
|
||||
// Arrowheads are sized by the weight, whatever a width profile does.
|
||||
let caps = if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() { w.max(self.stroke.weight) * 3.0 } else { w };
|
||||
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * self.stroke.miter_limit.clamp(1.0, 4.0)
|
||||
// Right-angled or smooth outlines (boxes, ellipses) end their mitres on the grown box.
|
||||
let boxy = matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like();
|
||||
let miter = if boxy { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
|
||||
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max);
|
||||
let glyphs = if let NodeKind::Text(t) = &self.kind { t.path_reach() + t.trim_reach() } else { 0.0 };
|
||||
stroke + fx + self.spread_miter_reach() + glyphs
|
||||
stroke + glyphs
|
||||
}
|
||||
|
||||
/// How much further than its spread a drop shadow can reach at the outline's sharp corners:
|
||||
|
|
|
|||
|
|
@ -1394,8 +1394,8 @@ impl PrintSettings {
|
|||
}
|
||||
|
||||
/// One row of a node's Export section.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ExportSetting {
|
||||
pub size: ExportSize,
|
||||
pub suffix: String,
|
||||
|
|
@ -1403,6 +1403,45 @@ pub struct ExportSetting {
|
|||
/// PDF only: bleed, marks and colour.
|
||||
#[serde(skip_serializing_if = "PrintSettings::is_default")]
|
||||
pub print: PrintSettings,
|
||||
/// Only the layer itself (on), or every layer that shows in its area (off).
|
||||
#[serde(skip_serializing_if = "is_true")]
|
||||
pub contents_only: bool,
|
||||
/// The layer's own box, cutting off what it paints outside it (shadows, outside strokes).
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub include_bounding_box: bool,
|
||||
/// SVG: text as outlines (on) or as `<text>` elements (off).
|
||||
#[serde(skip_serializing_if = "is_true")]
|
||||
pub svg_outline_text: bool,
|
||||
/// SVG: layer names as `id` attributes.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub svg_id_attribute: bool,
|
||||
/// SVG: strokes as `stroke` attributes where that's exact (on), else as outlines.
|
||||
#[serde(skip_serializing_if = "is_true")]
|
||||
pub svg_simplify_stroke: bool,
|
||||
}
|
||||
|
||||
impl Default for ExportSetting {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
size: ExportSize::default(),
|
||||
suffix: String::new(),
|
||||
format: ExportFormat::default(),
|
||||
print: PrintSettings::default(),
|
||||
contents_only: true,
|
||||
include_bounding_box: false,
|
||||
svg_outline_text: true,
|
||||
svg_id_attribute: false,
|
||||
svg_simplify_stroke: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_true(b: &bool) -> bool {
|
||||
*b
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
impl ExportSetting {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"file.exportLayers",
|
||||
"Export layers",
|
||||
"{ids? (default the selection, else every layer on the page with export settings), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}",
|
||||
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}",
|
||||
false,
|
||||
export_layers
|
||||
),
|
||||
|
|
@ -701,66 +701,95 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
/// Encode `ids` (or the page) in `format` at `scale`.
|
||||
pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result<Vec<u8>> {
|
||||
export_bytes_with(doc, ids, format, scale, &PrintSettings::default())
|
||||
export_bytes_with(doc, ids, &ExportSetting { format, ..Default::default() }, scale)
|
||||
}
|
||||
|
||||
/// [`export_bytes`], PDFs with `print`'s bleed, marks and colour.
|
||||
pub fn export_bytes_with(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64, print: &PrintSettings) -> Result<Vec<u8>> {
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Disabled("file.export".into(), "nothing to export".into()));
|
||||
/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own
|
||||
/// area), or their boxes with "include bounding box" and for slices.
|
||||
pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option<figcraft_geom::Rect> {
|
||||
ids.iter()
|
||||
.filter_map(|i| {
|
||||
let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice));
|
||||
if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) }
|
||||
})
|
||||
.reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off,
|
||||
/// and always for slices) every top-level layer of their page, so whatever shows in the area is in.
|
||||
fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec<NodeId> {
|
||||
let slice = ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)));
|
||||
match ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) {
|
||||
Some(page) if slice || !setting.contents_only => page.children.clone(),
|
||||
_ => super::edit::z_sorted(doc, ids),
|
||||
}
|
||||
match format {
|
||||
}
|
||||
|
||||
/// [`export_bytes`] with an export setting's format and options (its size is `scale`: see
|
||||
/// [`export_scale`]): contents only, include bounding box, the SVG options and PDF print settings.
|
||||
pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result<Vec<u8>> {
|
||||
let nothing = || EngineError::Disabled("file.export".into(), "nothing to export".into());
|
||||
if ids.is_empty() {
|
||||
return Err(nothing());
|
||||
}
|
||||
let area = export_area(doc, ids, setting).ok_or_else(nothing)?;
|
||||
let drawn = drawn_layers(doc, ids, setting);
|
||||
// The layers' own area unless an option or a slice says otherwise.
|
||||
let own_area = setting.contents_only
|
||||
&& !setting.include_bounding_box
|
||||
&& !ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)));
|
||||
match setting.format {
|
||||
// SVG and PDF colours are sRGB: a Display P3 file exports converted (out-of-gamut colours clip).
|
||||
ExportFormat::Svg => figcraft_svg::export_svg(&doc.in_srgb(), ids, &Default::default())
|
||||
.map(String::into_bytes)
|
||||
.ok_or_else(|| EngineError::Other("nothing to export".into())),
|
||||
ExportFormat::Svg => {
|
||||
let opts = figcraft_svg::SvgOptions {
|
||||
include_ids: setting.svg_id_attribute,
|
||||
outline_text: setting.svg_outline_text,
|
||||
simplify_stroke: setting.svg_simplify_stroke,
|
||||
bounds: Some(area),
|
||||
};
|
||||
figcraft_svg::export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).ok_or_else(nothing)
|
||||
}
|
||||
ExportFormat::Pdf => {
|
||||
let doc = &*doc.in_srgb();
|
||||
// Frames export one page each; anything else shares one page.
|
||||
let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
|
||||
let groups: Vec<Vec<NodeId>> = if all_frames { ids.iter().map(|i| vec![*i]).collect() } else { vec![super::edit::z_sorted(doc, ids)] };
|
||||
pdf_bytes(doc, &groups, print)
|
||||
let all_frames = own_area && ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
|
||||
let groups: Vec<Vec<NodeId>> = if all_frames { ids.iter().map(|i| vec![*i]).collect() } else { vec![drawn] };
|
||||
pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))
|
||||
}
|
||||
_ => {
|
||||
let pix = if ids.len() == 1 {
|
||||
ids.first().and_then(|i| figcraft_render::render_node(doc, *i, scale))
|
||||
} else {
|
||||
let b = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b));
|
||||
b.and_then(|b| {
|
||||
let view = figcraft_render::View {
|
||||
origin: (b.x0, b.y0),
|
||||
scale,
|
||||
width: (b.width() * scale).ceil().max(1.0) as u32,
|
||||
height: (b.height() * scale).ceil().max(1.0) as u32,
|
||||
};
|
||||
let mut pix = figcraft_render::Pixmap::new(view.width.clamp(1, 16384), view.height.clamp(1, 16384))?;
|
||||
let ordered = super::edit::z_sorted(doc, ids);
|
||||
figcraft_render::render_nodes_into(doc, &ordered, &view, &mut pix);
|
||||
Some(pix)
|
||||
})
|
||||
};
|
||||
let pix = pix.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
|
||||
figcraft_render::encode_export(&pix, format, doc.color_profile).map_err(EngineError::Other)
|
||||
let pix = figcraft_render::render_area(doc, &drawn, area, scale)
|
||||
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
|
||||
figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What Copy as PNG puts on the clipboard: the layers together, at 2x (in the document's colour
|
||||
/// profile; the clipboard wants sRGB).
|
||||
pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option<figcraft_render::Pixmap> {
|
||||
let area = export_area(doc, ids, &ExportSetting::default())?;
|
||||
figcraft_render::render_area(doc, &super::edit::z_sorted(doc, ids), area, 2.0)
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.export";
|
||||
let doc = s.doc()?;
|
||||
let mut ids = targets(s, p);
|
||||
if ids.is_empty() {
|
||||
ids = doc.get(s.page()?)?.children.clone();
|
||||
}
|
||||
let format = string(p, "format")
|
||||
.and_then(|f| ExportFormat::from_ext(&f))
|
||||
.or_else(|| string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp))))
|
||||
.unwrap_or_default();
|
||||
let scale = match size_param("file.export", p)? {
|
||||
Some(size) => export_scale(doc, &ids, size),
|
||||
let mut setting = export_setting(cmd, ExportSetting::default(), p)?;
|
||||
if string(p, "format").is_none()
|
||||
&& let Some(f) = string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp)))
|
||||
{
|
||||
setting.format = f;
|
||||
}
|
||||
let scale = match size_param(cmd, p)? {
|
||||
Some(_) => export_scale(doc, &ids, &setting),
|
||||
None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE),
|
||||
};
|
||||
let print = print_param("file.export", p, PrintSettings::default())?;
|
||||
let bytes = export_bytes_with(doc, &ids, format, scale, &print)?;
|
||||
let format = setting.format;
|
||||
let bytes = export_bytes_with(doc, &ids, &setting, scale)?;
|
||||
match string(p, "path") {
|
||||
Some(path) => {
|
||||
write(&path, &bytes)?;
|
||||
|
|
@ -770,10 +799,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The scale that gives `ids` (their bounds together) the export size `size`.
|
||||
pub fn export_scale(doc: &Document, ids: &[NodeId], size: ExportSize) -> f64 {
|
||||
let b = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b));
|
||||
b.map_or(1.0, |b| size.scale_for(b.width(), b.height()))
|
||||
/// The scale that gives `ids` (their export area together) `setting`'s export size.
|
||||
pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 {
|
||||
export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height()))
|
||||
}
|
||||
|
||||
/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix` and `format` that `p` sets.
|
||||
|
|
@ -788,6 +816,18 @@ pub(super) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Resu
|
|||
e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp"))?;
|
||||
}
|
||||
e.print = print_param(cmd, p, e.print)?;
|
||||
for (key, slot) in [
|
||||
("contentsOnly", &mut e.contents_only),
|
||||
("includeBoundingBox", &mut e.include_bounding_box),
|
||||
("svgOutlineText", &mut e.svg_outline_text),
|
||||
("svgIdAttribute", &mut e.svg_id_attribute),
|
||||
("svgSimplifyStroke", &mut e.svg_simplify_stroke),
|
||||
] {
|
||||
match get(p, key) {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?,
|
||||
}
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
|
|
@ -844,13 +884,17 @@ pub struct ExportJob {
|
|||
/// The files exporting `ids` with their export settings makes, with unique names (repeats get
|
||||
/// " 2", " 3"…). Layers without settings make none.
|
||||
pub fn export_jobs(doc: &Document, ids: &[NodeId]) -> Vec<ExportJob> {
|
||||
jobs_for(ids.iter().filter_map(|i| Some((*i, doc.node(*i)?.exports.clone()))), doc)
|
||||
}
|
||||
|
||||
/// The files exporting each layer with the settings beside it makes (see [`export_jobs`]).
|
||||
pub fn jobs_for(items: impl IntoIterator<Item = (NodeId, Vec<ExportSetting>)>, doc: &Document) -> Vec<ExportJob> {
|
||||
let mut used = std::collections::BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
for id in ids {
|
||||
let Some(n) = doc.node(*id) else { continue };
|
||||
let name = safe_file_name(&n.name);
|
||||
let base = Some(name.trim().trim_end_matches('.')).filter(|b| !b.is_empty()).unwrap_or("Untitled");
|
||||
for e in &n.exports {
|
||||
for (id, settings) in items {
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
let base = export_path(&n.name);
|
||||
for e in settings {
|
||||
if out.len() >= MAX_EXPORT_FILES {
|
||||
return out;
|
||||
}
|
||||
|
|
@ -859,13 +903,21 @@ pub fn export_jobs(doc: &Document, ids: &[NodeId]) -> Vec<ExportJob> {
|
|||
let file =
|
||||
(1..).map(|k| if k == 1 { format!("{stem}.{ext}") } else { format!("{stem} {k}.{ext}") }).find(|f| used.insert(f.to_lowercase()));
|
||||
if let Some(file) = file {
|
||||
out.push(ExportJob { id: *id, setting: e.clone(), file });
|
||||
out.push(ExportJob { id, setting: e, file });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The file path a layer name exports to, without extension: each `/` makes a folder; `.` and
|
||||
/// `..` segments are dropped, so it stays inside the export folder.
|
||||
fn export_path(name: &str) -> String {
|
||||
let parts: Vec<String> =
|
||||
name.split('/').map(|seg| safe_file_name(seg).trim().trim_end_matches('.').to_string()).filter(|seg| !seg.is_empty()).take(32).collect();
|
||||
if parts.is_empty() { "Untitled".into() } else { parts.join("/") }
|
||||
}
|
||||
|
||||
/// `name` without path separators and characters file systems reject.
|
||||
fn safe_file_name(name: &str) -> String {
|
||||
name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect()
|
||||
|
|
@ -879,11 +931,12 @@ pub fn exportable_layers(doc: &Document, page: NodeId) -> Vec<NodeId> {
|
|||
fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.exportLayers";
|
||||
let doc = s.doc()?;
|
||||
let assets = boolean(p, "assets").unwrap_or(false);
|
||||
let mut ids = targets(s, p);
|
||||
if ids.is_empty() {
|
||||
ids = exportable_layers(doc, s.page()?);
|
||||
ids = if assets { vec![s.page()?] } else { exportable_layers(doc, s.page()?) };
|
||||
}
|
||||
let jobs = export_jobs(doc, &ids);
|
||||
let jobs = if assets { crate::devinspect::asset_jobs(doc, &ids) } else { export_jobs(doc, &ids) };
|
||||
if jobs.is_empty() {
|
||||
return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into()));
|
||||
}
|
||||
|
|
@ -892,12 +945,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?;
|
||||
let mut files = Vec::with_capacity(jobs.len());
|
||||
for job in &jobs {
|
||||
let scale = export_scale(doc, &[job.id], job.setting.size);
|
||||
let bytes = export_bytes_with(doc, &[job.id], job.setting.format, scale, print.as_ref().unwrap_or(&job.setting.print))?;
|
||||
let setting = ExportSetting { print: print.unwrap_or(job.setting.print), ..job.setting.clone() };
|
||||
let scale = export_scale(doc, &[job.id], &setting);
|
||||
let bytes = export_bytes_with(doc, &[job.id], &setting, scale)?;
|
||||
let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()});
|
||||
match &dir {
|
||||
Some(d) => {
|
||||
let path = std::path::Path::new(d).join(&job.file).to_string_lossy().to_string();
|
||||
let path = std::path::Path::new(d).join(&job.file);
|
||||
// A layer name with slashes exports into folders.
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let path = path.to_string_lossy().to_string();
|
||||
write(&path, &bytes)?;
|
||||
f["path"] = json!(path);
|
||||
}
|
||||
|
|
@ -909,9 +968,10 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}))
|
||||
}
|
||||
|
||||
/// A PDF with one page per group of layers (an sRGB document), titled after the file.
|
||||
fn pdf_bytes(doc: &Document, groups: &[Vec<NodeId>], print: &PrintSettings) -> Result<Vec<u8>> {
|
||||
let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), print: *print, ..Default::default() };
|
||||
/// A PDF with one page per group of layers (an sRGB document), titled after the file; `area`
|
||||
/// sizes a single page to that page area instead of the layers' bounds.
|
||||
fn pdf_bytes(doc: &Document, groups: &[Vec<NodeId>], print: &PrintSettings, area: Option<figcraft_geom::Rect>) -> Result<Vec<u8>> {
|
||||
let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), print: *print, area, ..Default::default() };
|
||||
figcraft_pdf::export_groups(doc, groups, &opts).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
|
|
@ -923,7 +983,7 @@ pub fn frames_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> Result
|
|||
return Err(EngineError::Disabled("file.exportFramesPdf".into(), "the page has no visible frames".into()));
|
||||
}
|
||||
let groups: Vec<Vec<NodeId>> = frames.iter().map(|f| vec![*f]).collect();
|
||||
Ok((pdf_bytes(&doc.in_srgb(), &groups, print)?, frames.len()))
|
||||
Ok((pdf_bytes(&doc.in_srgb(), &groups, print, None)?, frames.len()))
|
||||
}
|
||||
|
||||
/// Every top-level frame of a page (reading order), one PDF page each.
|
||||
|
|
|
|||
|
|
@ -18,20 +18,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![spec!(
|
||||
"inspect.code",
|
||||
"Code for layer",
|
||||
"{id?, language?: css|html|react|swiftui|compose (default css; html and react 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)} → {code, language, connected}",
|
||||
"{id?, language?: css|html|react|swiftui|compose (default css; html and react 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), unit?: px|rem (CSS, HTML and React; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
false,
|
||||
|s, p| {
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad("inspect.code", "select a layer"))?;
|
||||
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("inspect.code", "language: css|html|react|swiftui|compose"))?,
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose"))?,
|
||||
None => CodeLang::Css,
|
||||
};
|
||||
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
|
||||
None | Some("px") => None,
|
||||
Some("rem") => Some(num_or(p, "rootFontSize", 16.0)).filter(|r| r.is_finite() && *r > 0.0 && *r <= 1000.0),
|
||||
Some(_) => return Err(bad(cmd, "unit: px|rem")),
|
||||
};
|
||||
if string(p, "unit").is_some_and(|u| u.eq_ignore_ascii_case("rem")) && rem.is_none() {
|
||||
return Err(bad(cmd, "rootFontSize: a positive number of pixels (up to 1000)"));
|
||||
}
|
||||
let doc = s.doc()?;
|
||||
if !doc.contains(id) {
|
||||
return Err(bad("inspect.code", "no such layer"));
|
||||
return Err(bad(cmd, "no such layer"));
|
||||
}
|
||||
let connected = crate::code_connect::link(doc, id, lang).is_some();
|
||||
Ok(json!({"code": code_for(doc, id, lang), "language": lang.id(), "connected": connected}))
|
||||
let code = code_for(doc, id, lang);
|
||||
let code = match rem {
|
||||
Some(root) if matches!(lang, CodeLang::Css | CodeLang::Html | CodeLang::React) => px_to_rem(&code, root),
|
||||
_ => code,
|
||||
};
|
||||
Ok(json!({"code": code, "language": lang.id(), "connected": connected}))
|
||||
}
|
||||
)]
|
||||
}
|
||||
|
|
@ -90,6 +104,34 @@ pub(crate) fn num(v: f64) -> String {
|
|||
if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") }
|
||||
}
|
||||
|
||||
/// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end
|
||||
/// in "px" are left alone.
|
||||
pub fn px_to_rem(code: &str, root: f64) -> String {
|
||||
let ident = |c: char| c.is_alphanumeric() || c == '_';
|
||||
let mut out = String::with_capacity(code.len());
|
||||
let mut rest = code;
|
||||
while let Some(i) = rest.find("px") {
|
||||
let (head, tail) = (rest.get(..i).unwrap_or(""), rest.get(i + 2..).unwrap_or(""));
|
||||
// The number right before "px".
|
||||
let start = head.char_indices().rev().take_while(|(_, c)| c.is_ascii_digit() || *c == '.' || *c == '-').last().map_or(head.len(), |(j, _)| j);
|
||||
let (before, number) = (head.get(..start).unwrap_or(""), head.get(start..).unwrap_or(""));
|
||||
let free = !before.chars().next_back().is_some_and(ident) && !tail.chars().next().is_some_and(|c| ident(c) || c == '-');
|
||||
match number.parse::<f64>() {
|
||||
Ok(v) if free && v.is_finite() => {
|
||||
out.push_str(before);
|
||||
out.push_str(&format!("{}rem", num(v / root)));
|
||||
}
|
||||
_ => {
|
||||
out.push_str(head);
|
||||
out.push_str("px");
|
||||
}
|
||||
}
|
||||
rest = tail;
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
match lang {
|
||||
CodeLang::Css => css(doc, id),
|
||||
|
|
@ -314,6 +356,16 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
|||
if n.opacity < 1.0 {
|
||||
put("opacity", tok(doc, n, &["opacity"], num(f64::from(n.opacity)), t));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
put("mix-blend-mode", n.blend.css().into());
|
||||
}
|
||||
// Rotation turns counter-clockwise about the top-left corner (the position given); CSS
|
||||
// rotate turns clockwise.
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 0.01 && rot.is_finite() {
|
||||
put("transform", format!("rotate({}deg)", num(-rot)));
|
||||
put("transform-origin", "top left".into());
|
||||
}
|
||||
for (i, p) in n.fills.iter().enumerate().filter(|(_, p)| p.visible) {
|
||||
match &p.kind {
|
||||
PaintKind::Solid { color } => {
|
||||
|
|
@ -322,10 +374,7 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
|||
// A variable binds the first paint's colour.
|
||||
put(key, if i == 0 { tok(doc, n, &["fill"], c, t) } else { c });
|
||||
}
|
||||
PaintKind::Gradient(g) => {
|
||||
let stops: Vec<String> = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect();
|
||||
put("background", format!("linear-gradient({})", stops.join(", ")));
|
||||
}
|
||||
PaintKind::Gradient(g) => put("background", css_gradient(g, n.width, n.height)),
|
||||
PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()),
|
||||
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
|
||||
}
|
||||
|
|
@ -379,6 +428,24 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
|||
LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))),
|
||||
_ => {}
|
||||
}
|
||||
match tx.align_h {
|
||||
TextAlignH::Left => {}
|
||||
TextAlignH::Center => put("text-align", "center".into()),
|
||||
TextAlignH::Right => put("text-align", "right".into()),
|
||||
TextAlignH::Justified => put("text-align", "justify".into()),
|
||||
}
|
||||
match s.case {
|
||||
TextCase::Original => {}
|
||||
TextCase::Upper => put("text-transform", "uppercase".into()),
|
||||
TextCase::Lower => put("text-transform", "lowercase".into()),
|
||||
TextCase::Title => put("text-transform", "capitalize".into()),
|
||||
TextCase::SmallCaps => put("font-variant", "small-caps".into()),
|
||||
}
|
||||
match s.decoration {
|
||||
TextDecoration::None => {}
|
||||
TextDecoration::Underline => put("text-decoration", "underline".into()),
|
||||
TextDecoration::Strikethrough => put("text-decoration", "line-through".into()),
|
||||
}
|
||||
if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight {
|
||||
put("text-box", "trim-both cap alphabetic".into());
|
||||
}
|
||||
|
|
@ -386,6 +453,26 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
|||
out
|
||||
}
|
||||
|
||||
/// A gradient fill on a `w`×`h` box as a CSS background: linear along its handles' direction
|
||||
/// (CSS angles: 0deg up, 90deg right), radial with its handles' radii, angular as a conic
|
||||
/// gradient from its start direction.
|
||||
fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64) -> String {
|
||||
let stops = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect::<Vec<_>>().join(", ");
|
||||
let (dx, dy) = ((g.to.0 - g.from.0) * w, (g.to.1 - g.from.1) * h);
|
||||
let angle = if dx == 0.0 && dy == 0.0 { 180.0 } else { dx.atan2(-dy).to_degrees().rem_euclid(360.0) };
|
||||
let pct = |v: f64| format!("{}%", num(v * 100.0));
|
||||
let at = format!("at {} {}", pct(g.from.0), pct(g.from.1));
|
||||
match g.kind {
|
||||
figcraft_color::GradientKind::Linear => format!("linear-gradient({}deg, {stops})", num(angle)),
|
||||
figcraft_color::GradientKind::Radial | figcraft_color::GradientKind::Diamond => {
|
||||
let (rx, ry) = (((g.width.0 - g.from.0) * w).hypot((g.width.1 - g.from.1) * h), dx.hypot(dy));
|
||||
let rel = |r: f64, side: f64| if side > 0.0 { pct(r / side) } else { "50%".into() };
|
||||
format!("radial-gradient({} {} {at}, {stops})", rel(rx, w), rel(ry, h))
|
||||
}
|
||||
figcraft_color::GradientKind::Angular => format!("conic-gradient(from {}deg {at}, {stops})", num(angle)),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ HTML and React markup
|
||||
|
||||
/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! typography, fills, strokes, effects), each with the style or variable behind it when there is
|
||||
//! one. The Inspect tab shows these, and `inspect.properties` returns them.
|
||||
|
||||
use figcraft_color::{Color, GradientKind, PaintKind};
|
||||
use figcraft_color::{Color, GradientKind, Hsb, Hsl, PaintKind};
|
||||
use figcraft_doc::*;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -30,19 +30,76 @@ pub struct Prop {
|
|||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// A layer in the inspected selection that has export settings.
|
||||
/// Why a layer is listed as an asset.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AssetKind {
|
||||
/// It has export settings.
|
||||
Export,
|
||||
/// A small layer made only of vector shapes (exported as SVG).
|
||||
Icon,
|
||||
/// A layer filled with an image (exported as PNG).
|
||||
Image,
|
||||
}
|
||||
|
||||
/// A layer in the inspected selection to hand over as a file.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct Asset {
|
||||
pub id: NodeId,
|
||||
pub name: String,
|
||||
/// Its export settings as shown ("PNG 2x", "SVG 1x"…).
|
||||
pub kind: AssetKind,
|
||||
/// How it exports, as shown ("PNG 2x", "SVG 1x"…).
|
||||
pub exports: Vec<String>,
|
||||
/// How it exports: its export settings, or SVG for an icon and PNG for an image.
|
||||
#[serde(skip)]
|
||||
pub settings: Vec<ExportSetting>,
|
||||
}
|
||||
|
||||
/// Most assets listed for one selection.
|
||||
const MAX_ASSETS: usize = 200;
|
||||
/// Largest side of a layer listed as an icon.
|
||||
const ICON_MAX: f64 = 128.0;
|
||||
|
||||
/// The layers in and under `ids` that have export settings, in layer order.
|
||||
/// Whether `id` (visible parts only) is made of vector shapes alone: paths, booleans, stars,
|
||||
/// polygons and lines, with rectangles and ellipses among them, in groups or frames. `paths` is
|
||||
/// set when one of the former is in it.
|
||||
fn vector_only(doc: &Document, id: NodeId, depth: usize, paths: &mut bool) -> bool {
|
||||
let Some(n) = doc.node(id) else { return false };
|
||||
if !n.visible {
|
||||
return true;
|
||||
}
|
||||
if n.fills.iter().chain(&n.strokes).any(|p| matches!(p.kind, PaintKind::Image(_) | PaintKind::Pattern(_))) {
|
||||
return false;
|
||||
}
|
||||
match n.kind {
|
||||
NodeKind::Vector { .. } | NodeKind::BooleanOp { .. } | NodeKind::Star { .. } | NodeKind::Polygon { .. } | NodeKind::Line => {
|
||||
*paths = true;
|
||||
true
|
||||
}
|
||||
NodeKind::Rectangle | NodeKind::Ellipse { .. } => true,
|
||||
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
|
||||
depth < 16 && !n.children.is_empty() && n.children.iter().all(|c| vector_only(doc, *c, depth + 1, paths))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `n` is an asset, if it is one.
|
||||
fn asset_kind(doc: &Document, n: &Node) -> Option<AssetKind> {
|
||||
let mut paths = false;
|
||||
if !n.exports.is_empty() {
|
||||
Some(AssetKind::Export)
|
||||
} else if n.width <= ICON_MAX && n.height <= ICON_MAX && vector_only(doc, n.id, 0, &mut paths) && paths {
|
||||
Some(AssetKind::Icon)
|
||||
} else if n.fills.iter().any(|p| p.visible && matches!(p.kind, PaintKind::Image(_))) {
|
||||
Some(AssetKind::Image)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The assets in and under `ids`, in layer order: layers with export settings, icons and images
|
||||
/// (an icon's own shapes aren't listed again).
|
||||
pub fn assets(doc: &Document, ids: &[NodeId]) -> Vec<Asset> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<NodeId> = ids.iter().rev().copied().collect();
|
||||
|
|
@ -54,15 +111,102 @@ pub fn assets(doc: &Document, ids: &[NodeId]) -> Vec<Asset> {
|
|||
break;
|
||||
}
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if !n.exports.is_empty() && !out.iter().any(|a: &Asset| a.id == id) {
|
||||
let exports = n.exports.iter().map(|e| format!("{} {}", e.format.label(), e.size.label())).collect();
|
||||
out.push(Asset { id, name: n.name.clone(), exports });
|
||||
if !n.visible || n.is_page() {
|
||||
if n.is_page() {
|
||||
stack.extend(n.children.iter().rev().copied());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let kind = asset_kind(doc, n);
|
||||
if let Some(kind) = kind
|
||||
&& !out.iter().any(|a: &Asset| a.id == id)
|
||||
{
|
||||
let settings = match kind {
|
||||
AssetKind::Export => n.exports.clone(),
|
||||
AssetKind::Icon => vec![ExportSetting { format: ExportFormat::Svg, ..Default::default() }],
|
||||
AssetKind::Image => vec![ExportSetting::default()],
|
||||
};
|
||||
let exports = settings.iter().map(|e| format!("{} {}", e.format.label(), e.size.label())).collect();
|
||||
out.push(Asset { id, name: n.name.clone(), kind, exports, settings });
|
||||
}
|
||||
if kind != Some(AssetKind::Icon) {
|
||||
stack.extend(n.children.iter().rev().copied());
|
||||
}
|
||||
stack.extend(n.children.iter().rev().copied());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The files exporting the assets in and under `ids` makes (see [`assets`]).
|
||||
pub fn asset_jobs(doc: &Document, ids: &[NodeId]) -> Vec<crate::cmd::file::ExportJob> {
|
||||
crate::cmd::file::jobs_for(assets(doc, ids).into_iter().map(|a| (a.id, a.settings)), doc)
|
||||
}
|
||||
|
||||
/// How the Inspect tab writes colours.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum ColorFormat {
|
||||
/// `#FF8000`
|
||||
#[default]
|
||||
Hex,
|
||||
/// `255, 128, 0`
|
||||
Rgb,
|
||||
/// `rgb(255, 128, 0)`, `rgba(255, 128, 0, 0.5)`
|
||||
Css,
|
||||
/// `30, 100%, 50%`
|
||||
Hsl,
|
||||
/// `30, 100%, 100%`
|
||||
Hsb,
|
||||
}
|
||||
|
||||
impl ColorFormat {
|
||||
pub const ALL: [ColorFormat; 5] = [ColorFormat::Hex, ColorFormat::Rgb, ColorFormat::Css, ColorFormat::Hsl, ColorFormat::Hsb];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
ColorFormat::Hex => "hex",
|
||||
ColorFormat::Rgb => "rgb",
|
||||
ColorFormat::Css => "css",
|
||||
ColorFormat::Hsl => "hsl",
|
||||
ColorFormat::Hsb => "hsb",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ColorFormat::Hex => "Hex",
|
||||
ColorFormat::Rgb => "RGB",
|
||||
ColorFormat::Css => "CSS",
|
||||
ColorFormat::Hsl => "HSL",
|
||||
ColorFormat::Hsb => "HSB",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(s: &str) -> Option<ColorFormat> {
|
||||
ColorFormat::ALL.into_iter().find(|f| f.id().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
|
||||
/// `c` in this format; the opacity follows as " · 50%" (CSS puts it in `rgba`).
|
||||
pub fn format(self, c: Color) -> String {
|
||||
let [r, g, b, _] = c.to_rgba8();
|
||||
let alpha = c.a < 0.999;
|
||||
let pct = |v: f32| format!("{}%", (v * 100.0).round());
|
||||
let body = match self {
|
||||
ColorFormat::Hex => format!("#{}", c.to_hex()),
|
||||
ColorFormat::Rgb => format!("{r}, {g}, {b}"),
|
||||
ColorFormat::Css if alpha => return format!("rgba({r}, {g}, {b}, {})", num(f64::from(c.a))),
|
||||
ColorFormat::Css => return format!("rgb({r}, {g}, {b})"),
|
||||
ColorFormat::Hsl => {
|
||||
let x = Hsl::from_color(c);
|
||||
format!("{}, {}, {}", x.h.round(), pct(x.s), pct(x.l))
|
||||
}
|
||||
ColorFormat::Hsb => {
|
||||
let x = Hsb::from_color(c);
|
||||
format!("{}, {}, {}", x.h.round(), pct(x.s), pct(x.b))
|
||||
}
|
||||
};
|
||||
if alpha { format!("{body} · {}", pct(c.a)) } else { body }
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame or section with a handoff status, for Dev Mode's list of what's ready.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct DevItem {
|
||||
|
|
@ -138,7 +282,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"inspect.assets",
|
||||
"Inspect assets",
|
||||
"{ids? (default the selection)} → {assets: [{id, name, exports}]} the layers in and under them with export settings (export them with `file.exportLayers`)",
|
||||
"{ids? (default the selection)} → {assets: [{id, name, kind: export|icon|image, exports}]} the layers in and under them to hand over as files: those with export settings, icons (small, vector shapes only; SVG) and images (PNG). Export them with `file.exportLayers {assets: true}`",
|
||||
false,
|
||||
|s, p| {
|
||||
let ids = targets(s, p);
|
||||
|
|
@ -148,16 +292,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"inspect.properties",
|
||||
"Inspect properties",
|
||||
"{id?} → {sections: [{title, rows: [{label, value, color?, token?}]}]} layout (relative to the parent), auto layout, typography, fills, strokes and effects, with style and variable names",
|
||||
"{id?, colorFormat?: hex|rgb|css|hsl|hsb (default hex)} → {sections: [{title, rows: [{label, value, color?, token?}]}]} layout (positions from the frame around the layer), auto layout, typography, fills, strokes and effects, with style and variable names",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.properties";
|
||||
let id = match id(p, "id") {
|
||||
Some(i) => i,
|
||||
None => s.selection().first().copied().ok_or_else(|| crate::bad("inspect.properties", "select a layer or pass `id`"))?,
|
||||
None => s.selection().first().copied().ok_or_else(|| crate::bad(cmd, "select a layer or pass `id`"))?,
|
||||
};
|
||||
let fmt = match string(p, "colorFormat") {
|
||||
Some(f) => ColorFormat::from_id(&f).ok_or_else(|| crate::bad(cmd, "colorFormat: hex|rgb|css|hsl|hsb"))?,
|
||||
None => ColorFormat::Hex,
|
||||
};
|
||||
let doc = s.doc()?;
|
||||
doc.node(id).ok_or_else(|| crate::bad("inspect.properties", "no such layer"))?;
|
||||
Ok(json!({"sections": serde_json::to_value(properties(doc, id)).unwrap_or(Value::Null)}))
|
||||
doc.node(id).ok_or_else(|| crate::bad(cmd, "no such layer"))?;
|
||||
Ok(json!({"sections": serde_json::to_value(properties_with(doc, id, fmt)).unwrap_or(Value::Null)}))
|
||||
}
|
||||
),
|
||||
]
|
||||
|
|
@ -173,11 +322,6 @@ fn num(v: f64) -> String {
|
|||
if s == "-0" { "0".into() } else { s.to_string() }
|
||||
}
|
||||
|
||||
/// `#RRGGBB` plus the opacity when it isn't 100%.
|
||||
fn color_text(c: Color) -> String {
|
||||
if c.a < 0.999 { format!("#{} · {}%", c.to_hex(), (c.a * 100.0).round()) } else { format!("#{}", c.to_hex()) }
|
||||
}
|
||||
|
||||
fn prop(label: impl Into<String>, value: impl Into<String>) -> Prop {
|
||||
Prop { label: label.into(), value: value.into(), color: None, token: None }
|
||||
}
|
||||
|
|
@ -214,8 +358,14 @@ fn style_name(doc: &Document, id: Option<&StyleId>) -> Option<String> {
|
|||
id.and_then(|s| doc.style(s)).map(|s| s.name.clone())
|
||||
}
|
||||
|
||||
/// The properties of `id`, grouped; empty when it doesn't exist.
|
||||
/// The properties of `id`, grouped, colours in hex; empty when it doesn't exist.
|
||||
pub fn properties(doc: &Document, id: NodeId) -> Vec<Section> {
|
||||
properties_with(doc, id, ColorFormat::Hex)
|
||||
}
|
||||
|
||||
/// [`properties`] with colours written in `fmt`.
|
||||
pub fn properties_with(doc: &Document, id: NodeId, fmt: ColorFormat) -> Vec<Section> {
|
||||
let color_text = |c: Color| fmt.format(c);
|
||||
let Some(n) = doc.node(id) else { return Vec::new() };
|
||||
let mut out = vec![layout(doc, id, n)];
|
||||
if let Some(l) = &n.layout {
|
||||
|
|
@ -233,7 +383,7 @@ pub fn properties(doc: &Document, id: NodeId) -> Vec<Section> {
|
|||
.filter(|(_, p)| p.visible)
|
||||
.map(|(i, p)| {
|
||||
let token = crate::cmd::lists::paint_binding_key(n, fills, i).and_then(|k| variable(doc, n, k));
|
||||
paint_row(p).token(token)
|
||||
paint_row(p, fmt).token(token)
|
||||
})
|
||||
.collect();
|
||||
match style_name(doc, style) {
|
||||
|
|
@ -313,11 +463,11 @@ pub fn properties(doc: &Document, id: NodeId) -> Vec<Section> {
|
|||
|
||||
/// A style or variable applies to the whole list: show it on the first row.
|
||||
/// One paint's row: its kind and colour (or stops, or opacity for images).
|
||||
fn paint_row(p: &figcraft_color::Paint) -> Prop {
|
||||
fn paint_row(p: &figcraft_color::Paint, fmt: ColorFormat) -> Prop {
|
||||
match &p.kind {
|
||||
PaintKind::Solid { color } => {
|
||||
let c = color.with_alpha(color.a * p.opacity);
|
||||
prop("Solid", color_text(c)).color(c)
|
||||
prop("Solid", fmt.format(c)).color(c)
|
||||
}
|
||||
PaintKind::Gradient(g) => {
|
||||
let kind = match g.kind {
|
||||
|
|
@ -326,7 +476,7 @@ fn paint_row(p: &figcraft_color::Paint) -> Prop {
|
|||
GradientKind::Angular => "Angular",
|
||||
GradientKind::Diamond => "Diamond",
|
||||
};
|
||||
let stops: Vec<String> = g.stops.iter().map(|s| format!("#{} {}%", s.color.to_hex(), (s.position * 100.0).round())).collect();
|
||||
let stops: Vec<String> = g.stops.iter().map(|s| format!("{} {}%", fmt.format(s.color), (s.position * 100.0).round())).collect();
|
||||
let r = prop(kind, stops.join(", "));
|
||||
match g.stops.first() {
|
||||
Some(s) => r.color(s.color),
|
||||
|
|
@ -348,13 +498,10 @@ fn tag_first(mut rows: Vec<Prop>, token: Option<String>) -> Vec<Prop> {
|
|||
fn layout(doc: &Document, id: NodeId, n: &Node) -> Section {
|
||||
let mut rows =
|
||||
vec![prop("Width", num(n.width)).token(variable(doc, n, "width")), prop("Height", num(n.height)).token(variable(doc, n, "height"))];
|
||||
// Position from the parent frame's top-left (from the page origin at the top level).
|
||||
// Position from the top-left of the frame around it (groups aren't frames; the canvas
|
||||
// origin at the top level).
|
||||
if let Some(b) = doc.world_bounds(id) {
|
||||
let origin = n
|
||||
.parent
|
||||
.filter(|p| !doc.node(*p).is_some_and(|pn| pn.is_page()))
|
||||
.and_then(|p| doc.world_bounds(p))
|
||||
.map_or((0.0, 0.0), |pb| (pb.x0, pb.y0));
|
||||
let origin = doc.containing_frame(id).and_then(|f| doc.world_bounds(f)).map_or((0.0, 0.0), |pb| (pb.x0, pb.y0));
|
||||
rows.push(prop("Top", num(b.y0 - origin.1)));
|
||||
rows.push(prop("Left", num(b.x0 - origin.0)));
|
||||
}
|
||||
|
|
@ -365,6 +512,9 @@ fn layout(doc: &Document, id: NodeId, n: &Node) -> Section {
|
|||
if n.opacity < 0.999 {
|
||||
rows.push(prop("Opacity", format!("{}%", (f64::from(n.opacity) * 100.0).round())).token(variable(doc, n, "opacity")));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
rows.push(prop("Blend", n.blend.label()));
|
||||
}
|
||||
if n.kind.has_corner_radius() {
|
||||
let r = match n.corners.uniform() {
|
||||
Some(r) if r > 0.0 => Some(num(r)),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,332 @@
|
|||
//! Export behaviour suite, written from the public documentation of design tools: export settings
|
||||
//! (scales, fixed widths and heights, suffixes, formats, the advanced options), what an export
|
||||
//! covers (effects, strokes, hidden layers, masks, slices, clipping, rotation, sub-pixel sizes),
|
||||
//! exporting several layers, Export frames to PDF and the Copy as actions. Each rule is a table row
|
||||
//! or an assert with a one-line derivation; where the documentation is silent the row says so and
|
||||
//! pins today's behaviour.
|
||||
|
||||
// The tables read best as tuples.
|
||||
#![allow(clippy::type_complexity)]
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::{Effect, EffectKind, NodeId};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// A rectangle filled with `fill` (hex).
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, fill: &str, parent: Option<NodeId>) -> NodeId {
|
||||
let mut p = json!({"x": x, "y": y, "width": w, "height": h});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
let r = id(&ex(s, "shape.rectangle", p));
|
||||
set(s, &[r], json!({"fills": [fill]}));
|
||||
r
|
||||
}
|
||||
|
||||
fn frame(s: &mut Session, x: f64, y: f64, w: f64, h: f64, clip: bool) -> NodeId {
|
||||
let f = id(&ex(s, "frame.create", json!({"x": x, "y": y, "width": w, "height": h})));
|
||||
set(s, &[f], json!({"clip": clip, "fills": []}));
|
||||
f
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, ids: &[NodeId], props: Value) {
|
||||
ex(s, "node.set", json!({"ids": ids, "props": props}));
|
||||
}
|
||||
|
||||
fn name(s: &mut Session, id: NodeId, name: &str) {
|
||||
set(s, &[id], json!({"name": name}));
|
||||
}
|
||||
|
||||
/// The bytes `file.export` returns for `p`.
|
||||
fn export(s: &mut Session, p: Value) -> Vec<u8> {
|
||||
let r = ex(s, "file.export", p);
|
||||
base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn png_size(b: &[u8]) -> (u32, u32) {
|
||||
assert!(b.starts_with(b"\x89PNG"), "not a PNG");
|
||||
(u32::from_be_bytes([b[16], b[17], b[18], b[19]]), u32::from_be_bytes([b[20], b[21], b[22], b[23]]))
|
||||
}
|
||||
|
||||
fn pixels(b: &[u8]) -> image::RgbaImage {
|
||||
image::load_from_memory(b).unwrap().to_rgba8()
|
||||
}
|
||||
|
||||
fn text(b: &[u8]) -> String {
|
||||
String::from_utf8(b.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
/// A drop shadow: offset, blur radius, spread.
|
||||
fn shadow(s: &mut Session, id: NodeId, offset: (f64, f64), radius: f64, spread: f64) {
|
||||
let mut e = Effect::of_type("dropShadow").unwrap();
|
||||
if let EffectKind::DropShadow(sh) = &mut e.kind {
|
||||
(sh.offset, sh.radius, sh.spread) = (offset, radius, spread);
|
||||
}
|
||||
s.doc_mut().unwrap().get_mut(id).unwrap().effects.push(e);
|
||||
}
|
||||
|
||||
fn file_names(r: &Value) -> Vec<String> {
|
||||
r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_sizes_follow_the_scale_or_the_fixed_side() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, "#FF0000", None);
|
||||
// (size, pixel size, derivation)
|
||||
let rows: [(&str, (u32, u32), &str); 10] = [
|
||||
("0.5x", (50, 25), "half scale halves both sides"),
|
||||
("0.75x", (75, 38), "docs silent: a fractional side rounds up (37.5 → 38)"),
|
||||
("1x", (100, 50), "1x is the layer's own size"),
|
||||
("1.5x", (150, 75), "1.5x multiplies both sides"),
|
||||
("2x", (200, 100), "2x doubles both sides"),
|
||||
("3x", (300, 150), "3x triples both sides"),
|
||||
("4x", (400, 200), "4x is the largest preset"),
|
||||
("512w", (512, 256), "a width fixes the width; the height keeps the proportions"),
|
||||
("200h", (400, 200), "a height fixes the height; the width keeps the proportions"),
|
||||
("64w", (64, 32), "a fixed width can also shrink"),
|
||||
];
|
||||
for (size, want, why) in rows {
|
||||
assert_eq!(png_size(&export(&mut s, json!({"ids": [r], "size": size}))), want, "{size}: {why}");
|
||||
}
|
||||
assert_eq!(png_size(&export(&mut s, json!({"ids": [r], "scale": 3}))), (300, 150), "`scale` is the same as `3x`");
|
||||
let odd = rect(&mut s, 0.0, 100.0, 100.0, 30.0, "#FF0000", None);
|
||||
assert_eq!(png_size(&export(&mut s, json!({"ids": [odd], "size": "512w"}))), (512, 154), "docs silent: the other side rounds up (153.6)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_file_names_take_the_suffix_and_slashes_make_folders() {
|
||||
let mut s = Session::new();
|
||||
let icon = rect(&mut s, 0.0, 0.0, 32.0, 32.0, "#FF0000", None);
|
||||
name(&mut s, icon, "Icon");
|
||||
ex(&mut s, "export.add", json!({"ids": [icon]}));
|
||||
ex(&mut s, "export.add", json!({"ids": [icon]}));
|
||||
ex(&mut s, "export.add", json!({"ids": [icon], "setting": {"size": "1x", "suffix": "-dark", "format": "svg"}}));
|
||||
ex(&mut s, "export.add", json!({"ids": [icon], "setting": {"size": "1x", "format": "png"}}));
|
||||
let close = rect(&mut s, 50.0, 0.0, 16.0, 16.0, "#000000", None);
|
||||
name(&mut s, close, "icons/close");
|
||||
ex(&mut s, "export.add", json!({"ids": [close]}));
|
||||
let sneaky = rect(&mut s, 80.0, 0.0, 16.0, 16.0, "#000000", None);
|
||||
name(&mut s, sneaky, "../up/./x");
|
||||
ex(&mut s, "export.add", json!({"ids": [sneaky]}));
|
||||
let r = ex(&mut s, "file.exportLayers", json!({"ids": [icon, close, sneaky]}));
|
||||
// (file, derivation)
|
||||
let rows: [(&str, &str); 6] = [
|
||||
("Icon.png", "a 1x setting without a suffix is the layer name"),
|
||||
("Icon@2x.png", "a new setting steps the scale up and fills in a matching @2x suffix"),
|
||||
("Icon-dark.svg", "the suffix goes after the layer name (docs silent on a prefix: there is none, names start with the layer name)"),
|
||||
("Icon 2.png", "docs silent: a name used twice gets a number"),
|
||||
("icons/close.png", "a slash in a layer name exports into a folder of that name"),
|
||||
("up/x.png", "docs silent: `.` and `..` segments are dropped, so a name never leaves the export folder"),
|
||||
];
|
||||
let names = file_names(&r);
|
||||
for (i, (want, why)) in rows.iter().enumerate() {
|
||||
assert_eq!(names.get(i).map(String::as_str), Some(*want), "{why}");
|
||||
}
|
||||
// Into a folder: the slash makes a subfolder.
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-export-names-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
ex(&mut s, "file.exportLayers", json!({"ids": [close], "dir": dir.to_string_lossy()}));
|
||||
assert!(dir.join("icons").join("close.png").is_file(), "the folder is created");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_formats_and_what_each_keeps() {
|
||||
let mut s = Session::new();
|
||||
let e = id(&ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 40, "height": 40})));
|
||||
set(&mut s, &[e], json!({"fills": ["#FF0000"]}));
|
||||
let png = export(&mut s, json!({"ids": [e], "format": "png"}));
|
||||
let jpg = export(&mut s, json!({"ids": [e], "format": "jpg"}));
|
||||
let svg = export(&mut s, json!({"ids": [e], "format": "svg"}));
|
||||
let pdf = export(&mut s, json!({"ids": [e], "format": "pdf"}));
|
||||
// (bytes, signature, derivation)
|
||||
let rows: [(&[u8], &[u8], &str); 4] = [
|
||||
(&png, b"\x89PNG", "PNG is a raster image"),
|
||||
(&jpg, b"\xFF\xD8\xFF", "JPG is a raster image"),
|
||||
(&svg, b"<svg", "SVG is vector markup"),
|
||||
(&pdf, b"%PDF", "PDF is a vector document"),
|
||||
];
|
||||
for (bytes, sig, why) in rows {
|
||||
assert!(bytes.starts_with(sig), "{why}");
|
||||
}
|
||||
assert_eq!(pixels(&png).get_pixel(0, 0)[3], 0, "PNG keeps transparency outside the circle");
|
||||
let corner = *pixels(&jpg).get_pixel(0, 0);
|
||||
assert!(corner[0] > 245 && corner[1] > 245 && corner[2] > 245, "JPG has no transparency: transparent areas are white");
|
||||
let mid = *pixels(&jpg).get_pixel(20, 20);
|
||||
assert!(mid[0] > 240 && mid[1] < 16 && mid[2] < 16, "docs silent: JPG quality is fixed and high (flat colours come back within a few levels)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contents_only_leaves_out_overlapping_layers() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#FF0000", None);
|
||||
rect(&mut s, 50.0, 50.0, 100.0, 100.0, "#0000FF", None);
|
||||
let own = pixels(&export(&mut s, json!({"ids": [a]})));
|
||||
assert_eq!(own.get_pixel(75, 75).0, [255, 0, 0, 255], "by default only the layer's own contents are exported");
|
||||
let all = export(&mut s, json!({"ids": [a], "contentsOnly": false}));
|
||||
assert_eq!(png_size(&all), (100, 100), "with contents only off the export keeps the layer's bounds");
|
||||
assert_eq!(pixels(&all).get_pixel(75, 75).0, [0, 0, 255, 255], "with contents only off, layers overlapping it are included");
|
||||
// The setting is kept on the layer and used by its exports.
|
||||
ex(&mut s, "export.add", json!({"ids": [a], "setting": {"size": "1x", "contentsOnly": false}}));
|
||||
assert!(!s.doc().unwrap().node(a).unwrap().exports[0].contents_only, "the export setting keeps the option");
|
||||
let r = ex(&mut s, "file.exportLayers", json!({"ids": [a]}));
|
||||
let data = base64::engine::general_purpose::STANDARD.decode(r["files"][0]["data"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(pixels(&data).get_pixel(75, 75).0, [0, 0, 255, 255], "a layer's own setting is honoured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_an_export_covers() {
|
||||
let mut s = Session::new();
|
||||
let size = |s: &mut Session, id: NodeId| png_size(&export(s, json!({"ids": [id]})));
|
||||
// Strokes: (align, weight, size, derivation)
|
||||
let strokes: [(&str, f64, (u32, u32), &str); 3] = [
|
||||
("inside", 4.0, (100, 50), "an inside stroke stays within the layer"),
|
||||
("center", 4.0, (104, 54), "a centre stroke reaches half its weight outside"),
|
||||
("outside", 4.0, (108, 58), "an outside stroke reaches its whole weight outside"),
|
||||
];
|
||||
for (k, (align, w, want, why)) in strokes.into_iter().enumerate() {
|
||||
let r = rect(&mut s, 0.0, 200.0 * k as f64, 100.0, 50.0, "#FF0000", None);
|
||||
set(&mut s, &[r], json!({"strokes": ["#000000"], "strokeWeight": w, "strokeAlign": align}));
|
||||
assert_eq!(size(&mut s, r), want, "{why}");
|
||||
}
|
||||
// A shadow extends the export towards its offset: blur 4 reaches 6 (1.5 × blur, docs silent on
|
||||
// the exact reach), moved 10 down, so 6 more on the left and right, 16 more below, none above.
|
||||
let r = rect(&mut s, 400.0, 0.0, 100.0, 100.0, "#FF0000", None);
|
||||
shadow(&mut s, r, (0.0, 10.0), 4.0, 0.0);
|
||||
assert_eq!(size(&mut s, r), (112, 116), "effects extend the bounds, in the direction the shadow falls");
|
||||
assert_eq!(pixels(&export(&mut s, json!({"ids": [r]}))).get_pixel(6, 0).0, [255, 0, 0, 255], "the layer's top edge is the image's top edge");
|
||||
// Include bounding box: the layer's box, whatever it paints outside it.
|
||||
let b = export(&mut s, json!({"ids": [r], "includeBoundingBox": true}));
|
||||
assert_eq!(png_size(&b), (100, 100), "include bounding box exports the layer's own box");
|
||||
// Frames: clipping frames export their box, others everything they hold.
|
||||
let clip = frame(&mut s, 0.0, 800.0, 100.0, 100.0, true);
|
||||
rect(&mut s, 50.0, 50.0, 100.0, 100.0, "#00FF00", Some(clip));
|
||||
assert_eq!(size(&mut s, clip), (100, 100), "a frame that clips its content exports its own box");
|
||||
let open = frame(&mut s, 300.0, 800.0, 100.0, 100.0, false);
|
||||
rect(&mut s, 50.0, 50.0, 100.0, 100.0, "#00FF00", Some(open));
|
||||
assert_eq!(size(&mut s, open), (150, 150), "a frame that doesn't clip exports the content outside it too");
|
||||
// Hidden layers paint nothing and take no room.
|
||||
let holder = frame(&mut s, 600.0, 800.0, 100.0, 100.0, false);
|
||||
let hidden = rect(&mut s, 50.0, 50.0, 200.0, 200.0, "#00FF00", Some(holder));
|
||||
set(&mut s, &[hidden], json!({"visible": false}));
|
||||
assert_eq!(size(&mut s, holder), (100, 100), "a hidden layer doesn't make the export larger");
|
||||
let lone = export(&mut s, json!({"ids": [hidden]}));
|
||||
assert!(pixels(&lone).pixels().all(|p| p[3] == 0), "docs silent: exporting a hidden layer gives an empty image of its size");
|
||||
// Rotation: the export is the rotated layer's upright bounding box.
|
||||
let rot = rect(&mut s, 0.0, 1200.0, 100.0, 50.0, "#FF0000", None);
|
||||
set(&mut s, &[rot], json!({"rotation": 90}));
|
||||
assert_eq!(size(&mut s, rot), (50, 100), "a layer turned 90° exports its upright bounds");
|
||||
let rot45 = rect(&mut s, 300.0, 1200.0, 100.0, 100.0, "#FF0000", None);
|
||||
set(&mut s, &[rot45], json!({"rotation": 45}));
|
||||
assert_eq!(size(&mut s, rot45), (142, 142), "a square turned 45° exports its diagonal (141.4, rounded up)");
|
||||
// Sub-pixel bounds round outwards to whole pixels at the export scale (docs silent).
|
||||
let sub = rect(&mut s, 600.5, 1200.25, 10.2, 10.2, "#FF0000", None);
|
||||
assert_eq!(size(&mut s, sub), (11, 11), "docs silent: 10.2 rounds up to 11");
|
||||
assert_eq!(png_size(&export(&mut s, json!({"ids": [sub], "scale": 2}))), (21, 21), "docs silent: at 2x 20.4 rounds up to 21");
|
||||
assert!(pixels(&export(&mut s, json!({"ids": [sub]}))).get_pixel(0, 0)[3] > 200, "the image starts at the layer's own edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_and_slices_export_what_shows() {
|
||||
let mut s = Session::new();
|
||||
// A 50×50 circle masking a 200×200 square: the group exports what the mask shows.
|
||||
let m = id(&ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 50, "height": 50})));
|
||||
let big = rect(&mut s, 0.0, 0.0, 200.0, 200.0, "#FF0000", None);
|
||||
let g = id(&ex(&mut s, "object.mask", json!({"ids": [m, big]})));
|
||||
assert_eq!(png_size(&export(&mut s, json!({"ids": [g]}))), (50, 50), "a mask limits what shows, so the export is the mask's area");
|
||||
// A slice exports everything visible in its area, from any layer.
|
||||
let under = rect(&mut s, 300.0, 0.0, 100.0, 100.0, "#00FF00", None);
|
||||
let slice = id(&ex(&mut s, "node.create", json!({"type": "slice", "x": 325, "y": 25, "width": 50, "height": 50})));
|
||||
let png = export(&mut s, json!({"ids": [slice]}));
|
||||
assert_eq!(png_size(&png), (50, 50), "a slice exports its own area");
|
||||
assert_eq!(pixels(&png).get_pixel(25, 25).0, [0, 255, 0, 255], "a slice exports the layers under it");
|
||||
let _ = under;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_layers_and_frames_to_pdf() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0, "#FF0000", None);
|
||||
let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0, "#0000FF", None);
|
||||
name(&mut s, a, "A");
|
||||
name(&mut s, b, "B");
|
||||
ex(&mut s, "export.add", json!({"ids": [a, b]}));
|
||||
ex(&mut s, "export.add", json!({"ids": [b], "setting": {"size": "1x", "format": "svg"}}));
|
||||
let r = ex(&mut s, "file.exportLayers", json!({"ids": [a, b]}));
|
||||
assert_eq!(file_names(&r), ["A.png", "B.png", "B.svg"], "several selected layers export one file per layer and setting");
|
||||
// docs silent: one-off `file.export` of a selection draws the layers into one file covering them all.
|
||||
assert_eq!(png_size(&export(&mut s, json!({"ids": [a, b]}))), (120, 40), "docs silent: a one-off export of several layers is one image");
|
||||
// Export frames to PDF: one page per visible top-level frame, in reading order.
|
||||
let f1 = frame(&mut s, 0.0, 500.0, 100.0, 100.0, true);
|
||||
let f2 = frame(&mut s, 200.0, 500.0, 100.0, 100.0, true);
|
||||
let f3 = frame(&mut s, 400.0, 500.0, 100.0, 100.0, true);
|
||||
set(&mut s, &[f3], json!({"visible": false}));
|
||||
let r = ex(&mut s, "file.exportFramesPdf", json!({}));
|
||||
assert_eq!(r["pages"], 2, "every visible top-level frame is one page; other layers and hidden frames are left out");
|
||||
// A PDF of several frames from the export settings: one page each.
|
||||
let pdf = export(&mut s, json!({"ids": [f1, f2], "format": "pdf"}));
|
||||
let raw = String::from_utf8_lossy(&pdf);
|
||||
assert_eq!(raw.matches("/Type/Page").count() - raw.matches("/Type/Pages").count(), 2, "frames exported to PDF get a page each");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_export_options() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0, "#FF0000", None);
|
||||
let b = rect(&mut s, 50.0, 0.0, 40.0, 40.0, "#FF0000", None);
|
||||
name(&mut s, a, "Card");
|
||||
name(&mut s, b, "Card");
|
||||
let plain = text(&export(&mut s, json!({"ids": [a, b], "format": "svg"})));
|
||||
assert!(!plain.contains("id=\"Card"), "the id attribute is off by default");
|
||||
let ids = text(&export(&mut s, json!({"ids": [a, b], "format": "svg", "svgIdAttribute": true})));
|
||||
assert!(ids.contains("id=\"Card\""), "include id attribute names elements after their layers");
|
||||
assert!(ids.contains("id=\"Card_2\""), "docs silent: ids are unique in the file, so a repeated name gets a number");
|
||||
// Text: outlined by default; kept as text when outlining is off.
|
||||
let t = id(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 0, "y": 100})));
|
||||
let outlined = text(&export(&mut s, json!({"ids": [t], "format": "svg"})));
|
||||
assert!(!outlined.contains("<text"), "outline text is on by default: text becomes paths");
|
||||
let live = text(&export(&mut s, json!({"ids": [t], "format": "svg", "svgOutlineText": false})));
|
||||
// Without a font nothing lays out, so there is no text to keep (CI has no fonts).
|
||||
let has_glyphs = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).is_some_and(|(l, _)| !l.glyphs.is_empty());
|
||||
if has_glyphs {
|
||||
assert!(live.contains("<text") && live.contains("Hello"), "with outline text off, text stays text (selectable, needs the font)");
|
||||
}
|
||||
// Strokes: simplified to stroke attributes by default.
|
||||
let st = rect(&mut s, 0.0, 200.0, 40.0, 40.0, "#FF0000", None);
|
||||
set(&mut s, &[st], json!({"strokes": ["#000000"], "strokeWeight": 4, "strokeAlign": "center"}));
|
||||
let simple = text(&export(&mut s, json!({"ids": [st], "format": "svg"})));
|
||||
assert!(simple.contains("stroke-width=\"4\""), "simplify stroke is on by default: a stroke attribute where it can be");
|
||||
let exact = text(&export(&mut s, json!({"ids": [st], "format": "svg", "svgSimplifyStroke": false})));
|
||||
assert!(!exact.contains("stroke-width"), "with simplify stroke off, strokes are exact outlines");
|
||||
let inside = rect(&mut s, 100.0, 200.0, 40.0, 40.0, "#FF0000", None);
|
||||
set(&mut s, &[inside], json!({"strokes": ["#000000"], "strokeWeight": 4, "strokeAlign": "inside", "cornerRadius": 8}));
|
||||
let svg = text(&export(&mut s, json!({"ids": [inside], "format": "svg"})));
|
||||
assert!(svg.contains("stroke-width=\"4\""), "an inside stroke on a rectangle simplifies too (the rectangle inset by half the weight)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_as_png_svg_and_code() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 20.0, "#FF0000", None);
|
||||
let b = rect(&mut s, 60.0, 0.0, 40.0, 20.0, "#0000FF", None);
|
||||
let doc = s.doc().unwrap();
|
||||
let png = crate::cmd::file::copy_png(doc, &[a, b]).unwrap();
|
||||
assert_eq!((png.width(), png.height()), (200, 40), "docs silent: Copy as PNG copies the selection at 2x, all of it in one image");
|
||||
let svg = crate::cmd::file::export_bytes(doc, &[a, b], figcraft_doc::ExportFormat::Svg, 1.0).unwrap();
|
||||
assert!(text(&svg).starts_with("<svg width=\"100\""), "Copy as SVG is the selection's SVG export");
|
||||
let css = crate::codegen::code_for(doc, a, crate::codegen::CodeLang::Css);
|
||||
assert!(css.contains("background: #FF0000;"), "Copy as CSS is the layer's CSS from Inspect");
|
||||
}
|
||||
|
|
@ -187,7 +187,7 @@ fn export_layers_uses_each_layers_settings() {
|
|||
let r = s.execute("file.exportLayers", &json!({"ids": [a, b, c]})).unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Icon.png", "Icon@2x.png", "Icon 2.png", "Logo_Dark.svg"], "unique, safe names");
|
||||
assert_eq!(names, ["Icon.png", "Icon@2x.png", "Icon 2.png", "Logo/Dark.svg"], "unique names; a slash makes a folder");
|
||||
assert_eq!(r["skipped"], 1);
|
||||
let data = |i: usize| base64::engine::general_purpose::STANDARD.decode(files[i]["data"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(png_width(&data(0)), 32);
|
||||
|
|
@ -200,7 +200,7 @@ fn export_layers_uses_each_layers_settings() {
|
|||
let dir = temp_dir("exportlayers");
|
||||
let r = s.execute("file.exportLayers", &json!({"dir": dir.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(4));
|
||||
assert!(dir.join("Icon@2x.png").is_file() && dir.join("Logo_Dark.svg").is_file());
|
||||
assert!(dir.join("Icon@2x.png").is_file() && dir.join("Logo").join("Dark.svg").is_file());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// Nothing to export.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,325 @@
|
|||
//! Inspect behaviour suite, written from the public documentation of design tools' inspect and
|
||||
//! developer handoff modes: the reported layout (size, position, rotation, radius, opacity, blend),
|
||||
//! colour formats, units, typography, the generated CSS, SwiftUI and Compose for common layers,
|
||||
//! the asset list and redline measurements. Each rule is a table row or an assert with a one-line
|
||||
//! derivation; where the documentation is silent the row says so and pins today's behaviour.
|
||||
|
||||
// The tables read best as tuples.
|
||||
#![allow(clippy::type_complexity)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_color::{Color, Gradient, GradientKind, Paint, PaintKind};
|
||||
use figcraft_doc::{LetterSpacing, LineHeight, NodeId};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
|
||||
let mut p = json!({"x": x, "y": y, "width": w, "height": h});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
let r = id(&ex(s, "shape.rectangle", p));
|
||||
set(s, &[r], json!({"fills": ["#FF0000"]}));
|
||||
r
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, ids: &[NodeId], props: Value) {
|
||||
ex(s, "node.set", json!({"ids": ids, "props": props}));
|
||||
}
|
||||
|
||||
/// The value of the inspected row `label` (first match), with extra `params`.
|
||||
fn row(s: &mut Session, id: NodeId, label: &str, params: Value) -> Option<String> {
|
||||
let mut p = params;
|
||||
p["id"] = json!(id);
|
||||
let r = ex(s, "inspect.properties", p);
|
||||
r["sections"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.flat_map(|sec| sec["rows"].as_array().cloned().unwrap_or_default())
|
||||
.find_map(|row| (row["label"] == label).then(|| row["value"].as_str().unwrap_or_default().to_string()))
|
||||
}
|
||||
|
||||
fn code(s: &mut Session, id: NodeId, params: Value) -> String {
|
||||
let mut p = params;
|
||||
p["id"] = json!(id);
|
||||
ex(s, "inspect.code", p)["code"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn css(s: &mut Session, id: NodeId) -> String {
|
||||
code(s, id, json!({}))
|
||||
}
|
||||
|
||||
fn gradient(kind: GradientKind, from: (f64, f64), to: (f64, f64)) -> Paint {
|
||||
let mut g = Gradient::default_for(kind, Color::BLACK);
|
||||
(g.from, g.to) = (from, to);
|
||||
Paint { kind: PaintKind::Gradient(g), ..Paint::solid(Color::BLACK) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_rows_report_size_position_and_look() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&ex(&mut s, "frame.create", json!({"x": 100, "y": 100, "width": 400, "height": 300})));
|
||||
// Positions given with a parent are inside it.
|
||||
let r = rect(&mut s, 10.0, 20.0, 80.0, 40.0, Some(f));
|
||||
let a = rect(&mut s, 100.0, 50.0, 20.0, 20.0, Some(f));
|
||||
let b = rect(&mut s, 160.0, 50.0, 20.0, 20.0, Some(f));
|
||||
let g = id(&ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
let top = rect(&mut s, 700.0, 50.0, 10.0, 10.0, None);
|
||||
set(&mut s, &[r], json!({"opacity": 0.5, "blend": "multiply", "cornerRadius": 8}));
|
||||
let mixed = rect(&mut s, 20.0, 100.0, 40.0, 40.0, Some(f));
|
||||
set(&mut s, &[mixed], json!({"topLeftRadius": 1, "topRightRadius": 2, "bottomRightRadius": 3, "bottomLeftRadius": 4}));
|
||||
let turned = rect(&mut s, 200.0, 200.0, 40.0, 40.0, Some(f));
|
||||
set(&mut s, &[turned], json!({"rotation": 30}));
|
||||
let _ = g;
|
||||
// (layer, label, value, derivation)
|
||||
let rows: [(NodeId, &str, &str, &str); 11] = [
|
||||
(r, "Width", "80", "the layer's width"),
|
||||
(r, "Height", "40", "the layer's height"),
|
||||
(r, "Left", "10", "positions are measured from the parent frame's top-left"),
|
||||
(r, "Top", "20", "positions are measured from the parent frame's top-left"),
|
||||
(a, "Left", "100", "groups aren't frames: a grouped layer is measured from the frame around the group"),
|
||||
(top, "Left", "700", "a top-level layer is measured from the canvas origin"),
|
||||
(r, "Opacity", "50%", "layer opacity in percent"),
|
||||
(r, "Blend", "Multiply", "a blend mode other than normal is reported"),
|
||||
(r, "Radius", "8", "one radius for all corners"),
|
||||
(mixed, "Radius", "1 2 3 4", "mixed radii in CSS order: top-left, top-right, bottom-right, bottom-left"),
|
||||
(turned, "Rotation", "30°", "rotation in degrees"),
|
||||
];
|
||||
for (layer, label, want, why) in rows {
|
||||
assert_eq!(row(&mut s, layer, label, json!({})).as_deref(), Some(want), "{label}: {why}");
|
||||
}
|
||||
assert_eq!(row(&mut s, top, "Blend", json!({})), None, "normal blending isn't reported");
|
||||
assert_eq!(row(&mut s, top, "Opacity", json!({})), None, "full opacity isn't reported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_formats() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0, None);
|
||||
set(&mut s, &[r], json!({"fills": ["#FF8000"]}));
|
||||
let half = rect(&mut s, 20.0, 0.0, 10.0, 10.0, None);
|
||||
s.doc_mut().unwrap().get_mut(half).unwrap().fills = vec![Paint::solid(Color::from_hex("#FF8000").unwrap().with_alpha(0.5))];
|
||||
// (format, opaque, half transparent, derivation)
|
||||
let rows: [(&str, &str, &str, &str); 5] = [
|
||||
("hex", "#FF8000", "#FF8000 · 50%", "hex with the opacity beside it (the default)"),
|
||||
("rgb", "255, 128, 0", "255, 128, 0 · 50%", "red, green and blue from 0 to 255"),
|
||||
("css", "rgb(255, 128, 0)", "rgba(255, 128, 0, 0.5)", "a CSS colour value"),
|
||||
("hsl", "30, 100%, 50%", "30, 100%, 50% · 50%", "hue in degrees, saturation and lightness in percent"),
|
||||
("hsb", "30, 100%, 100%", "30, 100%, 100% · 50%", "hue in degrees, saturation and brightness in percent"),
|
||||
];
|
||||
for (format, opaque, translucent, why) in rows {
|
||||
assert_eq!(row(&mut s, r, "Solid", json!({"colorFormat": format})).as_deref(), Some(opaque), "{format}: {why}");
|
||||
assert_eq!(row(&mut s, half, "Solid", json!({"colorFormat": format})).as_deref(), Some(translucent), "{format}: {why}");
|
||||
}
|
||||
assert_eq!(row(&mut s, r, "Solid", json!({})).as_deref(), Some("#FF8000"), "hex is the default");
|
||||
assert!(s.execute("inspect.properties", &json!({"id": r, "colorFormat": "cmyk"})).is_err(), "unknown formats are refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_units() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 40.0, None);
|
||||
let px = css(&mut s, r);
|
||||
assert!(px.contains("width: 100px;"), "pixels are the default unit");
|
||||
let rem = code(&mut s, r, json!({"unit": "rem"}));
|
||||
assert!(rem.contains("width: 6.25rem;") && rem.contains("height: 2.5rem;"), "rem divides by the root font size, 16 by default:\n{rem}");
|
||||
let ten = code(&mut s, r, json!({"unit": "rem", "rootFontSize": 10}));
|
||||
assert!(ten.contains("width: 10rem;"), "the root font size can be changed");
|
||||
assert!(s.execute("inspect.code", &json!({"id": r, "unit": "em"})).is_err(), "unknown units are refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typography_rows_and_css() {
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 0, "y": 0})));
|
||||
set(&mut s, &[t], json!({"fontSize": 24, "textAlign": "center", "textCase": "upper", "textDecoration": "underline"}));
|
||||
if let Some(tx) = s.doc_mut().unwrap().get_mut(t).unwrap().kind.text_mut() {
|
||||
tx.style.line_height = LineHeight::Percent(150.0);
|
||||
tx.style.letter_spacing = LetterSpacing::Percent(5.0);
|
||||
}
|
||||
// (label, value, derivation)
|
||||
let rows: [(&str, &str, &str); 6] = [
|
||||
("Font", "Inter", "the font family"),
|
||||
("Style", "Regular", "the font style"),
|
||||
("Size", "24", "the font size"),
|
||||
("Line height", "150%", "line height as set (percent here)"),
|
||||
("Letter spacing", "5%", "letter spacing as set"),
|
||||
("Align", "center", "horizontal alignment"),
|
||||
];
|
||||
for (label, want, why) in rows {
|
||||
assert_eq!(row(&mut s, t, label, json!({})).as_deref(), Some(want), "{label}: {why}");
|
||||
}
|
||||
let code = css(&mut s, t);
|
||||
// (declaration, derivation)
|
||||
let decls: [(&str, &str); 8] = [
|
||||
("font-family: \"Inter\";", "the family, quoted"),
|
||||
("font-size: 24px;", "the size in pixels"),
|
||||
("font-weight: 400;", "Regular is weight 400"),
|
||||
("line-height: 150%;", "a percent line height stays a percentage"),
|
||||
("letter-spacing: 0.05em;", "percent letter spacing is a fraction of the font size (em)"),
|
||||
("text-align: center;", "alignment becomes text-align"),
|
||||
("text-transform: uppercase;", "upper case becomes text-transform"),
|
||||
("text-decoration: underline;", "underline becomes text-decoration"),
|
||||
];
|
||||
for (d, why) in decls {
|
||||
assert!(code.contains(d), "{d}: {why}\n{code}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_for_common_layers() {
|
||||
let mut s = Session::new();
|
||||
let plain = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
let styled = rect(&mut s, 0.0, 100.0, 100.0, 50.0, None);
|
||||
set(
|
||||
&mut s,
|
||||
&[styled],
|
||||
json!({"cornerRadius": 8, "opacity": 0.5, "strokes": ["#000000"], "strokeWeight": 2, "blend": "multiply", "rotation": 30}),
|
||||
);
|
||||
ex(&mut s, "effect.add", json!({"ids": [styled], "type": "dropShadow"}));
|
||||
let linear = rect(&mut s, 0.0, 200.0, 100.0, 50.0, None);
|
||||
let radial = rect(&mut s, 0.0, 300.0, 100.0, 50.0, None);
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
d.get_mut(linear).unwrap().fills = vec![gradient(GradientKind::Linear, (0.0, 0.5), (1.0, 0.5))];
|
||||
d.get_mut(radial).unwrap().fills = vec![gradient(GradientKind::Radial, (0.5, 0.5), (0.5, 1.0))];
|
||||
}
|
||||
let shadow = css(&mut s, styled).lines().find(|l| l.starts_with("box-shadow")).map(str::to_string);
|
||||
// (layer, declaration, derivation)
|
||||
let rows: [(NodeId, &str, &str); 11] = [
|
||||
(plain, "width: 100px;", "the width in pixels"),
|
||||
(plain, "height: 50px;", "the height in pixels"),
|
||||
(plain, "background: #FF0000;", "a solid fill is the background"),
|
||||
(styled, "border-radius: 8px;", "corner radius is border-radius"),
|
||||
(styled, "opacity: 0.5;", "layer opacity is opacity"),
|
||||
(styled, "border: 2px solid #000000;", "docs silent: a stroke is a solid border of its weight"),
|
||||
(styled, "mix-blend-mode: multiply;", "a blend mode is mix-blend-mode"),
|
||||
(styled, "transform: rotate(-30deg);", "rotation turns counter-clockwise, CSS rotate turns clockwise"),
|
||||
(linear, "background: linear-gradient(90deg, ", "a linear gradient keeps its direction (left to right is 90deg)"),
|
||||
(radial, "background: radial-gradient(", "a radial gradient is radial-gradient"),
|
||||
(styled, "box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25);", "a drop shadow is box-shadow: x, y, blur, spread, colour"),
|
||||
];
|
||||
for (layer, d, why) in rows {
|
||||
let c = css(&mut s, layer);
|
||||
assert!(c.contains(d), "{d}: {why}\n{c}\nshadow: {shadow:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_code_for_a_simple_layer() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
set(&mut s, &[r], json!({"cornerRadius": 8}));
|
||||
let swift = code(&mut s, r, json!({"language": "swiftui"}));
|
||||
let compose = code(&mut s, r, json!({"language": "compose"}));
|
||||
// (code, expected fragment, derivation)
|
||||
let rows: [(&str, &str, &str); 4] = [
|
||||
(&swift, "frame(width: 100, height: 50)", "SwiftUI sizes a view with frame, in points"),
|
||||
(&swift, "Color(red: 1, green: 0, blue: 0, opacity: 1)", "docs silent: the fill as a SwiftUI colour"),
|
||||
(&compose, "Modifier.size(100.dp, 50.dp)", "Compose sizes in dp"),
|
||||
(&compose, "RoundedCornerShape(8.dp)", "docs silent: corner radius as a rounded shape"),
|
||||
];
|
||||
for (c, want, why) in rows {
|
||||
assert!(c.contains(want), "{want}: {why}\n{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_list_holds_exports_images_and_icons() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 400})));
|
||||
let marked = rect(&mut s, 10.0, 10.0, 50.0, 50.0, Some(f));
|
||||
ex(&mut s, "export.add", json!({"ids": [marked]}));
|
||||
let png = figcraft_render::encode_png(&figcraft_render::Pixmap::new(2, 2).unwrap()).unwrap();
|
||||
use base64::Engine as _;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(png);
|
||||
let photo = id(&ex(&mut s, "file.placeImage", json!({"data": data, "x": 500, "y": 0})));
|
||||
let v1 = id(&ex(&mut s, "node.create", json!({"type": "star", "x": 200, "y": 10, "width": 12, "height": 12, "parent": f})));
|
||||
let v2 = id(&ex(&mut s, "node.create", json!({"type": "polygon", "x": 214, "y": 10, "width": 12, "height": 12, "parent": f})));
|
||||
let icon = id(&ex(&mut s, "object.group", json!({"ids": [v1, v2]})));
|
||||
set(&mut s, &[icon], json!({"name": "icon/close"}));
|
||||
let card = rect(&mut s, 10.0, 200.0, 300.0, 100.0, Some(f));
|
||||
let r = ex(&mut s, "inspect.assets", json!({"ids": [f, photo]}));
|
||||
let assets = r["assets"].as_array().unwrap();
|
||||
let kind = |id: NodeId| assets.iter().find(|a| a["id"] == json!(id)).map(|a| a["kind"].as_str().unwrap_or_default().to_string());
|
||||
// (layer, kind, derivation)
|
||||
let rows: [(NodeId, Option<&str>, &str); 4] = [
|
||||
(marked, Some("export"), "layers with export settings are assets"),
|
||||
(photo, Some("image"), "layers filled with an image are assets"),
|
||||
(icon, Some("icon"), "small vector-only layers are icons (docs silent on the exact rule: vectors only, at most 128 a side)"),
|
||||
(card, None, "a plain rectangle is not an asset"),
|
||||
];
|
||||
for (layer, want, why) in rows {
|
||||
assert_eq!(kind(layer).as_deref(), want, "{why}");
|
||||
}
|
||||
assert!(!assets.iter().any(|a| a["id"] == json!(v1)), "an icon's own vectors aren't listed again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redlines_between_layers() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200})));
|
||||
let a = rect(&mut s, 20.0, 20.0, 50.0, 50.0, Some(f));
|
||||
let b = rect(&mut s, 120.0, 20.0, 50.0, 50.0, Some(f));
|
||||
let c = rect(&mut s, 40.0, 40.0, 50.0, 50.0, Some(f));
|
||||
let labels = |s: &mut Session, from: NodeId, to: Option<NodeId>| -> Vec<String> {
|
||||
let mut p = json!({"ids": [from]});
|
||||
if let Some(to) = to {
|
||||
p["to"] = json!(to);
|
||||
}
|
||||
let r = ex(s, "measure.distances", p);
|
||||
let mut v: Vec<String> = r["lines"].as_array().unwrap().iter().filter_map(|l| l["label"].as_str().map(str::to_string)).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
// (from, to, labels, derivation)
|
||||
let rows: [(NodeId, Option<NodeId>, &[&str], &str); 3] = [
|
||||
(a, Some(b), &["50"], "side by side: the gap between them"),
|
||||
(a, None, &["130", "20", "20", "230"], "with nothing hovered: to each edge of the parent frame"),
|
||||
(a, Some(c), &["20", "20", "20", "20"], "overlapping: between matching edges"),
|
||||
];
|
||||
for (from, to, want, why) in rows {
|
||||
let mut want: Vec<String> = want.iter().map(|w| w.to_string()).collect();
|
||||
want.sort();
|
||||
assert_eq!(labels(&mut s, from, to), want, "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspected_json_reports_the_layer() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 10.0, 20.0, 80.0, 40.0, None);
|
||||
set(&mut s, &[r], json!({"rotation": 15, "opacity": 0.25, "blend": "screen", "cornerRadius": 6}));
|
||||
// What `inspect_document` and the control channel return for a layer.
|
||||
let n = crate::inspect::node_json(s.doc().unwrap(), r, 0);
|
||||
// (field, value, derivation)
|
||||
let rows: [(&str, Value, &str); 6] = [
|
||||
("width", json!(80.0), "width"),
|
||||
("height", json!(40.0), "height"),
|
||||
("rotation", json!(15.0), "rotation in degrees"),
|
||||
("opacity", json!(0.25), "opacity as a fraction"),
|
||||
("blend", json!("screen"), "the blend mode"),
|
||||
("cornerRadius", json!(6.0), "the radius"),
|
||||
];
|
||||
for (k, want, why) in rows {
|
||||
assert_eq!(n[k], want, "{k}: {why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rem_conversion_leaves_names_alone() {
|
||||
let f = crate::codegen::px_to_rem;
|
||||
// Lengths convert, names ending in "px" don't.
|
||||
assert_eq!(f("width: 32px; --gap-8px: 1; margin: -8px 0px;", 16.0), "width: 2rem; --gap-8px: 1; margin: -0.5rem 0rem;");
|
||||
assert_eq!(f("épx 1px px", 16.0), "épx 0.06rem px");
|
||||
assert_eq!(f("", 16.0), "");
|
||||
}
|
||||
|
|
@ -1244,6 +1244,8 @@ mod draw_tools_tests;
|
|||
#[cfg(test)]
|
||||
mod effects_tests;
|
||||
#[cfg(test)]
|
||||
mod export_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_import_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_guides_tests;
|
||||
|
|
@ -1262,6 +1264,8 @@ mod html_codegen_tests;
|
|||
#[cfg(test)]
|
||||
mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod invite_links_tests;
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp or pdf at `scale` (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, other formats as base64 `data`.",
|
||||
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp or pdf at `scale` (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, other formats as base64 `data`.",
|
||||
obj(
|
||||
json!({
|
||||
"ids": ids_schema("Layers to export (several are composited into one image)"),
|
||||
|
|
@ -208,6 +208,11 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
"cropMarks": boolean("PDF: crop marks"),
|
||||
"bleedMarks": boolean("PDF: bleed marks"),
|
||||
"cmyk": boolean("PDF: CMYK colour instead of RGB"),
|
||||
"contentsOnly": boolean("Only the layers themselves (default true); false also draws every other layer that shows in their area"),
|
||||
"includeBoundingBox": boolean("Export the layers' own boxes, cutting off shadows and outside strokes (default false)"),
|
||||
"svgOutlineText": boolean("SVG: text as outlines (default true); false keeps `<text>` elements"),
|
||||
"svgIdAttribute": boolean("SVG: layer names as unique `id` attributes (default false)"),
|
||||
"svgSimplifyStroke": boolean("SVG: strokes as `stroke` attributes where exact (default true); false writes outlines"),
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
|
|
@ -661,8 +666,10 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
|
|||
if let Some(s) = s_arg(a, "size")? {
|
||||
params.insert("size".into(), json!(s));
|
||||
}
|
||||
// PDF print options, checked by the command.
|
||||
for k in ["bleed", "cropMarks", "bleedMarks", "cmyk"] {
|
||||
// PDF print options and the export options, checked by the command.
|
||||
for k in
|
||||
["bleed", "cropMarks", "bleedMarks", "cmyk", "contentsOnly", "includeBoundingBox", "svgOutlineText", "svgIdAttribute", "svgSimplifyStroke"]
|
||||
{
|
||||
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
||||
params.insert(k.into(), v.clone());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,11 +52,13 @@ pub struct PdfOptions {
|
|||
pub title: Option<String>,
|
||||
/// Bleed, printer's marks and CMYK colour.
|
||||
pub print: PrintSettings,
|
||||
/// A one-page export's page area (page coordinates) instead of its layers' bounds.
|
||||
pub area: Option<Rect>,
|
||||
}
|
||||
|
||||
impl Default for PdfOptions {
|
||||
fn default() -> Self {
|
||||
Self { outline_text: false, raster_scale: 2.0, title: None, print: PrintSettings::default() }
|
||||
Self { outline_text: false, raster_scale: 2.0, title: None, print: PrintSettings::default(), area: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +111,8 @@ pub fn export_groups(doc: &Document, groups: &[Vec<NodeId>], opts: &PdfOptions)
|
|||
}
|
||||
_ => None,
|
||||
};
|
||||
let b = frame.or(bounds).ok_or(PdfError::NoBounds(first))?;
|
||||
let area = opts.area.filter(|a| groups.len() == 1 && rect_finite(*a) && a.width() > 0.0 && a.height() > 0.0);
|
||||
let b = area.or(frame).or(bounds).ok_or(PdfError::NoBounds(first))?;
|
||||
let grow = 2.0 * print::Sheet::new(b.width(), b.height(), &opts.print).margin;
|
||||
if b.width() + grow > MAX_PAGE_SIDE || b.height() + grow > MAX_PAGE_SIDE {
|
||||
return Err(PdfError::TooLarge(b.width().round(), b.height().round()));
|
||||
|
|
|
|||
|
|
@ -359,7 +359,9 @@ fn exports_include_what_effects_paint_outside_the_layer() {
|
|||
];
|
||||
for (why, effects, grows) in rows {
|
||||
let (w, h) = size(effects);
|
||||
assert_eq!((w > 40 && h > 40, w == 40 && h == 40), (grows, !grows), "{why}: {w} × {h}");
|
||||
// Effects grow the export only on the sides they reach (a shadow falling down grows its height).
|
||||
assert!(w >= 40 && h >= 40, "{why}: {w} × {h}");
|
||||
assert_eq!(w > 40 || h > 40, grows, "{why}: {w} × {h}");
|
||||
}
|
||||
// The export holds the shadow where the canvas draws it, pixel for pixel.
|
||||
let (d, r) = square(vec![Paint::solid(hex("FF0000"))], vec![fx(EffectKind::DropShadow(Shadow { radius: 6.0, ..sharp((0.0, 10.0), 2.0) }))]);
|
||||
|
|
|
|||
|
|
@ -241,11 +241,6 @@ pub fn raster_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
|
|||
/// The page-space rectangle a node exports: a clipping frame's box (plus its own effects), else
|
||||
/// everything it paints.
|
||||
pub fn export_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
|
||||
let n = doc.node(id)?;
|
||||
if n.kind.is_frame_like() && n.clip {
|
||||
let reach = n.paint_reach();
|
||||
return Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), n.local_rect().inflate(reach, reach)));
|
||||
}
|
||||
doc.world_render_bounds(id)
|
||||
}
|
||||
|
||||
|
|
@ -256,16 +251,25 @@ pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option<Pixmap> {
|
|||
|
||||
/// [`render_node`] `time` seconds into animated GIFs (the presenter plays them).
|
||||
pub fn render_node_at(doc: &Document, id: NodeId, scale: f64, time: f64) -> Option<Pixmap> {
|
||||
let b = export_bounds(doc, id)?;
|
||||
area_at(doc, &[id], export_bounds(doc, id)?, scale, time)
|
||||
}
|
||||
|
||||
/// Rasterise `ids` (bottom to top) over page area `b` at `scale` (export): the image starts at the
|
||||
/// area's top-left and its sides round up to whole pixels. `None` past 16384 pixels a side.
|
||||
pub fn render_area(doc: &Document, ids: &[NodeId], b: Rect, scale: f64) -> Option<Pixmap> {
|
||||
area_at(doc, ids, b, scale, 0.0)
|
||||
}
|
||||
|
||||
fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Option<Pixmap> {
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 };
|
||||
let w = (b.width() * scale - 1e-6).ceil().max(1.0);
|
||||
let h = (b.height() * scale - 1e-6).ceil().max(1.0);
|
||||
if w > 16_384.0 || h > 16_384.0 {
|
||||
if !(w <= 16_384.0 && h <= 16_384.0 && b.x0.is_finite() && b.y0.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let view = View { origin: (b.x0, b.y0), scale, width: w as u32, height: h as u32 };
|
||||
let mut pix = new_pixmap(view.width, view.height)?;
|
||||
nodes_into(doc, &[id], &view, &mut pix, time);
|
||||
nodes_into(doc, ids, &view, &mut pix, time);
|
||||
Some(pix)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,15 +36,20 @@ const MAX_RASTER_SIDE: f64 = 4096.0;
|
|||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SvgOptions {
|
||||
/// Include node ids as `id` attributes (Figma's "Include id attribute").
|
||||
/// Layer names as `id` attributes (unique in the file: repeats get `_2`, `_3`…).
|
||||
pub include_ids: bool,
|
||||
/// Outline text (always true for now: no font embedding).
|
||||
/// Text as outlines; off: `<text>` elements (selectable, drawn with the reader's fonts).
|
||||
pub outline_text: bool,
|
||||
/// Strokes as `stroke` attributes where that's exact (uniform weight; centred, or inside and
|
||||
/// outside on boxes and ellipses); off: every stroke as a filled outline.
|
||||
pub simplify_stroke: bool,
|
||||
/// The page area the SVG covers (default: the exported layers' export bounds).
|
||||
pub bounds: Option<Rect>,
|
||||
}
|
||||
|
||||
impl Default for SvgOptions {
|
||||
fn default() -> Self {
|
||||
Self { include_ids: false, outline_text: true }
|
||||
Self { include_ids: false, outline_text: true, simplify_stroke: true, bounds: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +69,8 @@ struct W<'a> {
|
|||
/// isolates what it holds (it is a stacking context), so clips go on what blends instead: a
|
||||
/// layer that isolates anyway, or each paint and child of a plain group.
|
||||
clip: Option<String>,
|
||||
/// `id` attributes written so far.
|
||||
used_ids: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// ` clip-path="url(#…)"` for a clip path id (else nothing).
|
||||
|
|
@ -363,7 +370,7 @@ impl W<'_> {
|
|||
return;
|
||||
}
|
||||
let xf = xf * n.transform;
|
||||
let id_attr = if self.opts.include_ids { format!(" id=\"{}\"", esc(&n.name.replace(' ', "_"))) } else { String::new() };
|
||||
let id_attr = if self.opts.include_ids { format!(" id=\"{}\"", esc(&self.unique_id(&n.name))) } else { String::new() };
|
||||
if self.raster_whole(id, n, &id_attr) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -409,7 +416,10 @@ impl W<'_> {
|
|||
_ => figcraft_render::node_outline(self.doc, n),
|
||||
};
|
||||
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
|
||||
if let Some((path, rule)) = &outline
|
||||
let live_text = if self.opts.outline_text { None } else { self.text_element(n, xf) };
|
||||
if let Some(t) = &live_text {
|
||||
self.body.push_str(t);
|
||||
} else if let Some((path, rule)) = &outline
|
||||
&& closed
|
||||
{
|
||||
let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
|
||||
|
|
@ -484,7 +494,11 @@ impl W<'_> {
|
|||
}
|
||||
self.clip = inherited;
|
||||
}
|
||||
if n.has_visible_stroke() {
|
||||
if n.has_visible_stroke()
|
||||
&& let Some(simple) = self.opts.simplify_stroke.then(|| self.simple_stroke(n, xf)).flatten()
|
||||
{
|
||||
self.body.push_str(&simple);
|
||||
} else if n.has_visible_stroke() {
|
||||
let geom = match &n.kind {
|
||||
NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.as_ref().map(|(p, _)| p.clone()),
|
||||
_ => figcraft_doc::geometry::stroke_geometry(n),
|
||||
|
|
@ -504,6 +518,134 @@ impl W<'_> {
|
|||
self.body.push_str("</g>");
|
||||
}
|
||||
|
||||
/// `name` as an `id` not used yet in this file (spaces as underscores; repeats get `_2`…).
|
||||
fn unique_id(&mut self, name: &str) -> String {
|
||||
let base: String = name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).collect();
|
||||
let base = if base.is_empty() { "layer".to_string() } else { base };
|
||||
let id = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}_{k}") }).find(|i| !self.used_ids.contains(i)).unwrap_or(base);
|
||||
self.used_ids.insert(id.clone());
|
||||
id
|
||||
}
|
||||
|
||||
/// Text layer `n` as a `<text>` element, one `<tspan>` per laid-out line, in its first fill.
|
||||
/// shortcut: one style for the whole layer (the first run's family, size and weight) and text
|
||||
/// on a path stays outlined; write a `<tspan>` per styled run when mixed styles are needed.
|
||||
fn text_element(&mut self, n: &Node, xf: Affine) -> Option<String> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
if t.path.is_some() {
|
||||
return None;
|
||||
}
|
||||
let st = &t.style;
|
||||
let fill = n.fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into());
|
||||
let chars: Vec<char> = t.characters.chars().collect();
|
||||
let mut spans = String::new();
|
||||
let mut gxf = xf;
|
||||
if let Some((layout, dy)) = figcraft_text::layout_node(n) {
|
||||
gxf = xf * Affine::translate((0.0, dy));
|
||||
for line in &layout.lines {
|
||||
let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect();
|
||||
let text = st.case.apply(text.trim_end_matches(['\n', '\r']));
|
||||
let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y));
|
||||
let _ = write!(spans, "<tspan x=\"{}\" y=\"{}\">{}</tspan>", num(x), num(y), esc(&text));
|
||||
}
|
||||
}
|
||||
if spans.is_empty() {
|
||||
// No fonts laid it out: one line on the first baseline.
|
||||
let _ = write!(spans, "<tspan x=\"0\" y=\"{}\">{}</tspan>", num(st.font_size * 0.8), esc(&st.case.apply(&t.characters)));
|
||||
}
|
||||
let italic = if st.italic() { " font-style=\"italic\"" } else { "" };
|
||||
let spacing = match st.letter_spacing {
|
||||
figcraft_doc::LetterSpacing::Pixels(v) if v != 0.0 => format!(" letter-spacing=\"{}\"", num(v)),
|
||||
figcraft_doc::LetterSpacing::Percent(p) if p != 0.0 => format!(" letter-spacing=\"{}\"", num(p / 100.0 * st.font_size)),
|
||||
_ => String::new(),
|
||||
};
|
||||
Some(format!(
|
||||
"<text transform=\"{}\" font-family=\"{}\" font-size=\"{}\" font-weight=\"{}\"{italic}{spacing} {fill} xml:space=\"preserve\">{spans}</text>",
|
||||
matrix(gxf),
|
||||
esc(&st.font_family),
|
||||
num(st.font_size),
|
||||
st.weight()
|
||||
))
|
||||
}
|
||||
|
||||
/// `n`'s strokes as `stroke` attributes on a path, when that draws exactly what the outline
|
||||
/// does: a uniform weight (no per-side weights, width profile, brush or arrowheads), centred on
|
||||
/// the path, or inside or outside a box or ellipse (drawn centred on it grown or shrunk by half
|
||||
/// the weight). `None` otherwise.
|
||||
fn simple_stroke(&mut self, n: &Node, xf: Affine) -> Option<String> {
|
||||
use figcraft_doc::{StrokeAlign, StrokeCap, StrokeJoin};
|
||||
let k = &n.stroke;
|
||||
let w = k.weight;
|
||||
if k.sides.is_some()
|
||||
|| k.profile().is_some()
|
||||
|| k.brush.is_some()
|
||||
|| k.cap_start.is_arrow()
|
||||
|| k.cap_end.is_arrow()
|
||||
|| !(w.is_finite() && w > 0.0)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let path = match k.align {
|
||||
StrokeAlign::Center => match n.kind {
|
||||
NodeKind::Text(_) | NodeKind::BooleanOp { .. } | NodeKind::Group => return None,
|
||||
_ => figcraft_doc::geometry::stroke_geometry(n)?,
|
||||
},
|
||||
align if k.dashes.is_empty() => {
|
||||
let d = if align == StrokeAlign::Inside { -w / 2.0 } else { w / 2.0 };
|
||||
let r = n.local_rect().inflate(d, d);
|
||||
if r.width() <= 0.0 || r.height() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
match n.kind {
|
||||
NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep >= std::f64::consts::TAU - 1e-9 => {
|
||||
figcraft_geom::kurbo::Shape::to_path(&figcraft_geom::kurbo::Ellipse::from_rect(r), 0.1)
|
||||
}
|
||||
NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } if n.corners.smoothing == 0.0 => {
|
||||
// A rounded corner keeps a radius only while the shifted radius stays positive.
|
||||
let radii = n.corners.radii.map(|c| if c > 0.0 { c + d } else { 0.0 });
|
||||
if n.corners.radii.iter().zip(radii).any(|(c, r)| *c > 0.0 && r <= 0.0) {
|
||||
return None;
|
||||
}
|
||||
let [tl, tr, br, bl] = radii;
|
||||
figcraft_geom::kurbo::Shape::to_path(&figcraft_geom::kurbo::RoundedRect::from_rect(r, (tl, tr, br, bl)), 0.1)
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let cap = match k.cap_start {
|
||||
StrokeCap::Round => " stroke-linecap=\"round\"",
|
||||
StrokeCap::Square => " stroke-linecap=\"square\"",
|
||||
_ => "",
|
||||
};
|
||||
let join = match k.join {
|
||||
StrokeJoin::Miter if (k.miter_limit - 4.0).abs() > 1e-9 => format!(" stroke-miterlimit=\"{}\"", num(k.miter_limit.max(1.0))),
|
||||
StrokeJoin::Miter => String::new(),
|
||||
StrokeJoin::Round => " stroke-linejoin=\"round\"".into(),
|
||||
StrokeJoin::Bevel => " stroke-linejoin=\"bevel\"".into(),
|
||||
};
|
||||
let dash = if k.dashes.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" stroke-dasharray=\"{}\"", k.dashes.iter().map(|d| num(*d)).collect::<Vec<_>>().join(" "))
|
||||
};
|
||||
let mut out = String::new();
|
||||
for p in &n.strokes {
|
||||
if let Some(a) = self.paint_attr(p, n.width, n.height) {
|
||||
let a = a.replace("fill-opacity=", "stroke-opacity=").replacen("fill=", "stroke=", 1);
|
||||
let _ = write!(
|
||||
out,
|
||||
"<path transform=\"{}\" d=\"{}\" fill=\"none\" {a} stroke-width=\"{}\"{cap}{join}{dash}/>",
|
||||
matrix(xf),
|
||||
path_d(&path),
|
||||
num(w)
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `<image>` markup of `pix`, a render of `view`'s page area, with `attrs`; `None` when it is
|
||||
/// empty.
|
||||
fn image(&self, pix: &Pixmap, view: &View, attrs: &str) -> Option<String> {
|
||||
|
|
@ -776,9 +918,23 @@ fn sniff_mime(b: &[u8]) -> &'static str {
|
|||
|
||||
/// Export `ids` (in z-order) as one SVG document covering their export bounds.
|
||||
pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<String> {
|
||||
let bounds: Rect = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b))?;
|
||||
let bounds: Rect = match opts.bounds {
|
||||
Some(b) if [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) => b,
|
||||
_ => ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b))?,
|
||||
};
|
||||
let origin = Affine::translate((-bounds.x0, -bounds.y0));
|
||||
let mut w = W { doc, roots: ids, page: origin, defs: String::new(), body: String::new(), next: 0, opts, mask: None, clip: None };
|
||||
let mut w = W {
|
||||
doc,
|
||||
roots: ids,
|
||||
page: origin,
|
||||
defs: String::new(),
|
||||
body: String::new(),
|
||||
next: 0,
|
||||
opts,
|
||||
mask: None,
|
||||
clip: None,
|
||||
used_ids: Default::default(),
|
||||
};
|
||||
for id in ids {
|
||||
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
||||
w.node(*id, origin * parent_xf, 0);
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ fn inner_shadows_go_over_fills_and_under_strokes() {
|
|||
let svg = export(&d, &[n]);
|
||||
let fill = svg.find(&format!("fill=\"{FILL}\"")).unwrap();
|
||||
let shadow = svg.find("<g clip-path=").unwrap();
|
||||
let stroke = svg.find(&format!("fill=\"{STROKE}\"")).unwrap();
|
||||
// A simplified stroke (an inside stroke on a box is a stroke attribute).
|
||||
let stroke = svg.find(&format!("stroke=\"{STROKE}\"")).unwrap();
|
||||
assert!(fill < shadow && shadow < stroke, "{svg}");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ name = "English"
|
|||
"Bleed" = "Bleed"
|
||||
"Bleed marks" = "Bleed marks"
|
||||
"Bleed marks need a bleed" = "Bleed marks need a bleed"
|
||||
"Blend" = "Blend"
|
||||
"Blend mode" = "Blend mode"
|
||||
"Blue" = "Blue"
|
||||
"Blur" = "Blur"
|
||||
|
|
@ -219,6 +220,7 @@ name = "English"
|
|||
"Color" = "Color"
|
||||
"Color burn" = "Color burn"
|
||||
"Color dodge" = "Color dodge"
|
||||
"Color format" = "Color format"
|
||||
"Color profile" = "Color profile"
|
||||
"Color styles" = "Color styles"
|
||||
"Color values in this file are in this color space." = "Color values in this file are in this color space."
|
||||
|
|
@ -254,6 +256,7 @@ name = "English"
|
|||
"Constraints" = "Constraints"
|
||||
"Content" = "Content"
|
||||
"Contents hidden" = "Contents hidden"
|
||||
"Contents only" = "Contents only"
|
||||
"Contextual alternates" = "Contextual alternates"
|
||||
"Contrast" = "Contrast"
|
||||
"Contributors" = "Contributors"
|
||||
|
|
@ -527,6 +530,8 @@ name = "English"
|
|||
"Import…" = "Import…"
|
||||
"In an instance, this comes from the main component" = "In an instance, this comes from the main component"
|
||||
"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)."
|
||||
"Include bounding box" = "Include bounding box"
|
||||
"Include id attribute" = "Include id attribute"
|
||||
"Included" = "Included"
|
||||
"Included in layout" = "Included in layout"
|
||||
"Individual corners" = "Individual corners"
|
||||
|
|
@ -687,6 +692,7 @@ name = "English"
|
|||
"Next or previous control in the focused region" = "Next or previous control in the focused region"
|
||||
"Next ready for dev" = "Next ready for dev"
|
||||
"Next size option" = "Next size option"
|
||||
"No assets: no export settings, icons or images. Add export settings in the Design tab." = "No assets: no export settings, icons or images. Add export settings in the Design tab."
|
||||
"No boolean variables fit here." = "No boolean variables fit here."
|
||||
"No boolean variables yet. Create them in Local variables." = "No boolean variables yet. Create them in Local variables."
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "No branches. A branch is a copy of the file to try changes in, then review and merge."
|
||||
|
|
@ -698,7 +704,6 @@ name = "English"
|
|||
"No components yet. Select layers and press {keys}." = "No components yet. Select layers and press {keys}."
|
||||
"No conflicts." = "No conflicts."
|
||||
"No contributor data was built into this copy." = "No contributor data was built into this copy."
|
||||
"No export settings. Add them in the Design tab." = "No export settings. Add them in the Design tab."
|
||||
"No fonts match" = "No fonts match"
|
||||
"No layer changes to show." = "No layer changes to show."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link."
|
||||
|
|
@ -781,6 +786,7 @@ name = "English"
|
|||
"Or keep the file on a FigCraft server and share links to edit or to view it." = "Or keep the file on a FigCraft server and share links to edit or to view it."
|
||||
"Orange" = "Orange"
|
||||
"Outline stroke" = "Outline stroke"
|
||||
"Outline text" = "Outline text"
|
||||
"Outlines" = "Outlines"
|
||||
"Outside" = "Outside"
|
||||
"Overflow" = "Overflow"
|
||||
|
|
@ -1026,6 +1032,7 @@ name = "English"
|
|||
"Show/Hide UI" = "Show/Hide UI"
|
||||
"Side by side" = "Side by side"
|
||||
"Sidebar" = "Sidebar"
|
||||
"Simplify stroke" = "Simplify stroke"
|
||||
"Size" = "Size"
|
||||
"Size jitter" = "Size jitter"
|
||||
"Slashed zero" = "Slashed zero"
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ status = "draft — needs native review"
|
|||
"Bleed" = "Sangrado"
|
||||
"Bleed marks" = "Marcas de sangrado"
|
||||
"Bleed marks need a bleed" = "Las marcas de sangrado necesitan sangrado"
|
||||
"Blend" = "Fusión"
|
||||
"Blend mode" = "Modo de fusión"
|
||||
"Blue" = "Azul"
|
||||
"Blur" = "Desenfoque"
|
||||
|
|
@ -220,6 +221,7 @@ status = "draft — needs native review"
|
|||
"Color" = "Color"
|
||||
"Color burn" = "Subexponer color"
|
||||
"Color dodge" = "Sobreexponer color"
|
||||
"Color format" = "Formato de color"
|
||||
"Color profile" = "Perfil de color"
|
||||
"Color styles" = "Estilos de color"
|
||||
"Color values in this file are in this color space." = "Los valores de color de este archivo están en este espacio de color."
|
||||
|
|
@ -255,6 +257,7 @@ status = "draft — needs native review"
|
|||
"Constraints" = "Restricciones"
|
||||
"Content" = "Contenido"
|
||||
"Contents hidden" = "Contenido oculto"
|
||||
"Contents only" = "Solo el contenido"
|
||||
"Contextual alternates" = "Alternativas contextuales"
|
||||
"Contrast" = "Contraste"
|
||||
"Contributors" = "Colaboradores"
|
||||
|
|
@ -528,6 +531,8 @@ status = "draft — needs native review"
|
|||
"Import…" = "Importar…"
|
||||
"In an instance, this comes from the main component" = "En una instancia, esto viene del componente principal"
|
||||
"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "En el navegador puedes unirte a sesiones alojadas por la app de escritorio y a archivos de un servidor FigCraft. Una página abierta por https solo llega a otros equipos mediante enlaces wss:// (un servidor con TLS)."
|
||||
"Include bounding box" = "Incluir cuadro delimitador"
|
||||
"Include id attribute" = "Incluir atributo id"
|
||||
"Included" = "Incluidos"
|
||||
"Included in layout" = "Incluidos en el diseño"
|
||||
"Individual corners" = "Esquinas individuales"
|
||||
|
|
@ -688,6 +693,7 @@ status = "draft — needs native review"
|
|||
"Next or previous control in the focused region" = "Control siguiente o anterior de la región enfocada"
|
||||
"Next ready for dev" = "Siguiente listo para desarrollo"
|
||||
"Next size option" = "Siguiente opción de tamaño"
|
||||
"No assets: no export settings, icons or images. Add export settings in the Design tab." = "No hay recursos: ni ajustes de exportación, ni iconos, ni imágenes. Añade ajustes de exportación en la pestaña Diseño."
|
||||
"No boolean variables fit here." = "Ninguna variable booleana encaja aquí."
|
||||
"No boolean variables yet. Create them in Local variables." = "Aún no hay variables booleanas. Créalas en Variables locales."
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "No hay ramas. Una rama es una copia del archivo para probar cambios y luego revisarlos y fusionarlos."
|
||||
|
|
@ -699,7 +705,6 @@ status = "draft — needs native review"
|
|||
"No components yet. Select layers and press {keys}." = "Aún no hay componentes. Selecciona capas y pulsa {keys}."
|
||||
"No conflicts." = "Sin conflictos."
|
||||
"No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores."
|
||||
"No export settings. Add them in the Design tab." = "No hay ajustes de exportación. Añádelos en la pestaña Diseño."
|
||||
"No fonts match" = "Ninguna fuente coincide"
|
||||
"No layer changes to show." = "No hay cambios de capas que mostrar."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Añade un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo."
|
||||
|
|
@ -782,6 +787,7 @@ status = "draft — needs native review"
|
|||
"Or keep the file on a FigCraft server and share links to edit or to view it." = "O guarda el archivo en un servidor FigCraft y comparte enlaces para editarlo o verlo."
|
||||
"Orange" = "Naranja"
|
||||
"Outline stroke" = "Contornear trazo"
|
||||
"Outline text" = "Texto como contornos"
|
||||
"Outlines" = "Contornos"
|
||||
"Outside" = "Exterior"
|
||||
"Overflow" = "Desbordamiento"
|
||||
|
|
@ -1027,6 +1033,7 @@ status = "draft — needs native review"
|
|||
"Show/Hide UI" = "Mostrar/ocultar interfaz"
|
||||
"Side by side" = "En paralelo"
|
||||
"Sidebar" = "Barra lateral"
|
||||
"Simplify stroke" = "Simplificar trazo"
|
||||
"Size" = "Tamaño"
|
||||
"Size jitter" = "Variación de tamaño"
|
||||
"Slashed zero" = "Cero barrado"
|
||||
|
|
|
|||
|
|
@ -407,7 +407,7 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) {
|
|||
});
|
||||
let close = choice.is_some() || !open;
|
||||
match choice {
|
||||
Some(ExportChoice::Layers) => export_layers(app, &with_settings),
|
||||
Some(ExportChoice::Layers) => export_layers(app, &with_settings, false),
|
||||
Some(ExportChoice::Once(s)) if !ids.is_empty() => save_one(app, &ids, &s, &suggested_name(&doc, &ids, &s, "Export")),
|
||||
Some(ExportChoice::Once(_)) => app.toast(tr("Nothing to export")),
|
||||
None => {}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Exporting: the Design panel's Export section (a row per export setting with size, suffix and
|
||||
//! format, PDF print settings, a preview, and "Export N layers"), the page's Export button, and the
|
||||
//! export actions and print options the Export dialog shares. Every change runs an `export.*` or
|
||||
//! `file.export*` command.
|
||||
//! Exporting: the Design panel's Export section (a row per export setting with size, suffix,
|
||||
//! format and its advanced options, PDF print settings, a preview, and "Export N layers"), the
|
||||
//! page's Export button, and the export actions and print options the Export dialog shares. Every
|
||||
//! change runs an `export.*` or `file.export*` command.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
|
|
@ -56,16 +56,18 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
|||
(k, _) => trn("Export {n} layers", k, &[]),
|
||||
};
|
||||
if wide_button(ui, &label) {
|
||||
export_layers(app, sel);
|
||||
export_layers(app, sel, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// One export setting: size (typed or picked), suffix, format and remove; a PDF's print settings
|
||||
/// under it.
|
||||
/// One export setting: size (typed or picked), suffix, format, advanced options and remove; a
|
||||
/// PDF's print settings under it.
|
||||
fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting) {
|
||||
row(ui, 32.0, |ui| {
|
||||
let (size_w, format_w, gap) = (56.0, 60.0, ui.spacing().item_spacing.x);
|
||||
let suffix_w = (ui.available_width() - size_w - format_w - 24.0 - gap * 3.0).max(32.0);
|
||||
// Tighter spacing so the advanced options and remove buttons fit beside the fields.
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let (size_w, format_w, button, gap) = (52.0, 54.0, 22.0, ui.spacing().item_spacing.x);
|
||||
let suffix_w = (ui.available_width() - size_w - format_w - button * 2.0 - gap * 4.0).max(32.0);
|
||||
let presets: Vec<String> =
|
||||
ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect();
|
||||
if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets)
|
||||
|
|
@ -87,7 +89,19 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo
|
|||
}
|
||||
}
|
||||
});
|
||||
if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove export setting")).clicked() {
|
||||
let more = icons::button(ui, Icon::Dots, button, false, tr("Export settings"));
|
||||
egui::Popup::from_toggle_button_response(&more)
|
||||
.id(Id::new(("export_more", i)))
|
||||
.width(216.0)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| {
|
||||
if let Some(mut params) = advanced_options(ui, e) {
|
||||
params["ids"] = json!(ids);
|
||||
params["index"] = json!(i);
|
||||
app.run("export.update", params);
|
||||
}
|
||||
});
|
||||
if icons::button(ui, Icon::Minus, button, false, tr("Remove export setting")).clicked() {
|
||||
app.run("export.remove", json!({"ids": ids, "index": i}));
|
||||
}
|
||||
});
|
||||
|
|
@ -96,6 +110,25 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo
|
|||
}
|
||||
}
|
||||
|
||||
/// A setting's advanced options: contents only and include bounding box, and for SVG outline
|
||||
/// text, the id attribute and simplified strokes. Returns the changed option as command params.
|
||||
fn advanced_options(ui: &mut Ui, e: &ExportSetting) -> Option<Value> {
|
||||
let mut out = None;
|
||||
let mut option = |ui: &mut Ui, key: &str, on: bool, label: &str| {
|
||||
if widgets::checkbox(ui, on, label) {
|
||||
out = Some(json!({ key: !on }));
|
||||
}
|
||||
};
|
||||
option(ui, "contentsOnly", e.contents_only, tr("Contents only"));
|
||||
option(ui, "includeBoundingBox", e.include_bounding_box, tr("Include bounding box"));
|
||||
if e.format == ExportFormat::Svg {
|
||||
option(ui, "svgOutlineText", e.svg_outline_text, tr("Outline text"));
|
||||
option(ui, "svgIdAttribute", e.svg_id_attribute, tr("Include id attribute"));
|
||||
option(ui, "svgSimplifyStroke", e.svg_simplify_stroke, tr("Simplify stroke"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A PDF setting's print settings: a summary row that opens them.
|
||||
fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintSettings) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
|
@ -176,12 +209,17 @@ fn print_params(p: &PrintSettings) -> Value {
|
|||
json!({"bleed": p.bleed(), "cropMarks": p.crop_marks, "bleedMarks": p.bleed_marks, "cmyk": p.cmyk})
|
||||
}
|
||||
|
||||
/// `params` with the setting's print options added (PDF exports).
|
||||
fn with_print(mut params: Value, setting: &ExportSetting) -> Value {
|
||||
if setting.format == ExportFormat::Pdf
|
||||
&& let (Value::Object(m), Value::Object(p)) = (&mut params, print_params(&setting.print))
|
||||
{
|
||||
m.extend(p);
|
||||
/// `file.export` params for `setting`: its size, format and options (PDF: its print options).
|
||||
fn setting_params(setting: &ExportSetting) -> Value {
|
||||
let mut params = serde_json::to_value(setting).unwrap_or_else(|_| json!({}));
|
||||
if let Value::Object(m) = &mut params {
|
||||
m.remove("print");
|
||||
m.remove("suffix");
|
||||
if setting.format == ExportFormat::Pdf
|
||||
&& let Value::Object(p) = print_params(&setting.print)
|
||||
{
|
||||
m.extend(p);
|
||||
}
|
||||
}
|
||||
params
|
||||
}
|
||||
|
|
@ -267,19 +305,21 @@ pub(crate) fn wide_button(ui: &mut Ui, label: &str) -> bool {
|
|||
clicked
|
||||
}
|
||||
|
||||
/// Export `ids` with their export settings: one file through a save dialog, several into a
|
||||
/// folder.
|
||||
pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId]) {
|
||||
/// Export `ids` with their export settings (`assets`: the Inspect tab's assets in and under them):
|
||||
/// one file through a save dialog, several into a folder.
|
||||
pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) {
|
||||
let jobs = match app.session.doc() {
|
||||
Ok(doc) if assets => figcraft_engine::devinspect::asset_jobs(doc, ids),
|
||||
Ok(doc) => export_jobs(doc, ids),
|
||||
Err(_) => return,
|
||||
};
|
||||
match jobs.as_slice() {
|
||||
[] => {}
|
||||
[one] => save_one(app, &[one.id], &one.setting, &one.file),
|
||||
// The suggested name keeps only the file's own name (a layer's folders apply to folders).
|
||||
[one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)),
|
||||
_ => {
|
||||
let Some(dir) = app.services.pick_folder(tr("Export")) else { return };
|
||||
if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "dir": dir.to_string_lossy()})) {
|
||||
if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "dir": dir.to_string_lossy(), "assets": assets})) {
|
||||
let n = r["files"].as_array().map_or(0, Vec::len);
|
||||
app.toast(trn("Exported {n} files", n, &[]));
|
||||
}
|
||||
|
|
@ -292,7 +332,10 @@ pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting
|
|||
let ext = setting.format.ext();
|
||||
let Some(p) = app.services.pick_save(tr("Export"), file, &[ext]) else { return };
|
||||
let path = p.to_string_lossy().to_string();
|
||||
if app.run("file.export", with_print(json!({"ids": ids, "format": ext, "size": setting.size, "path": path}), setting)).is_some() {
|
||||
let mut params = setting_params(setting);
|
||||
params["ids"] = json!(ids);
|
||||
params["path"] = json!(path);
|
||||
if app.run("file.export", params).is_some() {
|
||||
app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const DATA_LABELS: &[&str] = &[
|
|||
"Alpha",
|
||||
"Background blur",
|
||||
"Black & white",
|
||||
"Blend",
|
||||
"Bristle",
|
||||
"Circle",
|
||||
"Color burn",
|
||||
|
|
|
|||
|
|
@ -101,9 +101,8 @@ pub fn invoke(app: &mut FigApp, id: &str) {
|
|||
}
|
||||
"edit.copyAsPng" => {
|
||||
let ids = app.session.selection();
|
||||
if let Some(first) = ids.first()
|
||||
&& let Some((mut pix, profile)) =
|
||||
app.session.doc().ok().and_then(|d| Some((figcraft_render::render_node(d, *first, 2.0)?, d.color_profile)))
|
||||
if let Some((mut pix, profile)) =
|
||||
app.session.doc().ok().and_then(|d| Some((figcraft_engine::cmd::file::copy_png(d, &ids)?, d.color_profile)))
|
||||
{
|
||||
// Clipboard images are read as sRGB.
|
||||
figcraft_render::to_srgb(&mut pix, profile);
|
||||
|
|
|
|||
|
|
@ -2084,14 +2084,15 @@ pub(crate) fn prop_row(app: &mut FigApp, ui: &mut Ui, p: &figcraft_engine::devin
|
|||
}
|
||||
}
|
||||
|
||||
/// The selection's layers with export settings: click one to export it, or export them all.
|
||||
/// The selection's assets (layers with export settings, icons, images): click one to export it,
|
||||
/// or export them all.
|
||||
fn inspect_assets(app: &mut FigApp, ui: &mut Ui, assets: &[figcraft_engine::devinspect::Asset]) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
widgets::separator(ui);
|
||||
let export_all = [(Icon::Export, tr("Export all"))];
|
||||
let all = widgets::section_header(ui, tr("Assets"), if assets.is_empty() { &[] } else { &export_all }, false).is_some();
|
||||
if assets.is_empty() {
|
||||
widgets::note(ui, tr("No export settings. Add them in the Design tab."), t.text_secondary);
|
||||
widgets::note(ui, tr("No assets: no export settings, icons or images. Add export settings in the Design tab."), t.text_secondary);
|
||||
return;
|
||||
}
|
||||
let mut export: Option<Vec<NodeId>> = all.then(|| assets.iter().map(|a| a.id).collect());
|
||||
|
|
@ -2117,7 +2118,7 @@ fn inspect_assets(app: &mut FigApp, ui: &mut Ui, assets: &[figcraft_engine::devi
|
|||
}
|
||||
}
|
||||
if let Some(ids) = export {
|
||||
crate::export_panel::export_layers(app, &ids);
|
||||
crate::export_panel::export_layers(app, &ids, true);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
|
@ -2142,7 +2143,22 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
|
|||
widgets::section_header(ui, &name, &[], false);
|
||||
crate::dev_mode::status(app, ui, &doc, &sel);
|
||||
crate::dev_mode::annotations_section(app, ui, &doc, first);
|
||||
for s in figcraft_engine::devinspect::properties(&doc, first) {
|
||||
use figcraft_engine::devinspect::ColorFormat;
|
||||
let fmt_id = Id::new("inspect_color_format");
|
||||
let fmt = ui.data(|d| d.get_temp::<usize>(fmt_id)).and_then(|i| ColorFormat::ALL.get(i).copied()).unwrap_or_default();
|
||||
row(ui, 32.0, |ui| {
|
||||
widgets::label(ui, tr("Color format"), t.text_secondary);
|
||||
let resp = widgets::dropdown(ui, fmt.label(), 72.0, None);
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for (i, f) in ColorFormat::ALL.iter().enumerate() {
|
||||
if menu_item(ui, f.label(), "") {
|
||||
ui.data_mut(|d| d.insert_temp(fmt_id, i));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
for s in figcraft_engine::devinspect::properties_with(&doc, first, fmt) {
|
||||
inspect_section(app, ui, &s);
|
||||
}
|
||||
inspect_assets(app, ui, &figcraft_engine::devinspect::assets(&doc, &sel));
|
||||
|
|
@ -2151,6 +2167,10 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
|
|||
let lang = ui.data(|d| d.get_temp::<usize>(lang_id)).and_then(|i| CodeLang::ALL.get(i).copied()).unwrap_or_default();
|
||||
widgets::separator(ui);
|
||||
let copy = widgets::section_header(ui, tr("Code"), &[(Icon::Export, tr("Copy"))], false).is_some();
|
||||
// Pixels or rem (of a 16 px root) for the web languages.
|
||||
let rem_id = Id::new("inspect_rem");
|
||||
let web = matches!(lang, CodeLang::Css | CodeLang::Html | CodeLang::React);
|
||||
let rem = web && ui.data(|d| d.get_temp::<bool>(rem_id)).unwrap_or(false);
|
||||
row(ui, 32.0, |ui| {
|
||||
let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code));
|
||||
menu_popup(&resp).show(|ui| {
|
||||
|
|
@ -2161,8 +2181,20 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
});
|
||||
if web {
|
||||
let resp = widgets::dropdown(ui, if rem { "rem" } else { "px" }, 56.0, None);
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for (on, label) in [(false, "px"), (true, "rem")] {
|
||||
if menu_item(ui, label, "") {
|
||||
ui.data_mut(|d| d.insert_temp(rem_id, on));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let code = crate::dev_mode::code(app, ui, first, lang);
|
||||
let code = if rem { figcraft_engine::codegen::px_to_rem(&code, 16.0) } else { code };
|
||||
egui::Frame::NONE
|
||||
.fill(t.field)
|
||||
.corner_radius(CornerRadius::same(6))
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
# Dev Mode parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (code connect and Dev Mode plugins) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (inspect behaviour suite and fixes: colour formats, rem, blend, positions from the frame, CSS for rotation, gradients and text, icons and images as assets; code connect and Dev Mode plugins) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
|
||||
|
||||
**Breadth ~90%, ready ~64% (estimated).** The inspect surface is broad and components connect
|
||||
**Breadth ~93%, ready ~70% (estimated).** The inspect surface is broad and components connect
|
||||
to the code components of a repository; left: dev resources links. Remaining: 1–3 h.
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| Dev Mode toggle (Shift+D), Inspect panel | done | |
|
||||
| Properties with their styles and variables; redlines on hover | done | |
|
||||
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML and React (ours); components take properties as parameters, instances are calls |
|
||||
| Assets list with export | done | |
|
||||
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
|
||||
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML and React (ours); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
|
||||
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
|
||||
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
|
||||
| Ready for dev list, statuses, "Changed" after edits | done | |
|
||||
| Focus view (one frame, previous / next) | done | |
|
||||
|
|
@ -24,5 +24,6 @@ to the code components of a repository; left: dev resources links. Remaining: 1
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes `<text>`), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Dev Mode breadth 90% → 93%, ready 64% → 70%, hours unchanged (1–3, dev resources); feature breadth and ready for real work unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–720 h |
|
||||
| 2026-10-10 | major | Created from the 2026-10-10 re-measure |
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (.fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
|
||||
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
|
||||
help center (image and video types, export formats), and FigCraft's source.
|
||||
|
||||
**Summary (estimated):** ~58% ready. The core interchange formats (SVG, PNG, JPG, PDF) are solid
|
||||
**Summary (estimated):** ~59% ready. The core interchange formats (SVG, PNG, JPG, PDF) are solid
|
||||
and tested; Figma's own document, `.fig`, opens (best effort, not yet checked against real-world
|
||||
files) but can't be written.
|
||||
|
||||
|
|
@ -39,12 +39,12 @@ files) but can't be written.
|
|||
|
||||
| Format | Figma | FigCraft | Notes | Hours |
|
||||
|---|---|---|---|---|
|
||||
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers" | — |
|
||||
| JPG | yes | yes | | — |
|
||||
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images | 1–2 |
|
||||
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them; sides round up to whole pixels | — |
|
||||
| JPG | yes | yes | white where transparent; fixed quality (90) | — |
|
||||
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
|
||||
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports | CMYK output intent 2–4 |
|
||||
| WebP | — | yes (ours) | | — |
|
||||
| Copy as CSS / SVG / PNG / text / link | yes | yes | | — |
|
||||
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
|
||||
| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML and React | see [dev-mode-parity](dev-mode-parity.md) | — |
|
||||
| `.fig` ("Save local copy") | yes | no | | 15–30 |
|
||||
|
||||
|
|
@ -61,6 +61,7 @@ files) but can't be written.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes `<text>`), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Summary ~55% → ~56%; SVG options 1–2 h → done |
|
||||
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
|
||||
| 2026-10-11 | minor | Embedded image colour profiles: placed and opened images are converted once, when decoded, from their own colour space into the file's (sRGB or Display P3): ICC profiles in PNG (`iCCP`), JPG (APP2), WebP and TIFF, matrix/curve and table profiles in RGB or grey (moxcms, already built for the `image` crate), and a PNG's `sRGB`, `gAMA` and `cHRM` chunks; images without a profile, or with one that can't be used (CMYK, damaged, over 4 MB), are taken as sRGB as before. The original file is kept (PDF exports still embed it); pixel exports carry the converted pixels with the file's profile. The image fill's details show the source profile (Unknown — treated as sRGB when there's none) and `image.place` reports it as `imageProfile`. |
|
||||
| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Summary ~40% → ~55%; `.fig` read 30–60 h → 8–15 h (write 15–30 h unchanged) |
|
||||
|
|
|
|||
|
|
@ -176,14 +176,14 @@ Or in a project's `.mcp.json`:
|
|||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `inspect.assets` (layers under the selection with export settings), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML and React, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. |
|
||||
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
|
||||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
|
||||
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
|
||||
| `get_node {id, depth}` | One layer as JSON. |
|
||||
| `select {ids}` | Replace the selection (`[]` deselects). |
|
||||
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?}` | png, jpg, svg, webp, pdf; `size` is `2x`, `512w` or `512h`. PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?}` (every layer once per setting, named `<layer><suffix>.<format>`). |
|
||||
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf; `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
|
||||
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
|
||||
| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. |
|
||||
| `undo` / `redo` | Step through history. |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (.fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
|
|
@ -19,7 +19,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
|
|||
| Feature breadth (does each Figma Design feature exist, weighted) | **~82%** (core editor areas 1–16: ~93%) | estimated from the area table below |
|
||||
| Ready for real work | **~51%** | estimated (dimension table below: written weights × written per-dimension scores) |
|
||||
| Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) |
|
||||
| Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 70.7% × discounts 0.9 × 0.9 × 0.85) |
|
||||
| Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 71.0% × discounts 0.9 × 0.9 × 0.85) |
|
||||
| Remaining to beta | **~170–305 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~307–596 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
|
||||
|
|
@ -107,8 +107,8 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries, component playground (behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 |
|
||||
| 12 | Styles & variables | 5 | 94% | 76% | Variables inside styles, new library variables reaching extended collections on their own, extending an extended collection, real-library QA (behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections and variables import/export landed) ([components-parity](components-parity.md)) | 3–5 |
|
||||
| 13 | Prototyping | 6 | 90% | 74% | Video triggers and media actions, gamepad triggers, delays on mouse triggers, manual overlay offsets, real prototype QA (behaviour suite from the documentation, key combinations, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 3–6 |
|
||||
| 14 | Import/export | 6 | 82% | 54% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 |
|
||||
| 15 | Dev Mode / inspect | 2 | 90% | 64% | Dev resources links, import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (code connect and plugins in Dev Mode landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–3 |
|
||||
| 14 | Import/export | 6 | 84% | 57% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 |
|
||||
| 15 | Dev Mode / inspect | 2 | 93% | 70% | Dev resources links, import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect and plugins in Dev Mode landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–3 |
|
||||
| 16 | Comments, versions, find, misc | 3 | 85% | 60% | Comments are local only; a branch's own version history (branches with review and merge landed) | 2–5 |
|
||||
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat ([collaboration-parity](collaboration-parity.md)) | 45–90 |
|
||||
| 18 | Plugins & widgets | 4 | 45% | 28% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples), then plugin panels called back per button or changed value and per-plugin settings (ABI v2). Left: distribution, widgets; panels one at a time without a colour picker; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 15–40 |
|
||||
|
|
@ -124,7 +124,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
|---|---|---|---|---|---|
|
||||
| Features (depth) | 35 | 61% | 47–95 (areas 1–13, 15, 16, 20) | The table above; core editor ~70% | this file |
|
||||
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
|
||||
| File formats | 12 | 58% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
|
||||
| File formats | 12 | 59% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) |
|
||||
| Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
|
||||
| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) |
|
||||
| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) |
|
||||
|
|
@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc
|
|||
localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration
|
||||
and is left out as an area, but its effect on working with Figma users is counted in discount 3.
|
||||
|
||||
**2. Average depth** ("Ready" column, weighted): **70.7%**.
|
||||
**2. Average depth** ("Ready" column, weighted): **71.0%**.
|
||||
|
||||
**3. Discounts for what still stops real work:**
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ and is left out as an area, but its effect on working with Figma users is counte
|
|||
| Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way |
|
||||
| File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) but writes nothing Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) |
|
||||
|
||||
70.7% × 0.90 × 0.90 × 0.85 = **~49%** (estimated).
|
||||
71.0% × 0.90 × 0.90 × 0.85 = **~49%** (estimated).
|
||||
|
||||
**4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments
|
||||
from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is
|
||||
|
|
@ -410,7 +410,8 @@ Hours in this narrative are the builders' estimates; the tables above supersede
|
|||
presenter) and variables (the local variables JSON passed along, next to the file or embedded:
|
||||
collections, modes, aliases, scopes, layer bindings and explicit modes).
|
||||
Export panel: a row per export setting (typed or preset sizes like `2x` / `512w` / `512h`, PNG,
|
||||
JPG, WebP, SVG and PDF, suffix), a preview, and "Export N layers" (`file.exportLayers`) in the
|
||||
JPG, WebP, SVG and PDF, suffix, advanced options: contents only, include bounding box, and for
|
||||
SVG outline text, id attributes and simplified strokes), a preview, and "Export N layers" (`file.exportLayers`) in the
|
||||
panel and the Export dialog. Paste from other apps: images, SVG and text (the newest clipboard
|
||||
content wins over our own copy; pasted SVG lands centred in the view). SVG import is editable and
|
||||
checked against a corpus of synthetic files (expected pixels worked out by hand, layer structure,
|
||||
|
|
@ -601,6 +602,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes `<text>`), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53%; Dev Mode 90%/64% → 93%/70%; file formats 55% → 56%; mainstream weekly areas 68.1% → 68.4%; feature breadth (~80%), ready for real work (~49%), essentials (~57%), mainstream (~46%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
|
||||
| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation (10 shape tests, 6 vector tests and 3 handle tests: about 70 table rows plus stepwise checks) and its fixes: rounded vector points are drawn and exported (the radius was stored only; a vector's corner radius now rounds every point); a boolean group looks like its top layer, a subtraction like the bottom one (always the bottom); Alt draws a line from its centre; stars keep to 3–60 points; delete and heal fits the curve through the deleted point; Shift-clicking a selected point at a corner of the points' box takes it out (it scaled); outline stroke turns the stroke's colour style into the fill's and keeps opacity and effects; the miter field shows the miter angle (it showed the ratio under that name); individual corners only for rectangles and frames; Option/Alt+Shift+F flattens; edit-mode keys reach the edit tools through MCP `key` (Delete removed the layer). Added: corner radius and star ratio handles on the canvas, Count and Ratio fields for polygons and stars, cut tool drags that divide a shape into layers, paint bucket drags across areas. Shapes & vector 97%/68% → 98%/72%, 1–2 h → 2–4 h (the suite found gaps the estimate didn't count: corner smoothing beyond rectangles, a join or cap per point). Totals recomputed by the doc's method, which earlier rows had left stale: the area table's weighted figures are breadth ~82% (core ~93%) and ready ~61% (core ~69%), not the ~80%/~58% (~91%/~68%) carried since #130; Features (depth) follows the weighted ready, 57% → 61%; ready for real work ~49% → ~51%; mainstream ~46% → ~48% (weekly areas 67.8% → 69.4%); the selection suite's area 2 hours (−3/−5) hadn't reached the totals, and neither it nor the text suite had reached the Features (depth) row: Features 60–115 h → 56–109 h, full parity ~321–618 h → ~319–615 h (−3/−5 for area 2, +1/+2 for area 4) |
|
||||
| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation (14 paint and 9 effects tests with table rows for fills, opacity, every blend mode by its formula, pass-through and Normal containers, gradient handles and stops, image scale modes and adjustments, colour models, the colour profile, shadows, blurs, effect order and exports; 3 command tests) and its fixes: groups and frames set to Normal blend their children among themselves first, and clipping frames no longer isolate their children (blend modes inside a clipping frame met only the frame's children, not its fill or what is behind it) — canvas, SVG (`isolation`; clips put on what blends, since a clipped group isolates) and PDF (isolated groups); plus darker is drawn (it was multiply) on the canvas and in PNG exports; drop shadows hide behind what the layer covers, translucent fills included, unless shown behind transparent areas, in SVG too (it never cut them out) and the SVG filter region reaches as far as the effects; Fill images cover the box to its edges (upscaled ones faded out at the edge); "Normal" names Normal, not pass-through, in commands and the blend menu (the menu's Normal set pass-through). Added: a blend menu for each paint in the colour picker. Paint 66% → 70%, 4–7 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h |
|
||||
|
|
|
|||
Loading…
Reference in New Issue