From 044cfef834005c235374d2ce302e586a1d23e60c Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 10:57:58 +0700 Subject: [PATCH] M6: PDF export depth and REST conditional and variable-mode actions (#65) PDF export - Vector masks clip to the mask's outline, luminance masks are luminosity soft masks (alpha masks as before). - Background blur: the backdrop the page draws under the layer is rendered, blurred and cut to its shape, under the layer's vector content. - Text hyperlinks become clickable link annotations (one per line and link). - Frames-to-PDF shares one path (`frames_pdf`): sRGB conversion for Display P3 files, hidden frames left out, the file name as the PDF title. - `figcraft-cli export -o x.pdf` and `convert x.figcraft x.pdf` write the page's frames one PDF page each. - Tests: mask types, backdrops, links, image deduplication, page sizes in points, hostile inputs; CLI end to end. Checked by rendering the PDFs back to PNG and diffing against the PNG export. Prototyping - Model: conditional actions (if / else if / else blocks), set variable mode, and expressions (literals, variable references, + - * /, comparisons, and/or/not, negate) for set-variable values and conditions; walk helpers for nested actions (remap on paste, colours, destination checks, library copies and pruning, canvas connections). - REST import maps CONDITIONAL, SET_VARIABLE_MODE and SET_VARIABLE with variable references and EXPRESSION values; unsupported functions (mode lookups) skip the action with a warning. - The presenter runs them in order (`figcraft_engine::proto_run`); the Prototype panel summarises conditionals, edits set-variable-mode, and `ui.set {protoEditor}` opens an interaction's editor. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 17 +- apps/figcraft-cli/src/files.rs | 22 +- apps/figcraft-cli/tests/pdf.rs | 59 ++++ crates/doc/src/colors.rs | 10 +- crates/doc/src/lib.rs | 6 +- crates/doc/src/proto.rs | 130 +++++++- crates/doc/src/proto_expr.rs | 243 +++++++++++++++ crates/doc/src/proto_expr_tests.rs | 197 ++++++++++++ crates/engine/src/cmd/file.rs | 37 ++- crates/engine/src/cmd/proto.rs | 26 +- crates/engine/src/lib.rs | 3 + crates/engine/src/library.rs | 37 ++- crates/engine/src/proto_run.rs | 87 ++++++ crates/engine/src/proto_run_tests.rs | 209 +++++++++++++ crates/format/src/rest/conditional_tests.rs | 209 +++++++++++++ crates/format/src/rest/mod.rs | 2 + crates/format/src/rest/proto.rs | 161 ++++++++-- .../format/src/rest/proto_variable_tests.rs | 32 +- crates/format/src/rest/variables.rs | 6 +- crates/pdf/src/draw.rs | 63 +++- crates/pdf/src/export_tests.rs | 286 ++++++++++++++++++ crates/pdf/src/lib.rs | 41 ++- crates/pdf/src/raster.rs | 61 ++++ crates/pdf/src/tests.rs | 8 +- crates/pdf/src/text.rs | 36 +++ crates/ui-egui/src/control.rs | 10 + crates/ui-egui/src/present.rs | 70 ++--- crates/ui-egui/src/present_variables_tests.rs | 105 +++++++ crates/ui-egui/src/proto_canvas.rs | 7 +- crates/ui-egui/src/proto_panel.rs | 111 ++++++- docs/cli.md | 9 +- docs/control-protocol.md | 7 +- docs/mcp.md | 19 +- 33 files changed, 2155 insertions(+), 171 deletions(-) create mode 100644 apps/figcraft-cli/tests/pdf.rs create mode 100644 crates/doc/src/proto_expr.rs create mode 100644 crates/doc/src/proto_expr_tests.rs create mode 100644 crates/engine/src/proto_run.rs create mode 100644 crates/engine/src/proto_run_tests.rs create mode 100644 crates/format/src/rest/conditional_tests.rs create mode 100644 crates/pdf/src/export_tests.rs create mode 100644 crates/ui-egui/src/present_variables_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 60a3c3f..0cc1847 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -115,13 +115,20 @@ dimension measured side by side. paths, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles; image fills from an `images` folder or placeholders; warnings for the rest), now with prototyping (interactions with their triggers, actions, transitions and easing, flows, - scrolling, the prototype device) and variables (the local variables JSON passed along, next to - the file or embedded: collections, modes, aliases, scopes, layer bindings and explicit modes). + scrolling, the prototype device; conditional actions (if / else if / else over expressions), + set variable with variable references and expressions, set variable mode, all run by the + 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 panel and the Export dialog. Paste from other apps: images, SVG and text (the newest clipboard - content wins over our own copy). *Left:* .fig import (best effort), PDF export (in progress), - REST import of conditional and variable-mode prototype actions. 14–26 h. + content wins over our own copy). PDF export: one page per frame in points (Export frames to + PDF, the Export panel, `figcraft-cli export -o x.pdf`), vectors, gradients and strokes as + vectors, real selectable text in subset fonts with clickable hyperlinks, images embedded once, + opacity and blend modes, clip content, alpha/luminance/vector masks, shadows and layer and + background blurs as images; checked by rendering the PDFs back against PNG exports. + *Left:* .fig import (best effort), variable mode lookups in prototype expressions, PDF print + options (bleed, CMYK). 10–20 h. 7. **Look & feel and interaction-fidelity pass**: tool by tool, panel by panel against the incumbent (cursors, modifier keys, hover states, menus, popovers). 40–60 h. 8. **Collaboration (local):** comments landed (pins, threads, resolve, panel). Version history @@ -147,7 +154,7 @@ dimension measured side by side. - **Next:** the fidelity pass (7) and hardening (9: a real-file corpus, platform QA); responsive presentation. -- **Then:** interop depth (6: PDF export, .fig best effort), plugins and collaboration (8). +- **Then:** interop depth (6: .fig best effort), plugins and collaboration (8). - **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs. ## Shipped so far diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index e5a5554..8632595 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -106,13 +106,22 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> { .ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))? .into_bytes(), "png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?, - "pdf" => figcraft_engine::cmd::file::export_bytes(doc, &children(doc, page), ExportFormat::Pdf, 1.0) - .map_err(|e| CliError::Failed(e.to_string()))?, + "pdf" => page_pdf(doc, page)?, other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf)"))), }; write(out, &bytes) } +/// A page as PDF: its top-level frames one page each, or (without frames) everything on one page. +fn page_pdf(doc: &Document, page: NodeId) -> CliResult> { + 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).map(|(bytes, _)| bytes) + } else { + figcraft_engine::cmd::file::export_bytes(doc, &children(doc, page), ExportFormat::Pdf, 1.0) + }; + r.map_err(|e| CliError::Failed(e.to_string())) +} + pub fn info(path: &str, as_json: bool) -> CliResult { let (doc, notes) = load_noted(path)?; let pages: Vec = doc @@ -183,11 +192,12 @@ pub fn export(file: &str, out: &str, format: Option<&str>, scale: f64, page: Opt let single = image_format(&ext(out)).is_some(); let bytes_of = |ids: &[NodeId]| figcraft_engine::cmd::file::export_bytes(&doc, ids, fmt, scale).map_err(|e| CliError::Failed(e.to_string())); if single { - let ids = match target { - Some(t) => vec![t], - None => children(&doc, page), + let bytes = match target { + Some(t) => bytes_of(&[t])?, + None if fmt == ExportFormat::Pdf => page_pdf(&doc, page)?, + None => bytes_of(&children(&doc, page))?, }; - write(out, &bytes_of(&ids)?)?; + write(out, &bytes)?; println!("{out}"); return Ok(ExitCode::SUCCESS); } diff --git a/apps/figcraft-cli/tests/pdf.rs b/apps/figcraft-cli/tests/pdf.rs new file mode 100644 index 0000000..28d5ca6 --- /dev/null +++ b/apps/figcraft-cli/tests/pdf.rs @@ -0,0 +1,59 @@ +//! `figcraft-cli export --format pdf` and `convert … .pdf` end to end. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::process::Command; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_figcraft-cli")) +} + +fn pages(pdf: &[u8]) -> usize { + let s = String::from_utf8_lossy(pdf); + s.matches("/Type/Page").count() - s.matches("/Type/Pages").count() +} + +#[test] +fn pdf_export_one_page_per_frame() { + let d = std::env::temp_dir().join(format!("figcraft-cli-pdf-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + // Two frames and a loose rectangle (not a frame: left out of the frames PDF). + let script = d.join("s.jsonl"); + std::fs::write( + &script, + [ + r##"{"command":"frame.create","params":{"x":0,"y":0,"width":100,"height":50,"name":"A","props":{"fill":"#ff0000"}}}"##, + r##"{"command":"frame.create","params":{"x":200,"y":0,"width":30,"height":40,"name":"B"}}"##, + r##"{"command":"shape.rectangle","params":{"x":0,"y":300,"width":500,"height":20}}"##, + ] + .join("\n"), + ) + .unwrap(); + let file = d.join("doc.figcraft"); + let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + + // The whole page: the frames, one PDF page each, sized in points. + let pdf = d.join("page.pdf"); + let out = bin().arg("export").arg(&file).arg("-o").arg(&pdf).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let bytes = std::fs::read(&pdf).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + assert_eq!(pages(&bytes), 2); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("/MediaBox[0 0 100 50]") && s.contains("/MediaBox[0 0 30 40]"), "{s}"); + + // `--format pdf` into a directory: a file per frame; `--node`: that layer alone. + let dir = d.join("out"); + assert!(bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--format", "pdf"]).status().unwrap().success()); + assert_eq!(pages(&std::fs::read(dir.join("A.pdf")).unwrap()), 1); + assert!(dir.join("B.pdf").exists()); + let one = d.join("b.pdf"); + assert!(bin().arg("export").arg(&file).arg("-o").arg(&one).args(["--node", "B"]).status().unwrap().success()); + assert!(String::from_utf8_lossy(&std::fs::read(&one).unwrap()).contains("/MediaBox[0 0 30 40]")); + + // `convert` writes the same frames PDF. + let conv = d.join("conv.pdf"); + assert!(bin().arg("convert").arg(&file).arg(&conv).status().unwrap().success()); + assert_eq!(pages(&std::fs::read(&conv).unwrap()), 2); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs index 8e82d8f..b3fcd41 100644 --- a/crates/doc/src/colors.rs +++ b/crates/doc/src/colors.rs @@ -56,10 +56,12 @@ impl Node { NodeKind::Text(t) => t.for_each_color_mut(f), _ => {} } - for a in self.interactions.iter_mut().flat_map(|i| i.actions.iter_mut()) { - if let Action::OpenOverlay { background: Some(c), .. } = a { - f(c); - } + for it in &mut self.interactions { + crate::proto::walk_mut(&mut it.actions, &mut |a| { + if let Action::OpenOverlay { background: Some(c), .. } = a { + f(c); + } + }); } } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index cc405da..8217e86 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -574,9 +574,7 @@ impl Document { c.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect(); c.parent = if n.id == root { Some(parent) } else { n.parent.and_then(|p| map.get(&p).copied()) }; for it in &mut c.interactions { - for a in &mut it.actions { - remap_action(a, map); - } + proto::walk_mut(&mut it.actions, &mut |a| remap_action(a, map)); } if let NodeKind::Instance { component, .. } = &mut c.kind && let Some(m) = map.get(component) @@ -780,6 +778,8 @@ mod colors_tests; #[cfg(test)] mod diff_tests; #[cfg(test)] +mod proto_expr_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod width_tests; diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index 56b0126..6e34c04 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -1,7 +1,15 @@ //! Prototype data: interactions (trigger → actions) with transitions, flows and overflow. -use crate::NodeId; +use crate::{CollectionId, ModeId, NodeId, Variables}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[path = "proto_expr.rs"] +mod expr; +pub use expr::*; + +/// Deepest nesting of conditional blocks followed. +pub const MAX_ACTION_DEPTH: usize = 16; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] @@ -382,7 +390,109 @@ pub enum Action { SetVariable { variable: String, value: serde_json::Value, + /// Computed when the action runs, in place of `value` (`count + 1`, `not open`). + #[serde(default, skip_serializing_if = "Option::is_none")] + expr: Option, }, + /// Switch a variable collection to one of its modes for the rest of the prototype. + SetVariableMode { + collection: CollectionId, + mode: ModeId, + }, + /// If / else if / else: the actions of the first block whose condition holds run (a block + /// without a condition is the "else"). + Conditional { + blocks: Vec, + }, +} + +/// One branch of a conditional action. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ConditionalBlock { + /// `None`: always (the "else" block). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub condition: Option, + #[serde(default)] + pub actions: Vec, +} + +impl ConditionalBlock { + /// The actions of the first block that applies, with variables resolved in `modes`. + pub fn choose<'a>(blocks: &'a [ConditionalBlock], vars: &Variables, modes: &BTreeMap) -> Option<&'a [Action]> { + blocks.iter().find(|b| b.condition.as_ref().is_none_or(|c| c.holds(vars, modes))).map(|b| b.actions.as_slice()) + } +} + +/// Every action in `actions` and in their conditional blocks (depth-first, in order). +pub fn walk(actions: &[Action]) -> Walk<'_> { + Walk { stack: vec![(actions.iter(), 0)] } +} + +/// Iterator of [`walk`]. +pub struct Walk<'a> { + stack: Vec<(std::slice::Iter<'a, Action>, usize)>, +} + +impl<'a> Iterator for Walk<'a> { + type Item = &'a Action; + fn next(&mut self) -> Option<&'a Action> { + loop { + let (it, depth) = self.stack.last_mut()?; + let depth = *depth; + let Some(a) = it.next() else { + self.stack.pop(); + continue; + }; + if let Action::Conditional { blocks } = a + && depth < MAX_ACTION_DEPTH + { + self.stack.extend(blocks.iter().rev().map(|b| (b.actions.iter(), depth + 1))); + } + return Some(a); + } + } +} + +/// Call `f` on every action in `actions` and in their conditional blocks. +pub fn walk_mut(actions: &mut [Action], f: &mut dyn FnMut(&mut Action)) { + walk_mut_at(actions, f, 0); +} + +fn walk_mut_at(actions: &mut [Action], f: &mut dyn FnMut(&mut Action), depth: usize) { + for a in actions { + f(a); + if let Action::Conditional { blocks } = a + && depth < MAX_ACTION_DEPTH + { + for b in blocks { + walk_mut_at(&mut b.actions, f, depth + 1); + } + } + } +} + +/// Keep the actions `keep` accepts, inside conditional blocks too; conditionals left with no +/// action in any block go as well. +pub fn retain_deep(actions: &mut Vec, keep: &mut dyn FnMut(&Action) -> bool) { + retain_at(actions, keep, 0); +} + +fn retain_at(actions: &mut Vec, keep: &mut dyn FnMut(&Action) -> bool, depth: usize) { + actions.retain_mut(|a| { + if !keep(a) { + return false; + } + match a { + Action::Conditional { blocks } if depth < MAX_ACTION_DEPTH => { + for b in blocks.iter_mut() { + retain_at(&mut b.actions, keep, depth + 1); + } + blocks.iter().any(|b| !b.actions.is_empty()) + } + Action::Conditional { .. } => false, + _ => true, + } + }); } impl Action { @@ -397,8 +507,26 @@ impl Action { Action::OpenUrl { .. } => "Open link", Action::ChangeTo { .. } => "Change to", Action::SetVariable { .. } => "Set variable", + Action::SetVariableMode { .. } => "Set variable mode", + Action::Conditional { .. } => "Conditional", } } + + /// The variables the action (and what it nests) sets or reads. + pub fn variables(&self) -> Vec<&str> { + let mut out = Vec::new(); + for a in walk(std::slice::from_ref(self)) { + match a { + Action::SetVariable { variable, expr, .. } => { + out.push(variable.as_str()); + out.extend(expr.iter().flat_map(Expr::variables)); + } + Action::Conditional { blocks } => out.extend(blocks.iter().filter_map(|b| b.condition.as_ref()).flat_map(Expr::variables)), + _ => {} + } + } + out + } pub fn dest(&self) -> Option { match self { Action::Navigate { dest, .. } diff --git a/crates/doc/src/proto_expr.rs b/crates/doc/src/proto_expr.rs new file mode 100644 index 0000000..bea8f3a --- /dev/null +++ b/crates/doc/src/proto_expr.rs @@ -0,0 +1,243 @@ +//! Prototype expressions: the values set-variable actions write and the conditions of +//! conditional actions, built from literals, variable references and operators. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::{CollectionId, ModeId, VariableValue, Variables}; + +/// Deepest expression nesting evaluated (deeper parts evaluate to nothing). +pub const MAX_EXPR_DEPTH: usize = 32; +/// Longest string an expression builds (characters). +const MAX_STRING: usize = 10_000; + +/// An expression over variables. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Expr { + /// A literal, or a variable reference (`VariableValue::Alias`). + Value(VariableValue), + /// An operator applied to its arguments. + Op { op: ExprOp, args: Vec }, +} + +/// Expression operators. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ExprOp { + /// Numbers add; with a string among the arguments, everything joins as text. + Add, + Subtract, + Multiply, + Divide, + Equals, + NotEqual, + Less, + LessOrEqual, + Greater, + GreaterOrEqual, + And, + Or, + /// Boolean not. + Not, + /// Numeric minus. + Negate, +} + +impl ExprOp { + /// The operator as written between (or before) its operands. + pub fn symbol(self) -> &'static str { + match self { + ExprOp::Add => "+", + ExprOp::Subtract => "-", + ExprOp::Multiply => "*", + ExprOp::Divide => "/", + ExprOp::Equals => "==", + ExprOp::NotEqual => "!=", + ExprOp::Less => "<", + ExprOp::LessOrEqual => "<=", + ExprOp::Greater => ">", + ExprOp::GreaterOrEqual => ">=", + ExprOp::And => "and", + ExprOp::Or => "or", + ExprOp::Not => "not", + ExprOp::Negate => "-", + } + } +} + +impl Expr { + /// A reference to variable `id`. + pub fn var(id: impl Into) -> Expr { + Expr::Value(VariableValue::Alias(id.into())) + } + + pub fn op(op: ExprOp, args: Vec) -> Expr { + Expr::Op { op, args } + } + + /// The value, with variables resolved in `modes`. `None` when a variable is missing, the + /// types don't fit the operator, a number isn't finite or the nesting is too deep. + pub fn eval(&self, vars: &Variables, modes: &BTreeMap) -> Option { + self.eval_at(vars, modes, 0) + } + + /// Whether a condition holds: a `true` boolean (anything else, or an error, doesn't). + pub fn holds(&self, vars: &Variables, modes: &BTreeMap) -> bool { + matches!(self.eval(vars, modes), Some(VariableValue::Boolean(true))) + } + + fn eval_at(&self, vars: &Variables, modes: &BTreeMap, depth: usize) -> Option { + if depth > MAX_EXPR_DEPTH { + return None; + } + let (op, args) = match self { + Expr::Value(VariableValue::Alias(id)) => return vars.resolve(id, modes), + Expr::Value(VariableValue::Number(n)) => return n.is_finite().then_some(VariableValue::Number(*n)), + Expr::Value(v) => return Some(v.clone()), + Expr::Op { op, args } => (*op, args), + }; + let vals: Vec = args.iter().map(|a| a.eval_at(vars, modes, depth + 1)).collect::>()?; + apply(op, &vals) + } + + /// Every variable the expression reads. + pub fn variables(&self) -> Vec<&str> { + let mut out = Vec::new(); + self.collect_variables(&mut out, 0); + out + } + + fn collect_variables<'a>(&'a self, out: &mut Vec<&'a str>, depth: usize) { + match self { + Expr::Value(VariableValue::Alias(id)) => out.push(id), + Expr::Op { args, .. } if depth < MAX_EXPR_DEPTH => args.iter().for_each(|a| a.collect_variables(out, depth + 1)), + _ => {} + } + } + + /// Readable text, variables by name (`count + 1`, `open == false`). + pub fn display(&self, vars: &Variables) -> String { + self.display_at(vars, 0) + } + + fn display_at(&self, vars: &Variables, depth: usize) -> String { + if depth > MAX_EXPR_DEPTH { + return "…".into(); + } + match self { + Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| v.name.clone()), + Expr::Value(VariableValue::String(s)) => format!("\"{s}\""), + Expr::Value(v) => v.display(), + Expr::Op { op, args } => { + let parts: Vec = args + .iter() + .map(|a| match a { + Expr::Op { args, .. } if args.len() > 1 => format!("({})", a.display_at(vars, depth + 1)), + _ => a.display_at(vars, depth + 1), + }) + .collect(); + match (op, parts.as_slice()) { + (ExprOp::Not, [x]) => format!("not {x}"), + (ExprOp::Negate, [x]) => format!("-{x}"), + _ => parts.join(&format!(" {} ", op.symbol())), + } + } + } + } +} + +fn number(v: &VariableValue) -> Option { + match v { + VariableValue::Number(n) => Some(*n), + _ => None, + } +} + +fn boolean(v: &VariableValue) -> Option { + match v { + VariableValue::Boolean(b) => Some(*b), + _ => None, + } +} + +fn finite(n: f64) -> Option { + n.is_finite().then_some(VariableValue::Number(n)) +} + +/// Text for joining: numbers without trailing zeros, booleans as `true` / `false`. +fn as_text(v: &VariableValue) -> Option { + match v { + VariableValue::String(s) => Some(s.clone()), + VariableValue::Number(_) | VariableValue::Boolean(_) => Some(v.display()), + VariableValue::Color(_) | VariableValue::Alias(_) => None, + } +} + +fn same(a: &VariableValue, b: &VariableValue) -> bool { + match (a, b) { + (VariableValue::Number(x), VariableValue::Number(y)) => (x - y).abs() <= 1e-9 * x.abs().max(y.abs()).max(1.0), + (VariableValue::Color(x), VariableValue::Color(y)) => x.to_rgba8() == y.to_rgba8(), + _ => a == b, + } +} + +/// `op` over already evaluated arguments. +fn apply(op: ExprOp, vals: &[VariableValue]) -> Option { + use ExprOp::*; + let nums = || vals.iter().map(number).collect::>>(); + let bools = || vals.iter().map(boolean).collect::>>(); + let fold = |f: fn(f64, f64) -> Option| { + let n = nums()?; + let (first, rest) = n.split_first()?; + rest.iter().try_fold(*first, |acc, x| f(acc, *x)).and_then(finite) + }; + match op { + Add if vals.iter().any(|v| matches!(v, VariableValue::String(_))) => { + let mut s = String::new(); + for v in vals { + s.push_str(&as_text(v)?); + } + Some(VariableValue::String(s.chars().take(MAX_STRING).collect())) + } + Add => fold(|a, b| Some(a + b)), + Subtract => fold(|a, b| Some(a - b)), + Multiply => fold(|a, b| Some(a * b)), + Divide => fold(|a, b| (b != 0.0).then(|| a / b)), + Equals | NotEqual => { + let (first, rest) = vals.split_first()?; + if rest.is_empty() { + return None; + } + let eq = rest.iter().all(|v| same(first, v)); + Some(VariableValue::Boolean(if op == Equals { eq } else { !eq })) + } + Less | LessOrEqual | Greater | GreaterOrEqual => { + let [a, b] = vals else { return None }; + let ord = match (a, b) { + (VariableValue::Number(x), VariableValue::Number(y)) => x.partial_cmp(y)?, + (VariableValue::String(x), VariableValue::String(y)) => x.cmp(y), + _ => return None, + }; + use std::cmp::Ordering as O; + Some(VariableValue::Boolean(match op { + Less => ord == O::Less, + LessOrEqual => ord != O::Greater, + Greater => ord == O::Greater, + _ => ord != O::Less, + })) + } + And => bools().filter(|b| !b.is_empty()).map(|b| VariableValue::Boolean(b.iter().all(|x| *x))), + Or => bools().filter(|b| !b.is_empty()).map(|b| VariableValue::Boolean(b.iter().any(|x| *x))), + Not => match vals { + [VariableValue::Boolean(b)] => Some(VariableValue::Boolean(!b)), + _ => None, + }, + Negate => match vals { + [VariableValue::Number(n)] => finite(-n), + [VariableValue::Boolean(b)] => Some(VariableValue::Boolean(!b)), + _ => None, + }, + } +} diff --git a/crates/doc/src/proto_expr_tests.rs b/crates/doc/src/proto_expr_tests.rs new file mode 100644 index 0000000..61613f8 --- /dev/null +++ b/crates/doc/src/proto_expr_tests.rs @@ -0,0 +1,197 @@ +//! Prototype expressions and nested (conditional) actions. + +use std::collections::BTreeMap; + +use crate::color::Color; +use crate::proto::*; +use crate::{Collection, Mode, NodeId, Variable, VariableType, VariableValue, Variables}; + +fn vars() -> Variables { + let values = |a: VariableValue, b: VariableValue| BTreeMap::from([("light".to_string(), a), ("dark".to_string(), b)]); + let var = |id: &str, kind, values| Variable { + id: id.into(), + name: format!("{id}-name"), + collection: "c".into(), + kind, + values, + description: String::new(), + scopes: None, + hidden_from_publishing: false, + }; + Variables { + collections: vec![Collection { + id: "c".into(), + name: "Theme".into(), + modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }], + }], + variables: vec![ + var("n", VariableType::Number, values(VariableValue::Number(3.0), VariableValue::Number(7.0))), + var("s", VariableType::String, values(VariableValue::String("hi".into()), VariableValue::String("yo".into()))), + var("b", VariableType::Boolean, values(VariableValue::Boolean(true), VariableValue::Boolean(false))), + var("alias", VariableType::Number, values(VariableValue::Alias("n".into()), VariableValue::Alias("n".into()))), + ], + } +} + +fn n(x: f64) -> Expr { + Expr::Value(VariableValue::Number(x)) +} + +fn s(x: &str) -> Expr { + Expr::Value(VariableValue::String(x.into())) +} + +fn b(x: bool) -> Expr { + Expr::Value(VariableValue::Boolean(x)) +} + +fn eval(e: &Expr) -> Option { + e.eval(&vars(), &BTreeMap::new()) +} + +#[test] +fn arithmetic_text_and_comparisons() { + use ExprOp::*; + let num = |x: f64| Some(VariableValue::Number(x)); + let yes = Some(VariableValue::Boolean(true)); + let no = Some(VariableValue::Boolean(false)); + assert_eq!(eval(&Expr::op(Add, vec![Expr::var("n"), n(1.0)])), num(4.0)); + assert_eq!(eval(&Expr::op(Subtract, vec![n(10.0), Expr::var("alias"), n(2.0)])), num(5.0), "left to right, through aliases"); + assert_eq!(eval(&Expr::op(Multiply, vec![Expr::var("n"), n(2.5)])), num(7.5)); + assert_eq!(eval(&Expr::op(Divide, vec![n(9.0), Expr::var("n")])), num(3.0)); + assert_eq!(eval(&Expr::op(Divide, vec![n(1.0), n(0.0)])), None, "division by zero"); + assert_eq!(eval(&Expr::op(Multiply, vec![n(1e300), n(1e300)])), None, "overflow"); + assert_eq!(eval(&Expr::op(Negate, vec![Expr::var("n")])), num(-3.0)); + // Text joins. + assert_eq!(eval(&Expr::op(Add, vec![s("Count: "), Expr::var("n"), s(" / "), b(true)])), Some(VariableValue::String("Count: 3 / true".into()))); + assert_eq!(eval(&Expr::op(Add, vec![s("x"), Expr::Value(VariableValue::Color(Color::BLACK))])), None); + // Comparisons. + assert_eq!(eval(&Expr::op(Equals, vec![Expr::var("n"), n(3.0)])), yes); + assert_eq!(eval(&Expr::op(Equals, vec![n(0.1 + 0.2), n(0.3)])), yes, "float noise"); + assert_eq!(eval(&Expr::op(NotEqual, vec![Expr::var("s"), s("hi")])), no); + assert_eq!(eval(&Expr::op(Equals, vec![Expr::var("s"), n(3.0)])), no, "different types differ"); + assert_eq!(eval(&Expr::op(Less, vec![Expr::var("n"), n(4.0)])), yes); + assert_eq!(eval(&Expr::op(LessOrEqual, vec![n(4.0), n(4.0)])), yes); + assert_eq!(eval(&Expr::op(Greater, vec![s("b"), s("a")])), yes, "strings compare"); + assert_eq!(eval(&Expr::op(GreaterOrEqual, vec![n(1.0), n(2.0)])), no); + assert_eq!(eval(&Expr::op(Greater, vec![n(1.0), s("a")])), None); + assert_eq!(eval(&Expr::op(Less, vec![n(1.0)])), None, "two operands"); + // Logic. + assert_eq!(eval(&Expr::op(And, vec![Expr::var("b"), b(true)])), yes); + assert_eq!(eval(&Expr::op(Or, vec![b(false), b(false)])), no); + assert_eq!(eval(&Expr::op(Not, vec![Expr::var("b")])), no); + assert_eq!(eval(&Expr::op(And, vec![b(true), n(1.0)])), None); + assert_eq!(eval(&Expr::op(And, vec![])), None); +} + +#[test] +fn variables_resolve_in_the_modes_given() { + let dark = BTreeMap::from([("c".to_string(), "dark".to_string())]); + let e = Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]); + assert_eq!(e.eval(&vars(), &dark), Some(VariableValue::Number(8.0))); + assert!(!Expr::var("b").holds(&vars(), &dark)); + assert!(Expr::var("b").holds(&vars(), &BTreeMap::new())); + // Missing variables and non-boolean conditions don't hold. + assert!(!Expr::var("missing").holds(&vars(), &dark)); + assert!(!n(1.0).holds(&vars(), &dark)); + assert_eq!(e.variables(), ["n"]); +} + +#[test] +fn display_reads_like_a_formula() { + let v = vars(); + let e = Expr::op(ExprOp::Equals, vec![Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]), n(4.0)]); + assert_eq!(e.display(&v), "(n-name + 1) == 4"); + assert_eq!(Expr::op(ExprOp::Not, vec![Expr::var("b")]).display(&v), "not b-name"); + assert_eq!(Expr::op(ExprOp::Add, vec![s("a"), Expr::var("gone")]).display(&v), "\"a\" + (missing variable)"); +} + +#[test] +fn deep_and_hostile_expressions_are_bounded() { + let mut e = n(1.0); + for _ in 0..(MAX_EXPR_DEPTH + 5) { + e = Expr::op(ExprOp::Add, vec![e, n(1.0)]); + } + assert_eq!(eval(&e), None, "too deep"); + assert!(e.display(&vars()).contains('…')); + assert!(e.variables().is_empty()); + assert_eq!(eval(&n(f64::NAN)), None); + let long = "x".repeat(8000); + let joined = Expr::op(ExprOp::Add, vec![s(&long), s(&long)]); + let Some(VariableValue::String(t)) = eval(&joined) else { panic!() }; + assert_eq!(t.chars().count(), 10_000, "capped"); +} + +fn nav(id: u64) -> Action { + Action::Navigate { dest: NodeId(id), preserve_scroll: false } +} + +fn cond(blocks: Vec<(Option, Vec)>) -> Action { + Action::Conditional { blocks: blocks.into_iter().map(|(condition, actions)| ConditionalBlock { condition, actions }).collect() } +} + +#[test] +fn walking_nested_actions() { + let mut actions = vec![ + nav(1), + cond(vec![ + (Some(Expr::var("b")), vec![nav(2), cond(vec![(None, vec![nav(3)])])]), + (None, vec![Action::SetVariable { variable: "n".into(), value: serde_json::Value::Null, expr: Some(Expr::var("alias")) }]), + ]), + Action::Back, + ]; + let dests: Vec = walk(&actions).filter_map(Action::dest).collect(); + assert_eq!(dests, [NodeId(1), NodeId(2), NodeId(3)], "depth-first, in order"); + assert_eq!(walk(&actions).count(), 7); + let mut vars_used: Vec<&str> = actions.iter().flat_map(Action::variables).collect(); + vars_used.sort_unstable(); + assert_eq!(vars_used, ["alias", "b", "n"]); + walk_mut(&mut actions, &mut |a| { + if let Action::Navigate { dest, .. } = a { + dest.0 += 10; + } + }); + assert_eq!(walk(&actions).filter_map(Action::dest).collect::>(), [NodeId(11), NodeId(12), NodeId(13)]); + // Dropping navigations empties the inner conditional (gone) but not the outer one. + retain_deep(&mut actions, &mut |a| !matches!(a, Action::Navigate { .. })); + assert_eq!(walk(&actions).count(), 3, "{actions:?}"); + retain_deep(&mut actions, &mut |a| !matches!(a, Action::SetVariable { .. })); + assert_eq!(actions, [Action::Back]); +} + +#[test] +fn conditional_choice_and_deep_nesting() { + let v = vars(); + let blocks = [ + ConditionalBlock { condition: Some(Expr::var("missing")), actions: vec![nav(1)] }, + ConditionalBlock { condition: Some(Expr::var("b")), actions: vec![nav(2)] }, + ConditionalBlock { condition: None, actions: vec![nav(3)] }, + ]; + assert_eq!(ConditionalBlock::choose(&blocks, &v, &BTreeMap::new()), Some(&[nav(2)][..])); + let dark = BTreeMap::from([("c".to_string(), "dark".to_string())]); + assert_eq!(ConditionalBlock::choose(&blocks, &v, &dark), Some(&[nav(3)][..])); + assert_eq!(ConditionalBlock::choose(&blocks[..1], &v, &dark), None); + // Nesting deeper than the limit stops walking (no stack overflow). + let mut a = nav(9); + for _ in 0..(MAX_ACTION_DEPTH * 4) { + a = cond(vec![(None, vec![a])]); + } + let list = vec![a]; + assert_eq!(walk(&list).count(), MAX_ACTION_DEPTH + 1); +} + +#[test] +fn serde_round_trip_and_old_files() { + let a = vec![ + cond(vec![(Some(Expr::op(ExprOp::GreaterOrEqual, vec![Expr::var("n"), n(2.0)])), vec![nav(4)]), (None, vec![])]), + Action::SetVariableMode { collection: "c".into(), mode: "dark".into() }, + Action::SetVariable { variable: "n".into(), value: serde_json::json!(1), expr: None }, + ]; + let j = serde_json::to_value(&a).unwrap_or_default(); + assert_eq!(j[2].get("expr"), None, "no expression: the field is left out"); + let back: Vec = serde_json::from_value(j).unwrap_or_default(); + assert_eq!(back, a); + // Files written before expressions existed still read. + let old: Action = serde_json::from_str(r#"{"type":"setVariable","variable":"n","value":2}"#).unwrap_or(Action::Back); + assert_eq!(old, Action::SetVariable { variable: "n".into(), value: serde_json::json!(2), expr: None }); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 024cc6e..6824a25 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -617,12 +617,8 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: 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 r = if all_frames { - figcraft_pdf::export_pdf(doc, ids, &figcraft_pdf::PdfOptions::default()) - } else { - figcraft_pdf::export_nodes_pdf(doc, &super::edit::z_sorted(doc, ids)) - }; - r.map_err(|e| EngineError::Other(e.to_string())) + let groups: Vec> = if all_frames { ids.iter().map(|i| vec![*i]).collect() } else { vec![super::edit::z_sorted(doc, ids)] }; + pdf_bytes(doc, &groups) } _ => { let pix = if ids.len() == 1 { @@ -779,6 +775,23 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { 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]) -> Result> { + let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), ..Default::default() }; + figcraft_pdf::export_groups(doc, groups, &opts).map_err(|e| EngineError::Other(e.to_string())) +} + +/// Every top-level frame of `page` (reading order) as one PDF page each, and the page count. +pub fn frames_pdf(doc: &Document, page: NodeId) -> Result<(Vec, usize)> { + let mut frames = doc.top_frames(page); + frames.retain(|f| doc.node(*f).is_some_and(|n| n.visible)); + if frames.is_empty() { + return Err(EngineError::Disabled("file.exportFramesPdf".into(), "the page has no visible frames".into())); + } + let groups: Vec> = frames.iter().map(|f| vec![*f]).collect(); + Ok((pdf_bytes(&doc.in_srgb(), &groups)?, frames.len())) +} + /// Every top-level frame of a page (reading order), one PDF page each. fn export_frames_pdf(s: &mut Session, p: &Value) -> Result { let doc = s.doc()?; @@ -787,19 +800,13 @@ fn export_frames_pdf(s: &mut Session, p: &Value) -> Result { Some(_) => return Err(bad("file.exportFramesPdf", "`page` is not a page")), None => s.page()?, }; - let frames = doc.top_frames(page); - if frames.is_empty() { - return Err(EngineError::Disabled("file.exportFramesPdf".into(), "the page has no frames".into())); - } - let title = Some(doc.name.clone()); - let bytes = figcraft_pdf::export_pdf(doc, &frames, &figcraft_pdf::PdfOptions { title, ..Default::default() }) - .map_err(|e| EngineError::Other(e.to_string()))?; + let (bytes, pages) = frames_pdf(doc, page)?; match string(p, "path") { Some(path) => { write(&path, &bytes)?; - Ok(json!({"path": path, "bytes": bytes.len(), "pages": frames.len()})) + Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) } - None => Ok(json!({"format": "pdf", "pages": frames.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})), + None => Ok(json!({"format": "pdf", "pages": pages, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})), } } diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index 61ad2d7..882181a 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -213,7 +213,7 @@ fn open_player(s: &mut Session, p: &Value, cmd: &str, ui: &str) -> Result /// Every node an action points at must exist. fn check_dests(cmd: &str, s: &Session, it: &Interaction) -> Result<()> { let doc = s.doc()?; - for a in &it.actions { + for a in figcraft_doc::proto::walk(&it.actions) { if let Some(d) = a.dest() && !doc.contains(d) { @@ -223,6 +223,27 @@ fn check_dests(cmd: &str, s: &Session, it: &Interaction) -> Result<()> { Ok(()) } +/// Variables, collections and modes that actions name by name, stored by id. +fn canonical_ids(doc: &Document, it: &mut Interaction) { + let vars = &doc.variables; + walk_mut(&mut it.actions, &mut |a| match a { + Action::SetVariable { variable, .. } => { + if let Some(v) = vars.get(variable) { + *variable = v.id.clone(); + } + } + Action::SetVariableMode { collection, mode } => { + if let Some(c) = vars.collection(collection) { + if let Some(m) = c.modes.iter().find(|m| m.id == *mode).or_else(|| c.modes.iter().find(|m| m.name == *mode)) { + *mode = m.id.clone(); + } + *collection = c.id.clone(); + } + } + _ => {} + }); +} + fn duration(p: &Value) -> Option { num(p, "durationMs").or_else(|| num(p, "duration_ms")).map(|d| d.clamp(0.0, 60_000.0) as u32) } @@ -249,6 +270,8 @@ fn add(s: &mut Session, p: &Value) -> Result { let easing: Easing = parse(cmd, p, "easing")?.unwrap_or_default(); Interaction { trigger, actions, transition, duration_ms: duration(p).unwrap_or(300), easing } }; + let mut it = it; + canonical_ids(s.doc()?, &mut it); check_dests(cmd, s, &it)?; let mut index = 0; for i in req_targets(cmd, s, p)? { @@ -301,6 +324,7 @@ fn set(s: &mut Session, p: &Value) -> Result { if it.actions.is_empty() { return Err(bad(cmd, "an interaction needs an action")); } + canonical_ids(s.doc()?, &mut it); check_dests(cmd, s, &it)?; if let Some(slot) = s.doc_mut()?.get_mut(i)?.interactions.get_mut(k) { *slot = it; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 706bb3f..d05164b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ pub mod inspect; pub mod library; pub mod measure; pub mod params; +pub mod proto_run; pub mod shapebuilder; pub mod snap; pub mod synthetic; @@ -897,6 +898,8 @@ mod proto_multi_tests; #[cfg(test)] mod proto_present_tests; #[cfg(test)] +mod proto_run_tests; +#[cfg(test)] mod rotated_select_tests; #[cfg(test)] mod section_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index a916955..dcaa585 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -219,12 +219,9 @@ fn alias_closure(vars: &Variables, seeds: impl IntoIterator) out } -/// The variables a layer's prototype actions set. -fn set_variables(n: &Node) -> impl Iterator { - n.interactions.iter().flat_map(|it| &it.actions).filter_map(|a| match a { - Action::SetVariable { variable, .. } => Some(variable), - _ => None, - }) +/// The variables a layer's prototype actions set or read (in expressions and conditions). +fn action_variables(n: &Node) -> impl Iterator { + n.interactions.iter().flat_map(|it| &it.actions).flat_map(Action::variables) } /// Variables the layers under `roots` bind, and the collections they choose modes of. @@ -716,7 +713,11 @@ fn localize(n: &mut Node, src: &Document, map: &BTreeMap, styles } n.plugin_data.remove("figcraft.thumbnail"); for it in &mut n.interactions { - it.actions.retain(|a| !matches!(a, Action::SetVariable { .. }) && a.dest().is_none_or(|d| map.contains_key(&d))); + // Variable actions stay with the library's file. + figcraft_doc::proto::retain_deep(&mut it.actions, &mut |a| { + !matches!(a, Action::SetVariable { .. } | Action::SetVariableMode { .. } | Action::Conditional { .. }) + && a.dest().is_none_or(|d| map.contains_key(&d)) + }); } n.interactions.retain(|it| !it.actions.is_empty()); } @@ -898,8 +899,8 @@ pub fn prune(doc: &mut Document, library: &str) -> Result<()> { // Variables layers bind or set, and local variables alias (with what those alias). let seeds = doc .nodes() - .flat_map(|n| n.bound.values().chain(set_variables(n))) - .cloned() + .flat_map(|n| n.bound.values().map(String::as_str).chain(action_variables(n))) + .map(str::to_string) .chain(doc.variables.variables.iter().filter(|v| !is_remote_variable(doc, &v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x { VariableValue::Alias(a) => Some(a.clone()), _ => None, @@ -912,9 +913,21 @@ pub fn prune(doc: &mut Document, library: &str) -> Result<()> { doc.variables.variables.retain(|v| !unused.contains(&v.id)); doc.libraries_mut().variables.retain(|r| !unused.contains(&r.local)); } - // Collections the remaining variables are in, or layers choose a mode of. - let used: BTreeSet = - doc.variables.variables.iter().map(|v| v.collection.clone()).chain(doc.nodes().flat_map(|n| n.var_modes.keys()).cloned()).collect(); + // Collections the remaining variables are in, or layers (and their prototype actions) choose a + // mode of. + let action_modes = doc.nodes().flat_map(|n| &n.interactions).flat_map(|it| figcraft_doc::proto::walk(&it.actions)).filter_map(|a| match a { + Action::SetVariableMode { collection, .. } => Some(collection), + _ => None, + }); + let used: BTreeSet = doc + .variables + .variables + .iter() + .map(|v| &v.collection) + .chain(doc.nodes().flat_map(|n| n.var_modes.keys())) + .chain(action_modes) + .cloned() + .collect(); let unused: BTreeSet = doc.libraries.collections.iter().filter(|r| r.library == library && !used.contains(&r.local)).map(|r| r.local.clone()).collect(); if !unused.is_empty() { diff --git a/crates/engine/src/proto_run.rs b/crates/engine/src/proto_run.rs new file mode 100644 index 0000000..ebca984 --- /dev/null +++ b/crates/engine/src/proto_run.rs @@ -0,0 +1,87 @@ +//! Prototype actions on variables, run by the prototype player on its own copy of the document: +//! set variable (a value or an expression), set variable mode, and choosing the branch of a +//! conditional action. + +use figcraft_doc::proto::{Action, ConditionalBlock, Expr}; +use figcraft_doc::{Document, NodeId, VariableType, VariableValue}; +use serde_json::Value; + +use crate::cmd::styles::apply_bindings; +use crate::params::{color_of, f64_of}; + +/// A JSON value as a value of a `kind` variable (`None` when it doesn't fit). +pub fn literal(kind: VariableType, v: &Value) -> Option { + Some(match kind { + VariableType::Color => VariableValue::Color(color_of(v)?), + VariableType::Number => VariableValue::Number(f64_of(v).filter(|n| n.is_finite())?), + VariableType::Boolean => VariableValue::Boolean(match v { + Value::Bool(b) => *b, + Value::String(s) => s == "true", + Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0), + _ => return None, + }), + VariableType::String => VariableValue::String(match v { + Value::String(s) => s.clone(), + Value::Bool(_) | Value::Number(_) => v.to_string(), + _ => return None, + }), + }) +} + +/// An expression result as a value of a `kind` variable: numbers and booleans become text for +/// string variables; other mismatches don't fit. +fn fit(kind: VariableType, v: VariableValue) -> Option { + match (kind, v) { + (VariableType::String, v @ (VariableValue::Number(_) | VariableValue::Boolean(_))) => Some(VariableValue::String(v.display())), + (k, v) if v.type_of() == Some(k) => Some(v), + _ => None, + } +} + +/// Set `variable` to `value`, or to `expr` evaluated in the modes of `node` (the layer whose +/// interaction runs), in every mode, and re-apply bindings. False when nothing could be set. +pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Value, expr: Option<&Expr>) -> bool { + let Some(var) = doc.variables.get(variable) else { return false }; + let (id, kind) = (var.id.clone(), var.kind); + let v = match expr { + Some(e) => e.eval(&doc.variables, &doc.modes_for(node)).and_then(|v| fit(kind, v)), + None => literal(kind, value), + }; + let Some(v) = v else { return false }; + let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == id) else { return false }; + for val in slot.values.values_mut() { + *val = v.clone(); + } + apply_bindings(doc); + true +} + +/// Switch `collection` to `mode` (ids or names) for the prototype `frame` is in: on its page, and +/// on the page's top-level frames that chose another mode of it (modes chosen deeper inside still +/// win), then re-apply bindings. False for an unknown collection or mode. +pub fn set_mode(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool { + let Some(c) = doc.variables.collection(collection) else { return false }; + let Some(mode) = c.modes.iter().find(|m| m.id == mode).or_else(|| c.modes.iter().find(|m| m.name == mode)).map(|m| m.id.clone()) else { + return false; + }; + let cid = c.id.clone(); + let Some(page) = doc.page_of(frame) else { return false }; + let mut targets: Vec = doc + .node(page) + .map(|p| p.children.iter().copied().filter(|t| doc.node(*t).is_some_and(|n| n.var_modes.contains_key(&cid))).collect()) + .unwrap_or_default(); + targets.push(page); + for id in targets { + if let Some(n) = doc.node_mut(id) { + n.var_modes.insert(cid.clone(), mode.clone()); + } + } + apply_bindings(doc); + true +} + +/// The actions of the branch of a conditional action that runs, with variables resolved in the +/// modes of `node`. +pub fn branch(doc: &Document, node: NodeId, blocks: &[ConditionalBlock]) -> Vec { + ConditionalBlock::choose(blocks, &doc.variables, &doc.modes_for(node)).map(<[Action]>::to_vec).unwrap_or_default() +} diff --git a/crates/engine/src/proto_run_tests.rs b/crates/engine/src/proto_run_tests.rs new file mode 100644 index 0000000..c59afe9 --- /dev/null +++ b/crates/engine/src/proto_run_tests.rs @@ -0,0 +1,209 @@ +//! Prototype actions on variables (set variable with expressions, set variable mode, conditional +//! branches) and conditional actions through the interaction commands. + +use figcraft_doc::proto::{Action, ConditionalBlock, Expr, ExprOp}; +use figcraft_doc::{Document, NodeId, NodeKind, VariableType, VariableValue}; +use serde_json::json; + +use crate::Session; +use crate::params::id_of; +use crate::proto_run::{branch, literal, set_mode, set_variable}; + +fn var_id(doc: &Document, name: &str) -> String { + doc.variables.get(name).map(|v| v.id.clone()).unwrap_or_default() +} + +fn num(n: f64) -> Expr { + Expr::Value(VariableValue::Number(n)) +} + +/// Two frames; a counter, a label and a colour in a collection with Light and Dark modes; a text +/// layer bound to the label and a rectangle bound to the colour. +fn setup() -> (Session, NodeId, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let a = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "A"})).unwrap()["id"]).unwrap(); + let b = id_of(&s.execute("frame.create", &json!({"x": 200, "y": 0, "width": 100, "height": 100, "name": "B"})).unwrap()["id"]).unwrap(); + let t = id_of(&s.execute("text.create", &json!({"text": "x", "x": 10, "y": 10, "parent": a})).unwrap()["id"]).unwrap(); + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 50, "width": 10, "height": 10, "parent": a})).unwrap()["id"]).unwrap(); + s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap(); + for (name, kind, value) in [("count", "number", json!(0)), ("label", "string", json!("Zero")), ("bg", "color", json!("#FFFFFF"))] { + s.execute("variable.create", &json!({"collection": "Theme", "name": name, "type": kind, "value": value})).unwrap(); + } + s.execute("variable.set", &json!({"variable": "bg", "mode": "Dark", "value": "#000000"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [t], "field": "characters", "variable": "label"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [r], "field": "fill", "variable": "bg"})).unwrap(); + (s, a, b, t, r) +} + +fn chars(doc: &Document, t: NodeId) -> String { + match doc.node(t).map(|n| &n.kind) { + Some(NodeKind::Text(td)) => td.characters.clone(), + _ => String::new(), + } +} + +fn fill_hex(doc: &Document, r: NodeId) -> Option { + doc.node(r).and_then(|n| n.fills.first()).and_then(|p| p.solid_color()).map(|c| c.to_hex()) +} + +#[test] +fn literals_parse_by_the_variable_type() { + assert_eq!(literal(VariableType::Number, &json!("4")), Some(VariableValue::Number(4.0))); + assert_eq!(literal(VariableType::Boolean, &json!(true)), Some(VariableValue::Boolean(true))); + assert_eq!(literal(VariableType::Boolean, &json!({})), None); + assert_eq!(literal(VariableType::String, &json!(3)), Some(VariableValue::String("3".into()))); + assert!(matches!(literal(VariableType::Color, &json!("#FF0000")), Some(VariableValue::Color(_)))); + assert_eq!(literal(VariableType::Number, &json!("lots")), None); +} + +#[test] +fn set_variable_evaluates_expressions_where_the_layer_is() { + let (s, a, _, t, _) = setup(); + let mut doc = s.doc().unwrap().clone(); + let (count, label) = (var_id(&doc, "count"), var_id(&doc, "label")); + // count = count + 1, twice. + let inc = Expr::op(ExprOp::Add, vec![Expr::var(&count), num(1.0)]); + for _ in 0..2 { + assert!(set_variable(&mut doc, a, &count, &json!(null), Some(&inc))); + } + assert_eq!(doc.variables.resolve(&count, &Default::default()), Some(VariableValue::Number(2.0))); + // label = "Count: " + count reaches the bound text. + let text = Expr::op(ExprOp::Add, vec![Expr::Value(VariableValue::String("Count: ".into())), Expr::var(&count)]); + assert!(set_variable(&mut doc, t, &label, &json!(null), Some(&text))); + assert_eq!(chars(&doc, t), "Count: 2"); + // A number result fits a string variable as text; a boolean doesn't fit a number. + assert!(set_variable(&mut doc, t, &label, &json!(null), Some(&Expr::var(&count)))); + assert_eq!(chars(&doc, t), "2"); + let truth = Expr::op(ExprOp::Equals, vec![Expr::var(&count), num(2.0)]); + assert!(!set_variable(&mut doc, a, &count, &json!(null), Some(&truth))); + // Errors (division by zero, missing variables) leave the value alone. + let div0 = Expr::op(ExprOp::Divide, vec![Expr::var(&count), num(0.0)]); + assert!(!set_variable(&mut doc, a, &count, &json!(null), Some(&div0))); + assert!(!set_variable(&mut doc, a, &count, &json!(null), Some(&Expr::var("nope")))); + assert!(!set_variable(&mut doc, a, "nope", &json!(1), None)); + assert_eq!(doc.variables.resolve(&count, &Default::default()), Some(VariableValue::Number(2.0))); + // A plain value still works. + assert!(set_variable(&mut doc, a, &label, &json!("Done"), None)); + assert_eq!(chars(&doc, t), "Done"); +} + +#[test] +fn set_mode_switches_the_prototype_and_keeps_nested_choices() { + let (mut s, a, b, _, r) = setup(); + // B chose Light explicitly; a group inside A chose Light too (nested: it wins). + s.execute("variable.setMode", &json!({"ids": [b], "collection": "Theme", "mode": "Light"})).unwrap(); + let g = id_of(&s.execute("frame.create", &json!({"x": 40, "y": 40, "width": 20, "height": 20, "parent": a})).unwrap()["id"]).unwrap(); + let r2 = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": g})).unwrap()["id"]).unwrap(); + s.execute("variable.bind", &json!({"ids": [r2], "field": "fill", "variable": "bg"})).unwrap(); + s.execute("variable.setMode", &json!({"ids": [g], "collection": "Theme", "mode": "Light"})).unwrap(); + let mut doc = s.doc().unwrap().clone(); + let theme = doc.variables.collections[0].clone(); + let dark = theme.modes.iter().find(|m| m.name == "Dark").map(|m| m.id.clone()).unwrap(); + assert_eq!(fill_hex(&doc, r).as_deref(), Some("FFFFFF")); + assert!(set_mode(&mut doc, a, &theme.id, &dark)); + assert_eq!(fill_hex(&doc, r).as_deref(), Some("000000"), "bound layers follow the new mode"); + assert_eq!(fill_hex(&doc, r2).as_deref(), Some("FFFFFF"), "a mode chosen inside the frame still wins"); + let page = doc.page_of(a).unwrap(); + assert_eq!(doc.node(page).unwrap().var_modes.get(&theme.id), Some(&dark)); + assert_eq!(doc.node(b).unwrap().var_modes.get(&theme.id), Some(&dark), "top-level frames that chose a mode switch too"); + // Collections and modes by name; unknown ones change nothing. + assert!(set_mode(&mut doc, b, "Theme", "Light")); + assert_eq!(fill_hex(&doc, r).as_deref(), Some("FFFFFF"), "modes by name"); + assert!(!set_mode(&mut doc, a, "Theme", "nope")); + assert!(!set_mode(&mut doc, a, "Nope", &dark)); +} + +#[test] +fn conditional_branches_follow_variables() { + let (s, a, b, _, _) = setup(); + let doc = s.doc().unwrap().clone(); + let count = var_id(&doc, "count"); + let blocks = vec![ + ConditionalBlock { + condition: Some(Expr::op(ExprOp::Greater, vec![Expr::var(&count), num(5.0)])), + actions: vec![Action::Navigate { dest: b, preserve_scroll: false }], + }, + ConditionalBlock { condition: Some(Expr::op(ExprOp::Equals, vec![Expr::var(&count), num(0.0)])), actions: vec![Action::Back] }, + ConditionalBlock { condition: None, actions: vec![Action::CloseOverlay] }, + ]; + assert_eq!(branch(&doc, a, &blocks), [Action::Back]); + let mut d2 = doc.clone(); + assert!(set_variable(&mut d2, a, &count, &json!(9), None)); + assert_eq!(branch(&d2, a, &blocks), [Action::Navigate { dest: b, preserve_scroll: false }]); + assert!(set_variable(&mut d2, a, &count, &json!(3), None)); + assert_eq!(branch(&d2, a, &blocks), [Action::CloseOverlay], "else"); + // Without an else, nothing runs; a condition that errors is false. + let no_else = &blocks[..1]; + assert!(branch(&d2, a, no_else).is_empty()); + let broken = [ConditionalBlock { condition: Some(Expr::var("nope")), actions: vec![Action::Back] }]; + assert!(branch(&d2, a, &broken).is_empty()); +} + +#[test] +fn interaction_commands_take_conditional_actions() { + let (mut s, a, b, _, _) = setup(); + let count = var_id(s.doc().unwrap(), "count"); + let conditional = json!({"type": "conditional", "blocks": [ + {"condition": {"op": {"op": "greaterOrEqual", "args": [{"value": {"type": "alias", "value": count}}, {"value": {"type": "number", "value": 2}}]}}, + "actions": [{"type": "navigate", "dest": b}]}, + {"actions": [{"type": "setVariable", "variable": count, "value": null, + "expr": {"op": {"op": "add", "args": [{"value": {"type": "alias", "value": count}}, {"value": {"type": "number", "value": 1}}]}}}]} + ]}); + s.execute("proto.interaction.add", &json!({"ids": [a], "trigger": {"type": "onClick"}, "actions": [conditional, {"type": "setVariableMode", "collection": "Theme", "mode": "Dark"}]})) + .unwrap(); + let it = s.doc().unwrap().node(a).unwrap().interactions[0].clone(); + let Action::Conditional { blocks } = &it.actions[0] else { panic!("{it:?}") }; + assert_eq!(blocks.len(), 2); + assert!(matches!(&blocks[1].actions[0], Action::SetVariable { expr: Some(_), .. })); + // Collections and modes named by name are stored by id. + let theme = s.doc().unwrap().variables.collections[0].clone(); + let dark = theme.modes.iter().find(|m| m.name == "Dark").map(|m| m.id.clone()).unwrap(); + assert_eq!(it.actions[1], Action::SetVariableMode { collection: theme.id, mode: dark }); + // Destinations inside branches must exist. + let bad = json!({"type": "conditional", "blocks": [{"actions": [{"type": "navigate", "dest": 99_999}]}]}); + assert!(s.execute("proto.interaction.add", &json!({"ids": [a], "action": bad})).is_err()); + // Saved files keep them. + let saved = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap(); + let (back, _) = figcraft_format::load_bytes(&saved).unwrap(); + assert_eq!(back.node(a).unwrap().interactions, s.doc().unwrap().node(a).unwrap().interactions, "saved and loaded"); +} + +#[test] +fn junk_conditional_actions_never_panic() { + let (mut s, a, b, _, _) = setup(); + let junk = [json!(null), json!(-1), json!(1e308), json!("x"), json!([]), json!({}), json!(true)]; + let shapes = |j: &serde_json::Value| { + [ + json!({"type": "conditional", "blocks": j}), + json!({"type": "conditional", "blocks": [{"condition": j, "actions": [{"type": "navigate", "dest": b}]}]}), + json!({"type": "conditional", "blocks": [{"condition": {"op": {"op": "add", "args": [j, j]}}, "actions": j}]}), + json!({"type": "setVariable", "variable": "count", "value": j, "expr": j}), + json!({"type": "setVariable", "variable": "count", "value": null, "expr": {"op": {"op": "divide", "args": [{"value": {"type": "number", "value": 1}}, j]}}}), + json!({"type": "setVariableMode", "collection": j, "mode": j}), + ] + }; + for j in &junk { + for action in shapes(j) { + if s.execute("proto.interaction.add", &json!({"ids": [a], "action": action})).is_ok() { + // Whatever was accepted runs without panicking. + let mut doc = s.doc().unwrap().clone(); + for it in doc.node(a).unwrap().interactions.clone() { + for act in figcraft_doc::proto::walk(&it.actions) { + match act { + Action::SetVariable { variable, value, expr } => { + let _ = set_variable(&mut doc, a, variable, value, expr.as_ref()); + } + Action::SetVariableMode { collection, mode } => { + let _ = set_mode(&mut doc, a, collection, mode); + } + Action::Conditional { blocks } => { + let _ = branch(&doc, a, blocks); + } + _ => {} + } + } + } + } + } + } +} diff --git a/crates/format/src/rest/conditional_tests.rs b/crates/format/src/rest/conditional_tests.rs new file mode 100644 index 0000000..906284f --- /dev/null +++ b/crates/format/src/rest/conditional_tests.rs @@ -0,0 +1,209 @@ +//! REST import of conditional, set-variable-mode and expression prototype actions. Every fixture is +//! synthetic, written by hand from the published REST schema (`Action`, `ConditionalBlock`, +//! `VariableData`, `Expression`). + +use super::proto_variable_tests::{FLAG, LABEL, SPACE, THEME, alias, at, variables_json}; +use super::tests::pointers; +use super::*; +use figcraft_doc::proto::{Action, ConditionalBlock, Expr, ExprOp, Transition, walk}; +use figcraft_doc::{VariableValue, Variables}; +use serde_json::json; + +fn data(ty: &str, value: Value) -> Value { + json!({"type": ty, "value": value}) +} + +fn var(id: &str) -> Value { + data("VARIABLE_ALIAS", alias(id)) +} + +fn expr(function: &str, args: Vec) -> Value { + json!({"type": "EXPRESSION", "resolvedType": "FLOAT", "value": {"expressionFunction": function, "expressionArguments": args}}) +} + +fn set(variable: &str, value: Value) -> Value { + json!({"type": "SET_VARIABLE", "variableId": variable, "variableValue": value}) +} + +fn navigate(dest: &str) -> Value { + json!({"type": "NODE", "destinationId": dest, "navigation": "NAVIGATE", "transition": {"type": "DISSOLVE", "duration": 200, "easing": {"type": "LINEAR"}}}) +} + +fn on(trigger: &str, actions: Vec) -> Value { + json!({"trigger": {"type": trigger}, "actions": actions}) +} + +fn file(interactions: Vec) -> Value { + json!({"name": "Logic", "document": {"id": "0:0", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page", "type": "CANVAS", "children": [ + {"id": "1:1", "name": "Home", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 200, "y": 200}, "children": [ + {"id": "1:2", "name": "Button", "type": "RECTANGLE", "relativeTransform": at(10.0, 10.0), "size": {"x": 40, "y": 20}, + "interactions": interactions} + ]}, + {"id": "1:3", "name": "Details", "type": "FRAME", "relativeTransform": at(300.0, 0.0), "size": {"x": 200, "y": 200}} + ]} + ]}}) +} + +fn import_file(interactions: Vec) -> (Document, Vec) { + import_with(&file(interactions), Some(&variables_json()), &no_images).unwrap() +} + +fn button(doc: &Document) -> &Node { + doc.nodes().find(|n| n.name == "Button").unwrap() +} + +fn has(warnings: &[String], s: &str) -> bool { + warnings.iter().any(|w| w.contains(s)) +} + +fn num(x: f64) -> Expr { + Expr::Value(VariableValue::Number(x)) +} + +#[test] +fn if_else_with_expressions() { + let (doc, warnings) = import_file(vec![on( + "ON_CLICK", + vec![ + set(SPACE, expr("ADDITION", vec![var(SPACE), data("FLOAT", json!(1))])), + json!({"type": "CONDITIONAL", "conditionalBlocks": [ + {"condition": expr("GREATER_THAN", vec![var(SPACE), data("FLOAT", json!(10))]), "actions": [navigate("1:3")]}, + {"condition": expr("AND", vec![var(FLAG), expr("NOT", vec![data("BOOLEAN", json!(false))])]), "actions": []}, + {"actions": [set(FLAG, data("BOOLEAN", json!(false)))]}, + {"condition": var(FLAG), "actions": [{"type": "BACK"}]} + ]}), + ], + )]); + let details = doc.nodes().find(|n| n.name == "Details").unwrap().id; + let its = &button(&doc).interactions; + assert_eq!(its.len(), 1, "{warnings:?}"); + let it = &its[0]; + assert_eq!( + it.actions[0], + Action::SetVariable { variable: SPACE.into(), value: Value::Null, expr: Some(Expr::op(ExprOp::Add, vec![Expr::var(SPACE), num(1.0)])) } + ); + let Action::Conditional { blocks } = &it.actions[1] else { panic!("{it:?}") }; + assert_eq!(blocks.len(), 3, "blocks after the else never run and are dropped"); + assert_eq!( + blocks[0], + ConditionalBlock { + condition: Some(Expr::op(ExprOp::Greater, vec![Expr::var(SPACE), num(10.0)])), + actions: vec![Action::Navigate { dest: details, preserve_scroll: false }], + } + ); + assert!(blocks[1].actions.is_empty(), "an empty branch is kept: it stops the else"); + assert_eq!( + blocks[1].condition, + Some(Expr::op(ExprOp::And, vec![Expr::var(FLAG), Expr::op(ExprOp::Not, vec![Expr::Value(VariableValue::Boolean(false))])])) + ); + assert_eq!(blocks[2].condition, None); + assert_eq!(blocks[2].actions, [Action::SetVariable { variable: FLAG.into(), value: json!(false), expr: None }]); + // The interaction takes the transition of the navigation inside the branch. + assert_eq!((it.transition, it.duration_ms), (Transition::Dissolve, 200)); + // The imported variables evaluate it: space/md is 8, so 8 + 1 = 9 is not > 10. + let vars: &Variables = &doc.variables; + let Some(Expr::Op { .. }) = blocks[0].condition.as_ref() else { panic!() }; + assert!(!blocks[0].condition.as_ref().unwrap().holds(vars, &Default::default())); + assert!(blocks[1].condition.as_ref().unwrap().holds(vars, &Default::default())); + assert!(warnings.iter().all(|w| !w.contains("conditional")), "{warnings:?}"); +} + +#[test] +fn set_variable_mode_and_variable_references() { + let (doc, warnings) = import_file(vec![ + on("ON_HOVER", vec![json!({"type": "SET_VARIABLE_MODE", "variableCollectionId": THEME, "variableModeId": "1:1"})]), + on("MOUSE_LEAVE", vec![json!({"type": "SET_VARIABLE_MODE", "variableCollectionId": THEME, "variableModeId": "9:9"})]), + on("MOUSE_DOWN", vec![json!({"type": "SET_VARIABLE_MODE", "variableCollectionId": "VariableCollectionId:404"})]), + on("MOUSE_ENTER", vec![set(LABEL, var(LABEL)), set(LABEL, expr("ADDITION", vec![data("STRING", json!("Size ")), var(SPACE)]))]), + on("MOUSE_UP", vec![set(SPACE, data("STRING", json!("not a number")))]), + ]); + let its = &button(&doc).interactions; + assert_eq!(its.len(), 2, "{its:#?}"); + assert_eq!(its[0].actions, [Action::SetVariableMode { collection: THEME.into(), mode: "1:1".into() }]); + assert_eq!(its[1].actions[0], Action::SetVariable { variable: LABEL.into(), value: Value::Null, expr: Some(Expr::var(LABEL)) }); + let Action::SetVariable { expr: Some(e), .. } = &its[1].actions[1] else { panic!() }; + assert_eq!(e.eval(&doc.variables, &Default::default()), Some(VariableValue::String("Size 8".into()))); + for w in ["set-variable-mode actions", "values that don't fit"] { + assert!(has(&warnings, w), "{w}: {warnings:?}"); + } +} + +#[test] +fn unsupported_conditions_skip_the_whole_conditional() { + let lookup = json!({"type": "EXPRESSION", "value": {"expressionFunction": "VAR_MODE_LOOKUP", "expressionArguments": [var(SPACE)]}}); + let (doc, warnings) = import_file(vec![ + on( + "ON_PRESS", + vec![ + json!({"type": "CONDITIONAL", "conditionalBlocks": [{"condition": lookup, "actions": [navigate("1:3")]}, {"actions": [{"type": "BACK"}]}]}), + ], + ), + on("ON_DRAG", vec![set(SPACE, expr("ADDITION", vec![var("VariableID:404"), data("FLOAT", json!(1))]))]), + on("ON_CLICK", vec![json!({"type": "CONDITIONAL", "conditionalBlocks": [{"condition": var(FLAG), "actions": [navigate("gone")]}]})]), + on("AFTER_TIMEOUT", vec![set(SPACE, expr("ADDITION", vec![]))]), + ]); + assert!(button(&doc).interactions.is_empty(), "{:#?}", button(&doc).interactions); + for w in ["unsupported functions", "conditions that can't be evaluated", "variables that aren't in the variables JSON", "without a destination"] { + assert!(has(&warnings, w), "{w}: {warnings:?}"); + } +} + +#[test] +fn nested_conditionals_and_depth_limit() { + // if FLAG { if SPACE == 8 { navigate } else { back } } + let inner = json!({"type": "CONDITIONAL", "conditionalBlocks": [ + {"condition": expr("EQUALS", vec![var(SPACE), data("FLOAT", json!(8))]), "actions": [navigate("1:3")]}, + {"actions": [{"type": "BACK"}]} + ]}); + let outer = json!({"type": "CONDITIONAL", "conditionalBlocks": [{"condition": var(FLAG), "actions": [inner]}]}); + let (doc, _) = import_file(vec![on("ON_CLICK", vec![outer])]); + let it = &button(&doc).interactions[0]; + assert_eq!(walk(&it.actions).count(), 4); + let mut deep = json!({"type": "BACK"}); + for _ in 0..40 { + deep = json!({"type": "CONDITIONAL", "conditionalBlocks": [{"condition": var(FLAG), "actions": [deep]}, {"actions": []}]}); + } + let (doc, warnings) = import_file(vec![on("ON_CLICK", vec![deep])]); + assert!(has(&warnings, "nested too deeply"), "{warnings:?}"); + assert!(button(&doc).interactions.iter().all(|it| walk(&it.actions).count() < 40)); +} + +#[test] +fn junk_in_conditional_actions_never_panics() { + let base = file(vec![ + on( + "ON_CLICK", + vec![json!({"type": "CONDITIONAL", "conditionalBlocks": [ + {"condition": expr("LESS_THAN_OR_EQUAL", vec![var(SPACE), expr("MULTIPLICATION", vec![data("FLOAT", json!(2)), var(SPACE)])]), + "actions": [navigate("1:3"), set(LABEL, expr("ADDITION", vec![var(LABEL), data("STRING", json!("!"))]))]}, + {"actions": [{"type": "SET_VARIABLE_MODE", "variableCollectionId": THEME, "variableModeId": "1:0"}]} + ]})], + ), + on("ON_HOVER", vec![set(FLAG, expr("OR", vec![var(FLAG), expr("NEGATE", vec![var(FLAG)])]))]), + ]); + let mut combined = base.clone(); + combined["meta"] = variables_json()["meta"].clone(); + let junk = [json!(null), json!(-1), json!(1e308), json!("x"), json!([]), json!({}), json!(true), alias(FLAG)]; + let mut ptrs = Vec::new(); + pointers(&combined["document"], "/document".into(), &mut ptrs); + for p in &ptrs { + for j in &junk { + let mut v = combined.clone(); + if let Some(slot) = v.pointer_mut(p) { + *slot = j.clone(); + } + if let Ok((doc, _)) = import(&v, &no_images) { + assert!(doc.validate().is_empty(), "{p} = {j}"); + for n in doc.nodes() { + for it in &n.interactions { + assert!(walk(&it.actions).filter_map(Action::dest).all(|d| doc.contains(d)), "{p} = {j}"); + for a in walk(&it.actions) { + assert!(a.variables().iter().all(|v| doc.variables.get(v).is_some()), "{p} = {j}"); + } + } + } + } + } + } +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 7433876..7d46bb7 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -18,6 +18,8 @@ //! main component's (`source`) and deriving overrides is left to the caller (the engine does it //! after loading), since that needs the component model. +#[cfg(test)] +mod conditional_tests; mod props; mod proto; #[cfg(test)] diff --git a/crates/format/src/rest/proto.rs b/crates/format/src/rest/proto.rs index 186ce94..73f2dd3 100644 --- a/crates/format/src/rest/proto.rs +++ b/crates/format/src/rest/proto.rs @@ -3,9 +3,10 @@ //! `prototypeDevice`, `prototypeBackgrounds` and `prototypeStartNodeID`. use figcraft_doc::proto::{ - Action, DeviceRotation, Direction, Easing, Interaction, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger, + Action, ConditionalBlock, DeviceRotation, Direction, Easing, Expr, ExprOp, Interaction, MAX_ACTION_DEPTH, MAX_EXPR_DEPTH, Overflow, + OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger, }; -use figcraft_doc::{Node, NodeId, VariableValue}; +use figcraft_doc::{Node, NodeId, VariableType, VariableValue}; use serde_json::{Value, json}; use super::Ctx; @@ -14,8 +15,9 @@ use super::variables; /// Longest transition or delay kept (ms). const MAX_MS: f64 = 60_000.0; -/// Deepest nesting of conditional blocks followed. -const MAX_ACTION_DEPTH: usize = 8; +/// Most blocks of one conditional and arguments of one expression function read. +const MAX_BLOCKS: usize = 64; +const MAX_ARGS: usize = 64; /// Longest URL kept (characters). const MAX_URL: usize = 4096; /// Default transition length when the file names none (ms). @@ -185,7 +187,7 @@ impl Ctx<'_> { } } - /// Append the action `a` maps to (conditional blocks without a condition are inlined). + /// Append the action `a` maps to (a lone conditional block without a condition is inlined). fn action<'v>(&mut self, a: &'v Value, out: &mut Vec, transition: &mut Option<&'v Value>, depth: usize) { let mapped = match text(a, "type") { Some("BACK") => Some(Action::Back), @@ -199,23 +201,21 @@ impl Ctx<'_> { mapped } Some("SET_VARIABLE") => self.set_variable(a), - Some("SET_VARIABLE_MODE") => { - self.warn("prototype actions that change variable modes are not supported and were skipped"); + Some("SET_VARIABLE_MODE") => self.set_variable_mode(a), + Some("CONDITIONAL") if depth >= MAX_ACTION_DEPTH => { + self.warn("conditional prototype actions nested too deeply were skipped"); None } - Some("CONDITIONAL") => { - let blocks = list(a, "conditionalBlocks"); - match blocks { - // A lone block without a condition always runs. - [only] if only.get("condition").is_none_or(Value::is_null) && depth < MAX_ACTION_DEPTH => { - for inner in list(only, "actions") { - self.action(inner, out, transition, depth + 1); - } + Some("CONDITIONAL") => match list(a, "conditionalBlocks") { + // A lone block without a condition always runs. + [only] if only.get("condition").is_none_or(Value::is_null) => { + for inner in list(only, "actions") { + self.action(inner, out, transition, depth + 1); } - _ => self.warn("conditional prototype actions are not supported and were skipped"), + None } - None - } + blocks => self.conditional(blocks, transition, depth), + }, Some("UPDATE_MEDIA_RUNTIME") => { self.warn("video actions are not supported and were skipped"); None @@ -228,6 +228,35 @@ impl Ctx<'_> { out.extend(mapped); } + /// If / else if / else blocks; skipped whole when a condition can't be imported (running the + /// wrong branch would be worse than none). + fn conditional<'v>(&mut self, blocks: &'v [Value], transition: &mut Option<&'v Value>, depth: usize) -> Option { + let mut out = Vec::new(); + for b in blocks.iter().take(MAX_BLOCKS) { + let condition = match b.get("condition").filter(|c| !c.is_null()) { + None => None, + Some(c) => match self.expr(c, 0) { + Some(e) => Some(e), + None => { + self.warn("conditional prototype actions with conditions that can't be evaluated were skipped"); + return None; + } + }, + }; + let mut actions = Vec::new(); + for inner in list(b, "actions") { + self.action(inner, &mut actions, transition, depth + 1); + } + let otherwise = condition.is_none(); + out.push(ConditionalBlock { condition, actions }); + if otherwise { + // Blocks after the "else" never run. + break; + } + } + out.iter().any(|b| !b.actions.is_empty()).then_some(Action::Conditional { blocks: out }) + } + fn node_action(&mut self, a: &Value) -> Option { let Some(dest) = text(a, "destinationId").and_then(|d| self.dest(d)) else { self.warn("prototype actions without a destination in the file were skipped"); @@ -264,20 +293,98 @@ impl Ctx<'_> { self.warn("set-variable actions on variables that aren't in the variables JSON were skipped"); return None; }; - let id = id.clone(); - let kind = *kind; - let value = a.get("variableValue").and_then(|d| d.get("value")).and_then(|v| variables::value(kind, v)); - let value = match value { + let (variable, kind) = (id.clone(), *kind); + let Some(data) = a.get("variableValue").filter(|d| d.is_object()) else { + self.warn("set-variable actions without a value were skipped"); + return None; + }; + let dynamic = data.get("value").is_some_and(|v| variables::alias(v).is_some()) || expr_parts(data).is_some(); + if dynamic { + // A variable reference or an expression, computed when the action runs. + let Some(expr) = self.expr(data, 0) else { + self.warn("set-variable actions with values that can't be evaluated were skipped"); + return None; + }; + return Some(Action::SetVariable { variable, value: Value::Null, expr: Some(expr) }); + } + let value = match data.get("value").and_then(|v| variables::value(kind, v)) { Some(VariableValue::Color(c)) => json!(c.to_css_hex()), Some(VariableValue::Number(n)) => json!(n), Some(VariableValue::String(s)) => json!(s), Some(VariableValue::Boolean(b)) => json!(b), Some(VariableValue::Alias(_)) | None => { - self.warn("set-variable actions with expressions or variable references are not supported and were skipped"); + self.warn("set-variable actions with values that don't fit the variable were skipped"); return None; } }; - Some(Action::SetVariable { variable: id, value }) + Some(Action::SetVariable { variable, value, expr: None }) + } + + fn set_variable_mode(&mut self, a: &Value) -> Option { + let collection = text(a, "variableCollectionId"); + let mode = text(a, "variableModeId"); + match (collection, mode) { + (Some(c), Some(m)) if self.var_modes.get(c).is_some_and(|modes| modes.iter().any(|x| x == m)) => { + Some(Action::SetVariableMode { collection: c.to_string(), mode: m.to_string() }) + } + _ => { + self.warn("set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"); + None + } + } + } + + /// A `VariableData` value (literal, variable reference or expression) as an expression. + fn expr(&mut self, data: &Value, depth: usize) -> Option { + if depth > MAX_EXPR_DEPTH { + return None; + } + if let Some((function, args)) = expr_parts(data) { + let op = match function { + "ADDITION" => ExprOp::Add, + "SUBTRACTION" => ExprOp::Subtract, + "MULTIPLICATION" => ExprOp::Multiply, + "DIVISION" => ExprOp::Divide, + "EQUALS" => ExprOp::Equals, + "NOT_EQUAL" => ExprOp::NotEqual, + "LESS_THAN" => ExprOp::Less, + "LESS_THAN_OR_EQUAL" => ExprOp::LessOrEqual, + "GREATER_THAN" => ExprOp::Greater, + "GREATER_THAN_OR_EQUAL" => ExprOp::GreaterOrEqual, + "AND" => ExprOp::And, + "OR" => ExprOp::Or, + "NOT" => ExprOp::Not, + "NEGATE" => ExprOp::Negate, + _ => { + self.warn("prototype expressions with unsupported functions (like variable mode lookups) were skipped"); + return None; + } + }; + if args.is_empty() || args.len() > MAX_ARGS { + return None; + } + let args = args.iter().map(|x| self.expr(x, depth + 1)).collect::>>()?; + return Some(Expr::op(op, args)); + } + let v = data.get("value")?; + if let Some(id) = variables::alias(v) { + if !self.var_kinds.contains_key(id) { + self.warn("prototype expressions on variables that aren't in the variables JSON were skipped"); + return None; + } + return Some(Expr::var(id)); + } + let kind = match text(data, "resolvedType").or_else(|| text(data, "type")).and_then(variables::kind_of) { + Some(k) => k, + // Untyped: from the JSON value itself. + None => match v { + Value::Bool(_) => VariableType::Boolean, + Value::Number(_) => VariableType::Number, + Value::String(_) => VariableType::String, + _ => VariableType::Color, + }, + }; + variables::value(kind, v).map(Expr::Value) } /// The imported layer a REST id names. @@ -313,6 +420,12 @@ impl Ctx<'_> { } } +/// The function name and arguments of an `EXPRESSION` value. +fn expr_parts(data: &Value) -> Option<(&str, &[Value])> { + let e = data.get("value").filter(|v| v.get("expressionFunction").is_some())?; + Some((text(e, "expressionFunction")?, list(e, "expressionArguments"))) +} + /// An easing curve by its type name, with custom parameters from `e`. fn easing_type(ty: Option<&str>, e: Option<&Value>) -> Easing { let field = |obj: &str, k: &str| e.and_then(|e| e.get(obj)).and_then(|o| num(o, k)); diff --git a/crates/format/src/rest/proto_variable_tests.rs b/crates/format/src/rest/proto_variable_tests.rs index f1dc293..521e65e 100644 --- a/crates/format/src/rest/proto_variable_tests.rs +++ b/crates/format/src/rest/proto_variable_tests.rs @@ -9,17 +9,17 @@ use figcraft_doc::proto::{ use figcraft_doc::{VariableType, VariableValue}; use serde_json::json; -const THEME: &str = "VariableCollectionId:1:1"; -const BRAND: &str = "VariableID:1:2"; -const SPACE: &str = "VariableID:1:3"; -const LABEL: &str = "VariableID:1:6"; -const FLAG: &str = "VariableID:1:7"; +pub(super) const THEME: &str = "VariableCollectionId:1:1"; +pub(super) const BRAND: &str = "VariableID:1:2"; +pub(super) const SPACE: &str = "VariableID:1:3"; +pub(super) const LABEL: &str = "VariableID:1:6"; +pub(super) const FLAG: &str = "VariableID:1:7"; -fn at(x: f64, y: f64) -> Value { +pub(super) fn at(x: f64, y: f64) -> Value { json!([[1, 0, x], [0, 1, y]]) } -fn alias(id: &str) -> Value { +pub(super) fn alias(id: &str) -> Value { json!({"type": "VARIABLE_ALIAS", "id": id}) } @@ -28,7 +28,7 @@ fn rgba(r: f64, g: f64, b: f64) -> Value { } /// A local variables response: one collection with two modes (the second is the default). -fn variables_json() -> Value { +pub(super) fn variables_json() -> Value { json!({"status": 200, "error": false, "meta": { "variableCollections": {THEME: {"id": THEME, "name": "Theme", "key": "k", "defaultModeId": "1:1", "remote": false, "hiddenFromPublishing": false, "variableIds": [], @@ -212,7 +212,7 @@ fn interactions_triggers_actions_and_transitions() { let id = |n: &str| named(&doc, n).id; let button = named(&doc, "Button"); let its = &button.interactions; - assert_eq!(its.len(), 7, "{its:#?}"); + assert_eq!(its.len(), 8, "{its:#?}"); assert_eq!(its[0].trigger, Trigger::OnClick); assert_eq!(its[0].actions, [Action::Navigate { dest: id("Details"), preserve_scroll: false }]); @@ -231,11 +231,13 @@ fn interactions_triggers_actions_and_transitions() { assert_eq!(its[3].trigger, Trigger::MouseEnter); assert_eq!(its[3].actions, [Action::OpenUrl { url: "https://example.com".into() }]); assert_eq!(its[4].trigger, Trigger::OnHover); - assert_eq!(its[4].actions, [Action::SetVariable { variable: FLAG.into(), value: json!(false) }]); + assert_eq!(its[4].actions, [Action::SetVariable { variable: FLAG.into(), value: json!(false), expr: None }]); assert_eq!((its[4].transition, its[4].duration_ms), (Transition::Instant, 300)); assert_eq!(its[5].actions, [Action::CloseOverlay], "a lone unconditional block is inlined"); - assert_eq!(its[6].trigger, Trigger::MouseUp); - assert_eq!(its[6].actions, [Action::ScrollTo { dest: id("Home") }], "unresolved destinations are dropped"); + assert_eq!(its[6].trigger, Trigger::OnDrag); + assert_eq!(its[6].actions, [Action::SetVariableMode { collection: THEME.into(), mode: "1:0".into() }]); + assert_eq!(its[7].trigger, Trigger::MouseUp); + assert_eq!(its[7].actions, [Action::ScrollTo { dest: id("Home") }], "unresolved destinations are dropped"); // The legacy single transition. let details = named(&doc, "Details"); @@ -244,13 +246,13 @@ fn interactions_triggers_actions_and_transitions() { assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home"), preserve_scroll: false }][..])); assert_eq!((legacy.transition, legacy.duration_ms, legacy.easing), (Transition::Dissolve, 200, Easing::EaseIn)); - for w in ["change variable modes", "video triggers", "modifier keys", "without a destination"] { + for w in ["video triggers", "modifier keys", "without a destination"] { assert!(has(&warnings, w), "{w}: {warnings:?}"); } // Without variables, set-variable actions have nothing to set. let (doc, warnings) = import(&file_json(), &no_images).unwrap(); assert_eq!(named(&doc, "Button").interactions.len(), 6); - assert!(has(&warnings, "set-variable actions"), "{warnings:?}"); + assert!(has(&warnings, "set-variable actions") && has(&warnings, "set-variable-mode actions"), "{warnings:?}"); } #[test] @@ -321,7 +323,7 @@ fn junk_in_prototypes_and_variables_never_panics() { assert!(doc.validate().is_empty(), "{p} = {j}"); for n in doc.nodes() { for it in &n.interactions { - assert!(it.actions.iter().filter_map(Action::dest).all(|d| doc.contains(d)), "{p} = {j}"); + assert!(figcraft_doc::proto::walk(&it.actions).filter_map(Action::dest).all(|d| doc.contains(d)), "{p} = {j}"); } assert!(n.bound.values().all(|v| doc.variables.get(v).is_some()), "{p} = {j}"); } diff --git a/crates/format/src/rest/variables.rs b/crates/format/src/rest/variables.rs index 13d0405..6cd00fe 100644 --- a/crates/format/src/rest/variables.rs +++ b/crates/format/src/rest/variables.rs @@ -23,8 +23,8 @@ pub fn source(v: &Value) -> Option<&Value> { [v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variableCollections").is_some_and(Value::is_object)) } -fn kind_of(t: Option<&str>) -> Option { - Some(match t? { +pub(super) fn kind_of(t: &str) -> Option { + Some(match t { "COLOR" => VariableType::Color, "FLOAT" => VariableType::Number, "STRING" => VariableType::String, @@ -110,7 +110,7 @@ impl Ctx<'_> { if id.is_empty() || self.var_kinds.contains_key(&id) { return None; } - let Some(kind) = kind_of(text(v, "resolvedType")) else { + let Some(kind) = text(v, "resolvedType").and_then(kind_of) else { self.warn("variables of unknown types were skipped"); return None; }; diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs index e466e33..2232768 100644 --- a/crates/pdf/src/draw.rs +++ b/crates/pdf/src/draw.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImagePaint, ImageScaleMode, Paint, PaintKind}; -use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind, StrokeAlign}; +use figcraft_doc::{Document, EffectKind, MaskType as DocMaskType, Node, NodeId, NodeKind, StrokeAlign}; use figcraft_geom::{Affine, BezPath, Rect, Shape}; use krilla::color::rgb; use krilla::geom::{Path, PathBuilder, Size, Transform}; @@ -27,6 +27,12 @@ pub(crate) struct Exporter<'a> { pub page_origin: Affine, /// The page's area in document page coordinates. pub page_rect: Rect, + /// The layers drawn on the current page (z-order): what background blurs see behind them. + pub page_ids: Vec, + /// Text hyperlinks on the current page: their areas (PDF page coordinates) and URLs. + pub links: Vec<(Rect, String)>, + /// Above zero while drawing into a soft mask (nothing there is visible or clickable). + pub(crate) masking: usize, pub(crate) fonts: HashMap>, images: HashMap>, } @@ -201,7 +207,17 @@ fn sniff(b: &[u8]) -> Sniffed { impl<'a> Exporter<'a> { pub(crate) fn new(doc: &'a Document, opts: &'a PdfOptions) -> Self { - Self { doc, opts, page_origin: Affine::IDENTITY, page_rect: Rect::ZERO, fonts: HashMap::new(), images: HashMap::new() } + Self { + doc, + opts, + page_origin: Affine::IDENTITY, + page_rect: Rect::ZERO, + page_ids: Vec::new(), + links: Vec::new(), + masking: 0, + fonts: HashMap::new(), + images: HashMap::new(), + } } /// Draw node `id`; `parent_xf` maps its parent's space to PDF page space. `as_mask` draws a @@ -233,6 +249,8 @@ impl<'a> Exporter<'a> { self.place_raster(s, r, op, *b); } } + // Background blur: the blurred backdrop clipped to the shape, under the node's content. + let backdrop = if fx.iter().any(|k| matches!(k, EffectKind::BackgroundBlur { .. })) && self.masking == 0 { self.backdrop(id) } else { None }; let nb = blend(n.blend); if let Some(b) = nb { s.push_blend_mode(b); @@ -241,6 +259,9 @@ impl<'a> Exporter<'a> { if op < 1.0 { s.push_opacity(norm(op)); } + if let Some(r) = &backdrop { + self.place_raster(s, r, 1.0, BlendMode::Normal); + } self.content(s, n, xf, depth); if let Some(sh) = &shadows { for r in &sh.inner { @@ -325,23 +346,47 @@ impl<'a> Exporter<'a> { } } - /// `content` drawn through the alpha of `mask_id` (an alpha soft mask). + /// `content` drawn through mask layer `mask_id`: its alpha or luminance (a soft mask drawn from + /// the mask layer, so vector, gradient and image masks all work), or its outline (a clip path). fn masked(&mut self, s: &mut Surface, mask_id: NodeId, content: &[NodeId], xf: Affine, depth: usize) { - if content.is_empty() { - return; - } + let Some(m) = self.doc.node(mask_id).filter(|_| !content.is_empty()) else { return }; + let kind = match m.mask_type { + DocMaskType::Vector => { + // The outline, fully opaque; nothing shows through a mask without one. + let mut plain = m.clone(); + plain.is_mask = false; + let clip = figcraft_render::subtree_outline(self.doc, &plain, depth + 1).and_then(|(mut p, r)| { + p.apply_affine(xf * m.transform); + Some((to_path(&p)?, rule(r))) + }); + if let Some((cp, r)) = clip { + s.push_clip_path(&cp, &r); + self.draw_all(s, content, xf, depth); + s.pop(); + } + return; + } + DocMaskType::Luminance => MaskType::Luminosity, + DocMaskType::Alpha => MaskType::Alpha, + }; let stream = { let mut sb = s.stream_builder(); let mut ms = sb.surface(); + self.masking += 1; self.node(&mut ms, mask_id, xf, depth + 1, true); + self.masking -= 1; ms.finish(); sb.finish() }; - s.push_mask(Mask::new(stream, MaskType::Alpha)); - for c in content { + s.push_mask(Mask::new(stream, kind)); + self.draw_all(s, content, xf, depth); + s.pop(); + } + + fn draw_all(&mut self, s: &mut Surface, ids: &[NodeId], xf: Affine, depth: usize) { + for c in ids { self.node(s, *c, xf, depth + 1, false); } - s.pop(); } /// Fill `path` (local space, mapped to the page by `xf`) with one paint on a w×h box. diff --git a/crates/pdf/src/export_tests.rs b/crates/pdf/src/export_tests.rs new file mode 100644 index 0000000..62fb6d6 --- /dev/null +++ b/crates/pdf/src/export_tests.rs @@ -0,0 +1,286 @@ +//! PDF export depth: mask types, background blur, text hyperlinks, image deduplication, page sizes +//! and hostile inputs. Every document here is synthetic. + +use std::sync::Arc; + +use super::*; +use crate::tests::{inflated, page_count, png}; +use figcraft_color::{Color, ImageScaleMode, Paint}; +use figcraft_doc::{Effect, EffectKind, ImageData, MaskType, NodeKind, StyleRun, TextData}; + +const URL: &str = "https://example.com/figcraft"; + +fn ellipse() -> NodeKind { + NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 } +} + +fn text(pdf: &[u8]) -> String { + String::from_utf8_lossy(pdf).into_owned() +} + +fn count(pdf: &[u8], needle: &str) -> usize { + text(pdf).matches(needle).count() +} + +/// A frame with a group masked by an ellipse of `kind` over a gradient rectangle. +fn masked_doc(kind: MaskType) -> (Document, NodeId) { + let mut d = Document::new("masks"); + let p = d.first_page().unwrap_or(NodeId(0)); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 120.0, 120.0).unwrap(); + let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + let m = d.create(g, ellipse(), "Mask", 10.0, 10.0, 80.0, 80.0).unwrap(); + { + let n = d.get_mut(m).unwrap(); + n.is_mask = true; + n.mask_type = kind; + // A half-transparent grey: the alpha and luminance masks see different things. + n.fills = vec![Paint::solid(Color::from_hex("80808080").unwrap())]; + } + let r = d.create(g, NodeKind::Rectangle, "Content", 0.0, 0.0, 100.0, 100.0).unwrap(); + // Opaque: the only soft masks in the file are the mask layer's. + d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::from_hex("2255FF").unwrap())]; + (d, f) +} + +#[test] +fn mask_types_alpha_luminance_and_vector() { + let soft = |kind| { + let (d, f) = masked_doc(kind); + export_pdf(&d, &[f], &PdfOptions::default()).unwrap() + }; + let alpha = soft(MaskType::Alpha); + assert!(text(&alpha).contains("/S/Alpha"), "an alpha soft mask"); + let lum = soft(MaskType::Luminance); + assert!(text(&lum).contains("/S/Luminosity"), "a luminosity soft mask"); + assert!(!text(&lum).contains("/S/Alpha")); + // A vector mask is the mask's outline as a clip path: no soft mask at all, and the clip's + // curves (the ellipse) are in the content. + let vector = soft(MaskType::Vector); + let s = text(&vector); + assert!(!s.contains("/SMask") && !s.contains("/S/Alpha") && !s.contains("/S/Luminosity"), "{s}"); + let content = inflated(&vector); + let ops: Vec<&str> = content.split_whitespace().collect(); + assert!(ops.windows(2).filter(|w| matches!(w[0], "W" | "W*") && w[1] == "n").count() >= 2, "frame and mask clips: {content}"); + assert!(content.contains(" c"), "curves of the ellipse outline"); +} + +#[test] +fn degenerate_vector_mask_exports() { + let (mut d, f) = masked_doc(MaskType::Vector); + let m = d.nodes().find(|n| n.name == "Mask").map(|n| n.id).unwrap(); + // A zero-size mask: an empty clip (nothing shows through), and nothing breaks. + d.get_mut(m).unwrap().width = 0.0; + d.get_mut(m).unwrap().height = 0.0; + let pdf = export_pdf(&d, &[f], &PdfOptions::default()).unwrap(); + assert!(pdf.starts_with(b"%PDF")); +} + +/// A frame with stripes under a frosted panel (background blur). +fn frosted_doc(radius: f64) -> (Document, NodeId, NodeId) { + let mut d = Document::new("frost"); + let p = d.first_page().unwrap_or(NodeId(0)); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 200.0, 120.0).unwrap(); + d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::WHITE)]; + for i in 0..10 { + let r = d.create(f, NodeKind::Rectangle, "Stripe", f64::from(i) * 20.0, 0.0, 10.0, 120.0).unwrap(); + d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::from_hex("E0245E").unwrap())]; + } + let panel = d.create(f, NodeKind::Rectangle, "Panel", 40.0, 30.0, 120.0, 60.0).unwrap(); + { + let n = d.get_mut(panel).unwrap(); + n.fills = vec![Paint::solid(Color::from_hex("FFFFFF40").unwrap())]; + n.corners.radii = [12.0; 4]; + n.effects = vec![Effect { kind: EffectKind::BackgroundBlur { radius }, visible: true }]; + } + // Drawn after the panel: never part of its backdrop. + let above = d.create(f, NodeKind::Rectangle, "Above", 0.0, 0.0, 30.0, 30.0).unwrap(); + d.get_mut(above).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + (d, f, panel) +} + +#[test] +fn background_blur_is_a_blurred_backdrop_image() { + let (d, f, panel) = frosted_doc(16.0); + let pdf = export_pdf(&d, &[f], &PdfOptions::default()).unwrap(); + // The blurred backdrop (an RGBA image: colour plus its alpha as a soft mask). + assert!(count(&pdf, "/Subtype/Image") >= 1); + // Without the effect there is nothing to rasterise. + let mut plain = d.clone(); + plain.get_mut(panel).unwrap().effects.clear(); + let pdf = export_pdf(&plain, &[f], &PdfOptions::default()).unwrap(); + assert_eq!(count(&pdf, "/Subtype/Image"), 0); +} + +#[test] +fn backdrop_leaves_out_the_layer_and_what_is_above_it() { + let (d, f, panel) = frosted_doc(8.0); + let mut tmp = d.clone(); + raster::hide_from(&mut tmp, panel, &[f]); + let hidden: Vec<&str> = tmp.nodes().filter(|n| !n.visible).map(|n| n.name.as_str()).collect(); + assert_eq!(hidden.len(), 2, "{hidden:?}"); + assert!(hidden.contains(&"Panel") && hidden.contains(&"Above")); + // Nested: later siblings at every level, and later layers of the page, go too. + let mut d = d; + let p = d.first_page().unwrap_or(NodeId(0)); + let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); + let inner = d.create(g, NodeKind::Rectangle, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap(); + d.create(g, NodeKind::Rectangle, "Sibling", 0.0, 0.0, 10.0, 10.0).unwrap(); + let later = d.create(p, NodeKind::Frame, "Later", 0.0, 0.0, 10.0, 10.0).unwrap(); + d.get_mut(f).unwrap().strokes = vec![Paint::solid(Color::BLACK)]; + let mut tmp = d.clone(); + raster::hide_from(&mut tmp, inner, &[f, later]); + let hidden: Vec<&str> = tmp.nodes().filter(|n| !n.visible).map(|n| n.name.as_str()).collect(); + assert_eq!(hidden.len(), 3, "{hidden:?}"); + assert!(["Inner", "Sibling", "Later"].iter().all(|h| hidden.contains(h)), "{hidden:?}"); + assert!(tmp.node(f).unwrap().strokes.is_empty(), "strokes paint over children"); + assert!(tmp.nodes().filter(|n| n.name == "Stripe").all(|n| n.visible)); +} + +#[test] +fn text_hyperlinks_become_link_annotations() { + let mut d = Document::new("links"); + let p = d.first_page().unwrap_or(NodeId(0)); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 300.0, 100.0).unwrap(); + let mut td = TextData::new("Read the docs here"); + td.style.font_size = 20.0; + let mut linked = td.style.clone(); + linked.link = Some(URL.into()); + // "docs" (chars 9..13) links. + td.runs = vec![StyleRun { start: 9, end: 13, style: linked }]; + let t = d.create(f, NodeKind::Text(td), "T", 10.0, 10.0, 280.0, 40.0).unwrap(); + d.get_mut(t).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let Some((layout, _)) = figcraft_text::layout_node(d.node(t).unwrap()) else { return }; + if layout.glyphs.is_empty() { + // No fonts on this machine: nothing to lay out or link. + return; + } + let pdf = export_pdf(&d, &[f], &PdfOptions::default()).unwrap(); + let s = text(&pdf); + assert_eq!(s.matches("/Subtype/Link").count(), 1, "{s}"); + assert!(s.contains(URL), "the URL is in the link action"); + // The link covers part of the line, inside the page. + let rect = s.split("/Subtype/Link").nth(1).and_then(|r| r.split("/Rect[").nth(1)).and_then(|r| r.split(']').next()).unwrap(); + let v: Vec = rect.split_whitespace().filter_map(|x| x.parse().ok()).collect(); + assert_eq!(v.len(), 4, "{rect}"); + assert!(v[0] > 10.0 && v[2] < 290.0 && v[2] > v[0], "{v:?}"); + assert!(v[1] >= 0.0 && v[3] <= 100.0 && v[3] > v[1], "{v:?}"); + + // Links inside a mask layer aren't clickable; outlined text keeps its links. + let outlined = export_pdf(&d, &[f], &PdfOptions { outline_text: true, ..Default::default() }).unwrap(); + assert_eq!(text(&outlined).matches("/Subtype/Link").count(), 1); + d.get_mut(t).unwrap().is_mask = true; + let masked = export_pdf(&d, &[f], &PdfOptions::default()).unwrap(); + assert_eq!(text(&masked).matches("/Subtype/Link").count(), 0); +} + +#[test] +fn hostile_links_are_skipped() { + let mut d = Document::new("links"); + let p = d.first_page().unwrap_or(NodeId(0)); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 50.0).unwrap(); + for (i, url) in [" ", &"x".repeat(MAX_URL + 1), "javascript:alert(1)\u{0}"].into_iter().enumerate() { + let mut td = TextData::new("Link"); + td.style.link = Some(url.to_string()); + // One off the page entirely. + let x = if i == 2 { 500.0 } else { 0.0 }; + d.create(f, NodeKind::Text(td), "T", x, 0.0, 80.0, 20.0).unwrap(); + } + let pdf = export_pdf(&d, &[f], &PdfOptions::default()).unwrap(); + assert_eq!(text(&pdf).matches("/Subtype/Link").count(), 0); + assert!(link_annotation(Rect::new(f64::NAN, 0.0, 1.0, 1.0), URL.into(), 10.0, 10.0).is_none()); + assert!(link_annotation(Rect::new(-50.0, -50.0, -10.0, -10.0), URL.into(), 10.0, 10.0).is_none()); + assert!(link_annotation(Rect::new(-5.0, -5.0, 5.0, 5.0), URL.into(), 10.0, 10.0).is_some(), "cut to the page"); +} + +#[test] +fn images_are_embedded_once() { + let mut d = Document::new("dedupe"); + let p = d.first_page().unwrap_or(NodeId(0)); + d.images.insert("same".into(), ImageData { bytes: Arc::new(png(16, 16)), width: 16, height: 16 }); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 400.0, 100.0).unwrap(); + for i in 0..4 { + let r = d.create(f, NodeKind::Rectangle, "R", f64::from(i) * 100.0, 0.0, 90.0, 90.0).unwrap(); + d.get_mut(r).unwrap().fills = vec![Paint::image("same", ImageScaleMode::Fill)]; + } + // The same image on a second page is still one image. + let f2 = d.create(p, NodeKind::Frame, "F2", 0.0, 200.0, 100.0, 100.0).unwrap(); + let r = d.create(f2, NodeKind::Rectangle, "R", 0.0, 0.0, 50.0, 50.0).unwrap(); + d.get_mut(r).unwrap().fills = vec![Paint::image("same", ImageScaleMode::Fit)]; + let pdf = export_pdf(&d, &[f, f2], &PdfOptions::default()).unwrap(); + assert_eq!(page_count(&pdf), 2); + assert_eq!(count(&pdf, "/Subtype/Image"), 1, "one image object for every use"); + // Adjusted copies are images of their own. + if let figcraft_color::PaintKind::Image(img) = &mut d.get_mut(r).unwrap().fills[0].kind { + img.filters.exposure = 0.5; + } + let pdf = export_pdf(&d, &[f, f2], &PdfOptions::default()).unwrap(); + assert_eq!(count(&pdf, "/Subtype/Image"), 2); +} + +#[test] +fn page_sizes_are_frame_sizes_in_points() { + let mut d = Document::new("sizes"); + let p = d.first_page().unwrap_or(NodeId(0)); + let a4 = d.create(p, NodeKind::Frame, "A4", 0.0, 0.0, 595.0, 842.0).unwrap(); + let phone = d.create(p, NodeKind::Frame, "Phone", 700.0, 0.0, 393.0, 852.0).unwrap(); + // A rotated frame exports its bounding box. + let rot = d.create(p, NodeKind::Frame, "Rotated", 0.0, 1000.0, 100.0, 100.0).unwrap(); + d.get_mut(rot).unwrap().transform = + Affine::translate((50.0, 1050.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4) * Affine::translate((-50.0, -50.0)); + let pdf = export_groups(&d, &[vec![a4], vec![phone], vec![rot]], &PdfOptions { title: Some("Sizes".into()), ..Default::default() }).unwrap(); + let s = text(&pdf); + assert_eq!(page_count(&pdf), 3); + assert!(s.contains("/MediaBox[0 0 595 842]") && s.contains("/MediaBox[0 0 393 852]"), "{s}"); + let side = 100.0 * std::f64::consts::SQRT_2; + let rotated = s.split("/MediaBox[0 0 ").filter_map(|r| r.split(' ').next()?.parse::().ok()).any(|w| (w - side).abs() < 0.01); + assert!(rotated, "rotated frame box"); + assert!(s.contains("Sizes"), "title metadata"); +} + +#[test] +fn hostile_effects_and_masks_never_panic() { + for radius in [f64::NAN, f64::INFINITY, -5.0, 0.0, 1e12] { + let (d, f, _) = frosted_doc(radius); + assert!(export_pdf(&d, &[f], &PdfOptions::default()).is_ok(), "radius {radius}"); + } + for kind in MaskType::ALL { + let (mut d, f) = masked_doc(kind); + let m = d.nodes().find(|n| n.name == "Mask").map(|n| n.id).unwrap(); + for w in [f64::NAN, 1e15, -10.0] { + d.get_mut(m).unwrap().width = w; + let _ = export_pdf(&d, &[f], &PdfOptions::default()); + } + // A mask with nothing above it, and a mask that is the frame's only child. + let g = d.nodes().find(|n| n.name == "G").map(|n| n.id).unwrap(); + let kids = d.node(g).unwrap().children.clone(); + if let Some(last) = kids.last() { + d.remove(*last).unwrap(); + } + assert!(export_pdf(&d, &[f], &PdfOptions::default()).is_ok()); + } + // Background blur on a page root itself, and nested three groups deep. + let (mut d, f, _) = frosted_doc(8.0); + d.get_mut(f).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur { radius: 4.0 }, visible: true }]; + assert!(export_pdf(&d, &[f], &PdfOptions::default()).is_ok()); +} + +/// Writes PDFs and PNG renders of the documents above for a visual comparison: +/// `FIGCRAFT_PDF_SAMPLE=/path/stem cargo test -p figcraft-pdf depth_samples`. +#[test] +fn depth_samples() { + let Ok(stem) = std::env::var("FIGCRAFT_PDF_SAMPLE") else { return }; + let mut docs: Vec<(String, Document, NodeId)> = MaskType::ALL + .into_iter() + .map(|k| { + let (d, f) = masked_doc(k); + (format!("mask-{}", k.id()), d, f) + }) + .collect(); + let (d, f, _) = frosted_doc(16.0); + docs.push(("frost".into(), d, f)); + for (name, d, f) in docs { + std::fs::write(format!("{stem}-{name}.pdf"), export_pdf(&d, &[f], &PdfOptions::default()).unwrap()).unwrap(); + let pix = figcraft_render::render_node(&d, f, 2.0).unwrap(); + std::fs::write(format!("{stem}-{name}.png"), figcraft_render::encode_png(&pix).unwrap()).unwrap(); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ede3878..4baa3a3 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -8,13 +8,14 @@ //! - solid fills and linear/radial gradients (with per-stop opacity), paint opacity and blend modes; //! - strokes, as filled outlines (`figcraft_render::draw::stroke_area`), so Inside/Outside //! alignment, per-side weights, dashes and arrow caps are exact; -//! - clip content (a clip path), masks (an alpha soft mask drawn from the mask layer, so vector, -//! gradient and image masks all work), node opacity and blend modes (isolated groups); +//! - clip content (a clip path), masks (alpha and luminance masks are soft masks drawn from the mask +//! layer, so vector, gradient and image masks all work; vector masks clip to the mask's outline), +//! node opacity and blend modes (isolated groups); //! - text: real text in its embedded (subset) font, selectable and searchable, positioned glyph by //! glyph from `figcraft_text`'s layout. Fonts that can't be embedded, glyphs without a source //! character (list markers, ellipses), non-solid/gradient text fills and text strokes fall back //! to glyph outlines. If the PDF writer rejects a font, the whole export is retried with -//! outlined text. +//! outlined text. Hyperlinked text becomes clickable link annotations (one per line and link). //! //! What is rasterised (at [`PdfOptions::raster_scale`], 2× by default): //! - image fills are embedded as images (the original PNG/JPEG/GIF/WebP bytes when no image @@ -22,7 +23,11 @@ //! - angular and diamond gradients: an image of the paint clipped to the shape; //! - drop and inner shadows: images of just the shadow, under/over the vector content; //! - layer blur, noise and texture: the whole node is rendered by `figcraft_render` and placed as -//! one image. Background blur has no backdrop to blur in an isolated export and is left out. +//! one image; +//! - background blur: what the page draws under the layer, rendered, blurred and cut to its shape, +//! placed under its (vector) content. +//! +//! Pages are sized in points, one point per pixel of the document. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod draw; @@ -109,6 +114,26 @@ pub fn export_groups(doc: &Document, groups: &[Vec], opts: &PdfOptions) } } +/// Longest hyperlink kept (bytes). +const MAX_URL: usize = 8192; + +/// A clickable area opening `url`, cut to the w×h page (`None` when nothing of it is on the page). +fn link_annotation(r: Rect, url: String, w: f64, h: f64) -> Option { + use krilla::action::LinkAction; + use krilla::annotation::{LinkAnnotation, Target}; + if !rect_finite(r) { + return None; + } + let r = r.intersect(Rect::new(0.0, 0.0, w, h)); + let url = url.trim(); + if !(r.width() > 0.0 && r.height() > 0.0) || url.is_empty() || url.len() > MAX_URL { + return None; + } + let rect = krilla::geom::Rect::from_ltrb(r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32)?; + let target = Target::Action(LinkAction::new(url.to_string()).into()); + Some(LinkAnnotation::new(rect, target).into()) +} + fn rect_finite(r: Rect) -> bool { r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite() } @@ -129,15 +154,23 @@ fn write(doc: &Document, pages: &[(Vec, Rect)], opts: &PdfOptions) -> Re let mut s = page.surface(); ex.page_origin = Affine::translate((-b.x0, -b.y0)); ex.page_rect = *b; + ex.page_ids = ids.clone(); 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); ex.node(&mut s, *id, ex.page_origin * parent_xf, 0, false); } s.finish(); + for (r, url) in std::mem::take(&mut ex.links) { + if let Some(a) = link_annotation(r, url, w, h) { + page.add_annotation(a); + } + } page.finish(); } pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}"))) } +#[cfg(test)] +mod export_tests; #[cfg(test)] mod tests; diff --git a/crates/pdf/src/raster.rs b/crates/pdf/src/raster.rs index cc98413..f102fdf 100644 --- a/crates/pdf/src/raster.rs +++ b/crates/pdf/src/raster.rs @@ -123,6 +123,40 @@ impl Exporter<'_> { self.place_raster(s, &raster(&pix, Self::view_rect(&view)), 1.0, n.blend); } + /// The backdrop of node `id` (what this page draws under it), blurred by its background blurs + /// and cut to its shape, as one image. + pub(crate) fn backdrop(&mut self, id: NodeId) -> Option { + let doc = self.doc; + let n = doc.node(id)?; + let radii: Vec = n + .effects + .iter() + .filter(|e| e.visible) + .filter_map(|e| match e.kind { + EffectKind::BackgroundBlur { radius } if radius.is_finite() && radius > 0.0 => Some(radius.min(1000.0)), + _ => None, + }) + .collect(); + let reach = radii.iter().fold(0.0, |a: f64, r| a.max(*r)) * 2.0; + let world = doc.world_transform(id); + let (path, rule) = figcraft_render::node_outline(doc, n)?; + let b = doc.world_render_bounds(id)?; + // Render a margin around the shape so the blur pulls in what lies just outside it. + let view = self.raster_view(b.inflate(reach + 1.0, reach + 1.0))?; + let mut tmp = doc.clone(); + hide_from(&mut tmp, id, &self.page_ids); + let mut back = Pixmap::new(view.width, view.height)?; + figcraft_render::render_nodes_into(&tmp, &self.page_ids, &view, &mut back); + for r in radii { + fx::blur(&mut back, r / 2.0 * view.scale); + } + let mut shape = Pixmap::new(view.width, view.height)?; + let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::WHITE), anti_alias: true, ..Default::default() }; + shape.fill_path(&figcraft_render::ts_path(&path)?, &paint, rule, figcraft_render::draw::ts_transform(view.transform() * world), None); + fx::mask_by(&mut back, &shape, false); + Some(raster(&back, Self::view_rect(&view))) + } + /// The drop and inner shadows of node `id`, as images. pub(crate) fn shadows(&mut self, id: NodeId) -> Option { let doc = self.doc; @@ -225,3 +259,30 @@ impl Exporter<'_> { fn finite_px(v: f64) -> i32 { if v.is_finite() { v.round().clamp(-100_000.0, 100_000.0) as i32 } else { 0 } } + +/// Hide node `id` and everything painted after it (later siblings up its ancestry, the page's +/// later layers and the strokes its ancestors paint over their children): what is left is its +/// backdrop on the page made of `roots`. +pub(crate) fn hide_from(doc: &mut figcraft_doc::Document, id: NodeId, roots: &[NodeId]) { + let mut hide: Vec = vec![id]; + let mut cur = id; + for _ in 0..crate::draw::MAX_DEPTH { + if let Some(i) = roots.iter().position(|r| *r == cur) { + hide.extend(roots.iter().skip(i + 1)); + break; + } + let Some(parent) = doc.node(cur).and_then(|n| n.parent) else { break }; + if let Some(p) = doc.node(parent) { + hide.extend(p.children.iter().skip_while(|c| **c != cur).skip(1)); + } + if let Some(pn) = doc.node_mut(parent) { + pn.strokes.clear(); + } + cur = parent; + } + for h in hide { + if let Some(n) = doc.node_mut(h) { + n.visible = false; + } + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index a442108..86057ac 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -5,13 +5,13 @@ use super::*; use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImageScaleMode, Paint}; use figcraft_doc::{BoolOp, Effect, EffectKind, ImageData, NodeKind, StrokeAlign, TextData}; -fn page_count(pdf: &[u8]) -> usize { +pub(crate) fn page_count(pdf: &[u8]) -> usize { let s = String::from_utf8_lossy(pdf); s.matches("/Type/Page").count() - s.matches("/Type/Pages").count() } /// Every stream inflated (krilla deflates content, fonts and CMaps), joined. -fn inflated(pdf: &[u8]) -> String { +pub(crate) fn inflated(pdf: &[u8]) -> String { let mut out = String::new(); let mut i = 0; while let Some(off) = find(&pdf[i..], b"stream") { @@ -37,7 +37,7 @@ fn find(h: &[u8], n: &[u8]) -> Option { h.windows(n.len()).position(|w| w == n) } -fn png(w: u32, h: u32) -> Vec { +pub(crate) fn png(w: u32, h: u32) -> Vec { let mut p = figcraft_render::Pixmap::new(w, h).unwrap(); for (i, px) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().enumerate() { let (x, y) = (i as u32 % w, i as u32 / w); @@ -47,7 +47,7 @@ fn png(w: u32, h: u32) -> Vec { figcraft_render::encode_png(&p).unwrap() } -fn has_inter() -> bool { +pub(crate) fn has_inter() -> bool { figcraft_text::find_face("Inter", "Regular").is_some() } diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 5e458ed..7114ffb 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -40,6 +40,9 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine if !affine_ok(gxf) { return; } + if ex.masking == 0 { + record_links(ex, &layout, gxf); + } let (w, h) = (n.width, n.height); let chars: Vec = t.characters.chars().collect(); let styles: BTreeSet = layout.glyphs.iter().map(|g| g.style).chain(layout.decorations.iter().map(|d| d.style)).collect(); @@ -81,6 +84,39 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine } } +/// Most hyperlink areas on one page. +const MAX_LINKS: usize = 10_000; + +/// Note the hyperlinked text of a layout (`gxf`: layout → PDF page): one area per line and run of +/// glyphs linking to the same URL. +fn record_links(ex: &mut Exporter, layout: &TextLayout, gxf: Affine) { + for line in &layout.lines { + let mut run: Option<(&str, f64, f64)> = None; + let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); + for g in glyphs.iter().map(Some).chain([None]) { + let link = g.and_then(|g| { + let url = layout.styles.get(g.style)?.link.as_deref().filter(|u| !u.trim().is_empty())?; + let (a, b) = (g.x, g.x + g.advance); + (a.is_finite() && b.is_finite()).then(|| (url, a.min(b), a.max(b))) + }); + if let (Some(r), Some((url, a, b))) = (run.as_mut(), link) + && r.0 == url + { + r.1 = r.1.min(a); + r.2 = r.2.max(b); + continue; + } + if let Some((url, x0, x1)) = run.take() + && ex.links.len() < MAX_LINKS + { + let area = gxf.transform_rect_bbox(figcraft_geom::Rect::new(x0, line.top, x1, line.top + line.height)); + ex.links.push((area, url.to_string())); + } + run = link; + } + } +} + impl Exporter<'_> { /// The PDF font of a face (cached; `None` when krilla can't read it). fn font(&mut self, face: &Arc) -> Option { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 33632d2..b77a692 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -263,6 +263,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if p.get("collapseLayers").and_then(Value::as_bool) == Some(true) { app.ui.expanded.clear(); } + // The interaction editor popover in the Prototype panel: [layer id, index]; null closes it. + match p.get("protoEditor") { + Some(Value::Null) => crate::proto_panel::open_editor(ctx, None), + Some(Value::Array(a)) => { + let id = a.first().and_then(Value::as_u64).ok_or("protoEditor: [layer id, interaction index]")?; + let k = a.get(1).and_then(Value::as_u64).unwrap_or(0) as usize; + crate::proto_panel::open_editor(ctx, Some((figcraft_doc::NodeId(id), k))); + } + _ => {} + } // The prototype preview pane: true / a frame id opens it, false or null closes it. match p.get("preview") { Some(Value::Bool(false) | Value::Null) => app.ui.preview = None, diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 6b9a91c..dd2ac4f 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -21,7 +21,7 @@ use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; use figcraft_color::{Color, Paint, PaintKind}; use figcraft_doc::proto::{Action, Device, Direction, Easing, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger}; -use figcraft_doc::{Document, HConstraint, Node, NodeId, NodeKind, VConstraint, VariableValue}; +use figcraft_doc::{Document, HConstraint, Node, NodeId, NodeKind, VConstraint}; use figcraft_geom::{Affine, Point}; use serde_json::{Value, json}; @@ -588,6 +588,12 @@ impl Presenter { } fn perform(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option, now: f64) -> bool { + self.perform_at(a, node, tr, dur, easing, rect, now, 0) + } + + /// One action; `depth` counts the conditional actions it is inside. + #[allow(clippy::too_many_arguments)] + fn perform_at(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option, now: f64, depth: usize) -> bool { let Some(doc) = self.doc.clone() else { return false }; match a { Action::Navigate { dest, preserve_scroll } => { @@ -696,18 +702,28 @@ impl Presenter { self.rev += 1; true } - Action::SetVariable { variable, value } => { + Action::SetVariable { variable, value, expr } => { let Some(d) = self.doc.as_mut() else { return false }; - let Some(var) = d.variables.get(variable).cloned() else { return false }; - let Some(v) = variable_value(&var.values, value) else { return false }; - if let Some(slot) = d.variables.variables.iter_mut().find(|x| x.id == var.id) { - for val in slot.values.values_mut() { - *val = v.clone(); - } + let set = figcraft_engine::proto_run::set_variable(d, node, variable, value, expr.as_ref()); + self.rev += u64::from(set); + set + } + Action::SetVariableMode { collection, mode } => { + let Some(d) = self.doc.as_mut() else { return false }; + let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode); + self.rev += u64::from(set); + set + } + Action::Conditional { blocks } => { + if depth >= figcraft_doc::proto::MAX_ACTION_DEPTH { + return false; } - figcraft_engine::cmd::styles::apply_bindings(d); - self.rev += 1; - true + // Evaluated now: earlier actions of the interaction may have changed variables. + let mut did = false; + for inner in figcraft_engine::proto_run::branch(&doc, node, blocks) { + did |= self.perform_at(&inner, node, tr, dur, easing, rect, now, depth + 1); + } + did } } } @@ -1193,24 +1209,6 @@ pub fn overlay_rect(pos: OverlayPosition, base: Rect, size: Vec2, anchor: Option Rect::from_min_size(pos2(x, y), size) } -fn variable_value(values: &BTreeMap, v: &serde_json::Value) -> Option { - let sample = values.values().next(); - Some(match (sample, v) { - (Some(VariableValue::Color(_)), _) => VariableValue::Color(figcraft_engine::params::color_of(v)?), - (Some(VariableValue::Number(_)), _) => VariableValue::Number(figcraft_engine::params::f64_of(v)?), - (Some(VariableValue::Boolean(_)), _) => VariableValue::Boolean(match v { - serde_json::Value::Bool(b) => *b, - serde_json::Value::String(s) => s == "true", - serde_json::Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0), - _ => return None, - }), - (_, serde_json::Value::String(s)) => VariableValue::String(s.clone()), - (_, serde_json::Value::Bool(b)) => VariableValue::Boolean(*b), - (_, serde_json::Value::Number(n)) => VariableValue::Number(n.as_f64()?), - _ => return None, - }) -} - /// Layers with interactions under `root`, with their screen rects (clipped by frames that clip /// content and by `screen`), bottom-most first. fn hotspots(doc: &Document, root: NodeId, place: &Place, screen: Rect) -> Vec<(NodeId, Rect)> { @@ -2199,6 +2197,10 @@ fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Lay } } +#[cfg(test)] +#[path = "present_variables_tests.rs"] +mod variables_tests; + #[cfg(test)] mod tests { use super::*; @@ -2498,16 +2500,6 @@ mod tests { assert_eq!(overlay_rect(OverlayPosition::Manual, base, size, Some(anchor)).min, pos2(30.0, 60.0)); } - #[test] - fn variables_parse_by_type() { - let mut m = BTreeMap::new(); - m.insert("m1".to_string(), VariableValue::Number(1.0)); - assert_eq!(variable_value(&m, &serde_json::json!("4")), Some(VariableValue::Number(4.0))); - m.insert("m1".to_string(), VariableValue::Boolean(false)); - assert_eq!(variable_value(&m, &serde_json::json!(true)), Some(VariableValue::Boolean(true))); - assert_eq!(variable_value(&m, &serde_json::json!({})), None); - } - /// Smart animate renders a temporary document per animation frame; keep that cheap. #[test] fn smart_animate_frame_cost() { diff --git a/crates/ui-egui/src/present_variables_tests.rs b/crates/ui-egui/src/present_variables_tests.rs new file mode 100644 index 0000000..732cc7c --- /dev/null +++ b/crates/ui-egui/src/present_variables_tests.rs @@ -0,0 +1,105 @@ +//! The player runs variable actions: expressions, conditional branches and variable modes. + +use std::sync::Arc; + +use figcraft_doc::proto::{Action, ConditionalBlock, Expr, ExprOp, Interaction, Trigger}; +use figcraft_doc::{Document, NodeId, VariableValue}; +use figcraft_engine::Session; +use figcraft_engine::params::id_of; +use serde_json::json; + +use super::Presenter; + +/// Screens A and B; a button on A; a number `count` (0) and a colour `bg` (white in Light, black in +/// Dark) bound to A's fill. +fn setup() -> (Document, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let mut make = |cmd: &str, p: serde_json::Value| id_of(&s.execute(cmd, &p).unwrap()["id"]).unwrap(); + let a = make("frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "A"})); + let b = make("frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100, "name": "B"})); + let button = make("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20, "parent": a})); + s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.create", &json!({"collection": "Theme", "name": "count", "type": "number", "value": 0})).unwrap(); + s.execute("variable.create", &json!({"collection": "Theme", "name": "bg", "type": "color", "value": "#FFFFFF"})).unwrap(); + s.execute("variable.set", &json!({"variable": "bg", "mode": "Dark", "value": "#000000"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [a], "field": "fill", "variable": "bg"})).unwrap(); + (s.doc().unwrap().clone(), a, b, button) +} + +fn on_click(doc: &mut Document, node: NodeId, actions: Vec) { + let it = Interaction { trigger: Trigger::OnClick, actions, transition: Default::default(), duration_ms: 0, easing: Default::default() }; + doc.get_mut(node).unwrap().interactions.push(it); +} + +fn count_of(p: &Presenter) -> Option { + let d = p.doc.as_ref()?; + let id = d.variables.get("count")?.id.clone(); + d.variables.resolve(&id, &Default::default()) +} + +#[test] +fn counter_then_conditional_navigation() { + let (mut doc, a, b, button) = setup(); + let count = doc.variables.get("count").unwrap().id.clone(); + on_click( + &mut doc, + button, + vec![ + Action::SetVariable { + variable: count.clone(), + value: json!(null), + expr: Some(Expr::op(ExprOp::Add, vec![Expr::var(&count), Expr::Value(VariableValue::Number(1.0))])), + }, + // Evaluated after the increment. + Action::Conditional { + blocks: vec![ConditionalBlock { + condition: Some(Expr::op(ExprOp::GreaterOrEqual, vec![Expr::var(&count), Expr::Value(VariableValue::Number(2.0))])), + actions: vec![Action::Navigate { dest: b, preserve_scroll: false }], + }], + }, + ], + ); + let mut p = Presenter::new(a); + p.sync(&Arc::new(doc), 0.0); + assert!(p.fire(button, 0, None, 1.0)); + assert_eq!(p.current, a, "count is 1: no navigation yet"); + assert_eq!(count_of(&p), Some(VariableValue::Number(1.0))); + assert!(p.fire(button, 0, None, 2.0)); + assert_eq!(p.current, b, "count is 2: the branch navigates"); + assert_eq!(count_of(&p), Some(VariableValue::Number(2.0))); + // Restarting starts the variables over. + p.restart(a, 3.0); + assert_eq!(count_of(&p), Some(VariableValue::Number(0.0))); +} + +#[test] +fn set_variable_mode_recolours_bound_layers() { + let (mut doc, a, _, button) = setup(); + on_click(&mut doc, button, vec![Action::SetVariableMode { collection: "Theme".into(), mode: "Dark".into() }]); + let hex = |p: &Presenter| p.doc.as_ref().and_then(|d| d.node(a)?.fills.first()?.solid_color()).map(|c| c.to_hex()); + let mut p = Presenter::new(a); + p.sync(&Arc::new(doc), 0.0); + assert_eq!(hex(&p).as_deref(), Some("FFFFFF")); + assert!(p.fire(button, 0, None, 1.0)); + assert_eq!(hex(&p).as_deref(), Some("000000")); + // An unknown mode does nothing. + let mut d2 = p.doc.clone().unwrap(); + on_click(&mut d2, button, vec![Action::SetVariableMode { collection: "Theme".into(), mode: "Sepia".into() }]); + let mut p2 = Presenter::new(a); + p2.sync(&Arc::new(d2), 0.0); + assert!(!p2.fire(button, 1, None, 1.0)); +} + +#[test] +fn deeply_nested_conditionals_stop() { + let (mut doc, a, b, button) = setup(); + let mut act = Action::Navigate { dest: b, preserve_scroll: false }; + for _ in 0..(figcraft_doc::proto::MAX_ACTION_DEPTH * 3) { + act = Action::Conditional { blocks: vec![ConditionalBlock { condition: None, actions: vec![act] }] }; + } + on_click(&mut doc, button, vec![act]); + let mut p = Presenter::new(a); + p.sync(&Arc::new(doc), 0.0); + assert!(!p.fire(button, 0, None, 1.0)); + assert_eq!(p.current, a); +} diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index a183a6a..79ebb0b 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -77,7 +77,7 @@ fn noodles(app: &FigApp, ctx: &egui::Context, doc: &Arc, page: NodeId) let Some(n) = doc.node(id) else { continue }; let Some(from) = screen_rect(app, doc, id) else { continue }; for (k, it) in n.interactions.iter().enumerate() { - let Some(dest) = it.actions.iter().find_map(|a| a.dest()) else { continue }; + let Some(dest) = figcraft_doc::proto::walk(&it.actions).find_map(|a| a.dest()) else { continue }; // Change-to targets are variants; draw to them as they are. let Some(to) = screen_rect(app, doc, dest) else { continue }; if doc.page_of(dest) != Some(page) { @@ -169,7 +169,10 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { }; // Destination outline for the selected layer's connections. if (selected || is_open) - && let Some(dest) = doc.node(nd.source).and_then(|n| n.interactions.get(nd.index)).and_then(|it| it.actions.iter().find_map(|a| a.dest())) + && let Some(dest) = doc + .node(nd.source) + .and_then(|n| n.interactions.get(nd.index)) + .and_then(|it| figcraft_doc::proto::walk(&it.actions).find_map(|a| a.dest())) && let Some(r) = screen_rect(app, doc, dest) { painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, ACCENT.gamma_multiply(0.7)), StrokeKind::Outside); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index a0be137..39ae314 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -81,8 +81,18 @@ fn trigger_summary(t: &Trigger) -> String { } } -const ACTIONS: [&str; 9] = - ["Navigate to", "Change to", "Back", "Scroll to", "Open link", "Open overlay", "Swap overlay", "Close overlay", "Set variable"]; +const ACTIONS: [&str; 10] = [ + "Navigate to", + "Change to", + "Back", + "Scroll to", + "Open link", + "Open overlay", + "Swap overlay", + "Close overlay", + "Set variable", + "Set variable mode", +]; const TRANSITIONS: [&str; 8] = ["Instant", "Dissolve", "Smart animate", "Move in", "Move out", "Push", "Slide in", "Slide out"]; @@ -127,12 +137,25 @@ fn name_of(doc: &Document, id: NodeId) -> String { /// One-line action summary ("Navigate to Frame 2"). fn action_summary(doc: &Document, a: &Action) -> String { + let vars = &doc.variables; match a { Action::OpenUrl { url } if !url.is_empty() => format!("Open link {url}"), - Action::SetVariable { variable, .. } => { - let name = doc.variables.get(variable).map(|v| v.name.clone()).unwrap_or_else(|| variable.clone()); - format!("Set variable {name}") + Action::SetVariable { variable, expr, .. } => { + let name = vars.get(variable).map_or(variable.as_str(), |v| v.name.as_str()); + match expr { + Some(e) => format!("Set {name} to {}", e.display(vars)), + None => format!("Set variable {name}"), + } } + Action::SetVariableMode { collection, mode } => { + let c = vars.collection(collection); + let mode = c.and_then(|c| c.modes.iter().find(|m| &m.id == mode)).map_or(mode.as_str(), |m| m.name.as_str()); + format!("Set {} to {mode}", c.map_or(collection.as_str(), |c| c.name.as_str())) + } + Action::Conditional { blocks } => match blocks.first().and_then(|b| b.condition.as_ref()) { + Some(c) => format!("If {}", c.display(vars)), + None => a.label().to_string(), + }, other => match other.dest() { Some(d) => format!("{} {}", other.label(), name_of(doc, d)), None => other.label().to_string(), @@ -173,7 +196,13 @@ fn action_of(label: &str, doc: &Document, page: NodeId, node: NodeId, cur: Optio Some(VariableValue::String(s)) => json!(s), _ => Value::Null, }; - Action::SetVariable { variable: v.id.clone(), value } + Action::SetVariable { variable: v.id.clone(), value, expr: None } + } + "Set variable mode" => { + let c = doc.variables.collections.iter().find(|c| !c.modes.is_empty())?; + // The mode after the default: switching to the default changes nothing. + let m = c.modes.get(1).or(c.modes.first())?; + Action::SetVariableMode { collection: c.id.clone(), mode: m.id.clone() } } _ => return None, }) @@ -470,7 +499,10 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N trigger_rows(app, ui, node, k, it); widgets::separator(ui); action_rows(app, ui, doc, page, node, k, it); - if !matches!(it.actions.first(), Some(Action::OpenUrl { .. } | Action::SetVariable { .. } | Action::CloseOverlay)) { + if !matches!( + it.actions.first(), + Some(Action::OpenUrl { .. } | Action::SetVariable { .. } | Action::SetVariableMode { .. } | Action::CloseOverlay) + ) { widgets::separator(ui); animation_rows(app, ui, node, k, it); } @@ -614,7 +646,7 @@ fn action_rows(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node let resp = widgets::dropdown(ui, action.label(), ui.available_width(), None); egui::Popup::menu(&resp).show(|ui| { for (i, l) in ACTIONS.iter().enumerate() { - if i == 2 || i == 4 || i == 5 || i == 8 { + if matches!(i, 2 | 4 | 5 | 8) { ui.separator(); } if menu_item(ui, l, "") { @@ -623,6 +655,7 @@ fn action_rows(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node None => app.toast(match *l { "Change to" => "Change to needs a layer inside a variant", "Set variable" => "Create a variable first", + "Set variable mode" => "Create a variable collection first", _ => "There's no destination for that action", }), } @@ -661,7 +694,7 @@ fn action_rows(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node } }); } - Action::SetVariable { variable, value } => { + Action::SetVariable { variable, value, expr } => { let vars = doc.variables.variables.clone(); widgets::sublabels(ui, "Variable", None); row(ui, 32.0, |ui| { @@ -670,7 +703,7 @@ fn action_rows(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node egui::Popup::menu(&resp).show(|ui| { for v in &vars { if menu_item(ui, &v.name, "") { - patch(app, node, k, json!({"action": {"type": "setVariable", "variable": v.id, "value": value}})); + patch(app, node, k, json!({"action": {"type": "setVariable", "variable": v.id, "value": value, "expr": expr}})); ui.close(); } } @@ -678,10 +711,12 @@ fn action_rows(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node }); row(ui, 32.0, |ui| { widgets::label(ui, "Value", t.text_secondary); - let shown = match value { - Value::String(s) => s.clone(), - Value::Null => String::new(), - other => other.to_string(), + // An expression shows as text; typing a value replaces it. + let shown = match (expr, value) { + (Some(e), _) => e.display(&doc.variables), + (None, Value::String(s)) => s.clone(), + (None, Value::Null) => String::new(), + (None, other) => other.to_string(), }; if let Some(s) = widgets::text_field(ui, Id::new(("proto_varval", node, k)), &shown, ui.available_width(), "Value") { let v = serde_json::from_str::(s.trim()).ok().filter(|v| !v.is_string()).unwrap_or(Value::String(s)); @@ -743,6 +778,54 @@ fn action_rows(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node }); } } + Action::SetVariableMode { collection, mode } => { + let colls = &doc.variables.collections; + let current = doc.variables.collection(collection); + widgets::sublabels(ui, "Collection", None); + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, current.map_or("Choose collection", |c| c.name.as_str()), ui.available_width(), None); + egui::Popup::menu(&resp).show(|ui| { + for c in colls { + if menu_item(ui, &c.name, "") + && let Some(m) = c.modes.first() + { + patch(app, node, k, json!({"action": Action::SetVariableMode { collection: c.id.clone(), mode: m.id.clone() }})); + ui.close(); + } + } + }); + }); + widgets::sublabels(ui, "Mode", None); + row(ui, 32.0, |ui| { + let modes = current.map(|c| c.modes.as_slice()).unwrap_or_default(); + let name = modes.iter().find(|m| &m.id == mode).map_or("Choose mode", |m| m.name.as_str()); + let resp = widgets::dropdown(ui, name, ui.available_width(), None); + egui::Popup::menu(&resp).show(|ui| { + for m in modes { + if menu_item(ui, &m.name, "") { + patch(app, node, k, json!({"action": Action::SetVariableMode { collection: collection.clone(), mode: m.id.clone() }})); + ui.close(); + } + } + }); + }); + } + Action::Conditional { blocks } => { + // Read-only: the branches and what each runs. + for (i, b) in blocks.iter().enumerate() { + let head = match (&b.condition, i) { + (Some(c), 0) => format!("If {}", c.display(&doc.variables)), + (Some(c), _) => format!("Else if {}", c.display(&doc.variables)), + (None, _) => "Else".to_string(), + }; + widgets::sublabels(ui, &head, None); + let lines: Vec = + if b.actions.is_empty() { vec!["Nothing".to_string()] } else { b.actions.iter().map(|a| action_summary(doc, a)).collect() }; + for l in lines { + row(ui, 24.0, |ui| widgets::label(ui, &l, t.text_secondary)); + } + } + } Action::Back | Action::CloseOverlay => {} } } diff --git a/docs/cli.md b/docs/cli.md index f4a2b47..12c8e1f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -34,18 +34,23 @@ page 1 "Screens": 380 nodes, 4 top-level - `--node` takes a layer id (`12` or `0:12`) or a layer name. - The format comes from `--format`, else ``'s extension, else png. `--scale` (default 1, max 64) applies to raster formats. +- A `.pdf` `` without `--node` holds the page's top-level frames, one PDF page each in reading + order, each the frame's size in points (a page without frames goes on one page). Shapes, text + and gradients stay vector, text is real (selectable, with subset fonts) and its links clickable; + shadows and blurs are images. ```sh figcraft-cli export app.figcraft -o exports/ --scale 2 figcraft-cli export app.figcraft -o hero.svg --node "Hero" +figcraft-cli export app.figcraft -o screens.pdf --page "Screens" ``` ### `convert ` By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.svg` (becomes vectors), `.png/.jpg/.webp/.gif/.bmp` (an image layer). Outputs: `.figcraft`, `.json` (FigCraft JSON, pretty-printed), `.svg`, -`.png/.jpg/.webp` (the current page rendered at 1x with its background), `.pdf` (when PDF export is -available). +`.png/.jpg/.webp` (the current page rendered at 1x with its background), `.pdf` (the current page's +frames, one page each). ### `render -o [--scale N] [--page NAME]` diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e35d231..d6c6fd3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -97,7 +97,7 @@ text tool). Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`. -### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?}` +### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?}` Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`, `home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `iconGallery` (bool: @@ -112,8 +112,9 @@ frame id: open the prototype preview pane like `proto.preview`; false or `null` to what it can scroll), `colorProfileDialog` (`srgb|displayP3`: open File ▸ Color profile with that profile chosen; `null` closes it), `stylePicker` (`fill|stroke|effect|grid`: open the style picker for the selection; `null` closes it) with `stylePickerEdit` (a style id or name: open its editor), -`stylePickerView` (`list|grid`). Returns `ui.inspect` (which reports `stylePicker` and -`stylePickerView`). +`stylePickerView` (`list|grid`), `protoEditor` (`[layer id, interaction index]`: open that +interaction's editor in the Prototype panel; `null` closes it). Returns `ui.inspect` (which reports +`stylePicker` and `stylePickerView`). ### `ui.screenshot {path}` diff --git a/docs/mcp.md b/docs/mcp.md index 1d1dc94..7911215 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -120,7 +120,7 @@ Or in a project's `.mcp.json`: | `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?}` | png, jpg, svg, webp, pdf; `size` is `2x`, `512w` or `512h`. 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 `.`). | +| `export {ids, format, scale \| size, path?}` | 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; `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 `.`). | | `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 when attached. | | `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.svg` or an image; save as `.figcraft`. | | `undo` / `redo` | Step through history. | @@ -167,7 +167,15 @@ moves or resizes it, `{action: "remove", index}` deletes it. Rendering, PNG/SVG/ ### Prototypes Interactions are `proto.interaction.add` / `set` / `remove` / `clear` (a navigate action can keep -the scroll position with `preserveScroll: true`), flows `proto.flow.add` / `rename` / `remove`, +the scroll position with `preserveScroll: true`). Variable actions: `{type: "setVariable", +variable, value}` sets a value, or with `expr` an expression computed when it runs; +`{type: "setVariableMode", collection, mode}` switches a collection's mode for the prototype (ids or +names; modes chosen on layers inside the screen still win); `{type: "conditional", blocks: +[{condition?, actions}]}` runs the first block whose condition holds (a block without one is the +"else"). Expressions are `{value: {type: number|string|boolean|color|alias, value}}` (`alias` reads +a variable) or `{op: {op, args}}` with `add` (text joins when a string is involved), `subtract`, +`multiply`, `divide`, `equals`, `notEqual`, `less`, `lessOrEqual`, `greater`, `greaterOrEqual`, +`and`, `or`, `not` and `negate`. Flows `proto.flow.add` / `rename` / `remove`, and the device, its rotation and the background `proto.settings {device?, background?, rotation?}` (`None`, `iPhone 16`, `iPhone 16 Pro Max`, `Android Compact`, `iPad mini`, `MacBook Air`, `Desktop`; `rotation` `portrait` or `landscape`, which turns phones and tablets). On a device the @@ -270,13 +278,14 @@ The JSON returned by the public REST API's file endpoint (`GET /v1/files/:key`, `run_command {command: "file.import", params: {path}}` (pages appended to the current document, with their flows, prototype settings and variables). The result's `warnings` list what was skipped or approximated (unsupported layer, paint and effect -types, video and gamepad triggers, conditional and variable-mode actions, bindings to library +types, video and gamepad triggers, variable mode lookups in expressions, bindings to library variables, instances of components outside the file, which come in as frames). Pages, the layer tree, geometry, paints, strokes, effects, corners, constraints, auto layout, layout grids, exports, text with styled runs, vectors, components, component sets, instances and component properties are mapped, and so is prototyping: `interactions` (on click, hover, press, drag, after delay, mouse -enter/leave/down/up and key triggers; navigate, overlays, back, scroll to, change to, open link and -set variable actions; dissolve, smart animate, move, push and slide transitions with their easing, +enter/leave/down/up and key triggers; navigate, overlays, back, scroll to, change to, open link, +set variable (values, variable references and expressions), set variable mode and conditional +(if / else if / else over expressions) actions; dissolve, smart animate, move, push and slide transitions with their easing, custom bezier and spring curves included), flow starting points, scroll overflow and fixed/sticky layers, and the prototype device. Image fills keep their `imageRef` and show a grey placeholder unless the image files sit in an `images` folder next to the JSON, named by `imageRef`