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 <noreply@anthropic.com>
This commit is contained in:
parent
8dff0089e5
commit
044cfef834
17
ROADMAP.md
17
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
|
||||
|
|
|
|||
|
|
@ -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<Vec<u8>> {
|
||||
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<ExitCode> {
|
||||
let (doc, notes) = load_noted(path)?;
|
||||
let pages: Vec<Value> = 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Expr>,
|
||||
},
|
||||
/// 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<ConditionalBlock>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<Expr>,
|
||||
#[serde(default)]
|
||||
pub actions: Vec<Action>,
|
||||
}
|
||||
|
||||
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<CollectionId, ModeId>) -> 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<Action>, keep: &mut dyn FnMut(&Action) -> bool) {
|
||||
retain_at(actions, keep, 0);
|
||||
}
|
||||
|
||||
fn retain_at(actions: &mut Vec<Action>, 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<NodeId> {
|
||||
match self {
|
||||
Action::Navigate { dest, .. }
|
||||
|
|
|
|||
|
|
@ -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<Expr> },
|
||||
}
|
||||
|
||||
/// 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<String>) -> Expr {
|
||||
Expr::Value(VariableValue::Alias(id.into()))
|
||||
}
|
||||
|
||||
pub fn op(op: ExprOp, args: Vec<Expr>) -> 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<CollectionId, ModeId>) -> Option<VariableValue> {
|
||||
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<CollectionId, ModeId>) -> bool {
|
||||
matches!(self.eval(vars, modes), Some(VariableValue::Boolean(true)))
|
||||
}
|
||||
|
||||
fn eval_at(&self, vars: &Variables, modes: &BTreeMap<CollectionId, ModeId>, depth: usize) -> Option<VariableValue> {
|
||||
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<VariableValue> = args.iter().map(|a| a.eval_at(vars, modes, depth + 1)).collect::<Option<_>>()?;
|
||||
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<String> = 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<f64> {
|
||||
match v {
|
||||
VariableValue::Number(n) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn boolean(v: &VariableValue) -> Option<bool> {
|
||||
match v {
|
||||
VariableValue::Boolean(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn finite(n: f64) -> Option<VariableValue> {
|
||||
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<String> {
|
||||
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<VariableValue> {
|
||||
use ExprOp::*;
|
||||
let nums = || vals.iter().map(number).collect::<Option<Vec<f64>>>();
|
||||
let bools = || vals.iter().map(boolean).collect::<Option<Vec<bool>>>();
|
||||
let fold = |f: fn(f64, f64) -> Option<f64>| {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VariableValue> {
|
||||
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<Expr>, Vec<Action>)>) -> 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<NodeId> = 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::<Vec<_>>(), [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<Action> = 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 });
|
||||
}
|
||||
|
|
@ -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<Vec<NodeId>> = 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<Value> {
|
|||
Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}))
|
||||
}
|
||||
|
||||
/// A PDF with one page per group of layers (an sRGB document), titled after the file.
|
||||
fn pdf_bytes(doc: &Document, groups: &[Vec<NodeId>]) -> Result<Vec<u8>> {
|
||||
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<u8>, 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<Vec<NodeId>> = 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<Value> {
|
||||
let doc = s.doc()?;
|
||||
|
|
@ -787,19 +800,13 @@ fn export_frames_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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)})),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ fn open_player(s: &mut Session, p: &Value, cmd: &str, ui: &str) -> Result<Value>
|
|||
/// 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<u32> {
|
||||
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<Value> {
|
|||
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<Value> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -219,12 +219,9 @@ fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>)
|
|||
out
|
||||
}
|
||||
|
||||
/// The variables a layer's prototype actions set.
|
||||
fn set_variables(n: &Node) -> impl Iterator<Item = &VariableId> {
|
||||
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<Item = &str> {
|
||||
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<NodeId, NodeId>, 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<String> =
|
||||
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<String> = 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<String> =
|
||||
doc.libraries.collections.iter().filter(|r| r.library == library && !used.contains(&r.local)).map(|r| r.local.clone()).collect();
|
||||
if !unused.is_empty() {
|
||||
|
|
|
|||
|
|
@ -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<VariableValue> {
|
||||
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<VariableValue> {
|
||||
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<NodeId> = 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<Action> {
|
||||
ConditionalBlock::choose(blocks, &doc.variables, &doc.modes_for(node)).map(<[Action]>::to_vec).unwrap_or_default()
|
||||
}
|
||||
|
|
@ -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<String> {
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>) -> 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>) -> Value {
|
||||
json!({"trigger": {"type": trigger}, "actions": actions})
|
||||
}
|
||||
|
||||
fn file(interactions: Vec<Value>) -> 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<Value>) -> (Document, Vec<String>) {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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<Action>, 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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Expr> {
|
||||
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::<Option<Vec<Expr>>>()?;
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<VariableType> {
|
||||
Some(match t? {
|
||||
pub(super) fn kind_of(t: &str) -> Option<VariableType> {
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<NodeId>,
|
||||
/// 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<u32, Option<krilla::text::Font>>,
|
||||
images: HashMap<String, Option<(Image, u32, u32)>>,
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<f64> = 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::<f64>().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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<NodeId>], 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<krilla::annotation::Annotation> {
|
||||
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<NodeId>, 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;
|
||||
|
|
|
|||
|
|
@ -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<Raster> {
|
||||
let doc = self.doc;
|
||||
let n = doc.node(id)?;
|
||||
let radii: Vec<f64> = 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<Shadows> {
|
||||
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<NodeId> = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<usize> {
|
|||
h.windows(n.len()).position(|w| w == n)
|
||||
}
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
pub(crate) fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
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<u8> {
|
|||
figcraft_render::encode_png(&p).unwrap()
|
||||
}
|
||||
|
||||
fn has_inter() -> bool {
|
||||
pub(crate) fn has_inter() -> bool {
|
||||
figcraft_text::find_face("Inter", "Regular").is_some()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<char> = t.characters.chars().collect();
|
||||
let styles: BTreeSet<usize> = 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<Face>) -> Option<Font> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Rect>, 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<Rect>, 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<String, VariableValue>, v: &serde_json::Value) -> Option<VariableValue> {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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<Action>) {
|
||||
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<VariableValue> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ fn noodles(app: &FigApp, ctx: &egui::Context, doc: &Arc<Document>, 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);
|
||||
|
|
|
|||
|
|
@ -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::<Value>(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<String> =
|
||||
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 => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `<out>`'s extension, else png. `--scale` (default 1, max 64)
|
||||
applies to raster formats.
|
||||
- A `.pdf` `<out>` 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 <in> <out>`
|
||||
|
||||
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 <file> -o <out.png> [--scale N] [--page NAME]`
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
||||
|
|
|
|||
19
docs/mcp.md
19
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 `<layer><suffix>.<format>`). |
|
||||
| `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 `<layer><suffix>.<format>`). |
|
||||
| `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`
|
||||
|
|
|
|||
Loading…
Reference in New Issue