Merge main into about-credits-columns

This commit is contained in:
FredApps
2026-11-05 15:11:35 +01:00
69 changed files with 1225 additions and 98 deletions
+1
View File
@@ -48,3 +48,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
Generated
+10
View File
@@ -1157,6 +1157,15 @@ dependencies = [
"zip 9.1.1",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -2121,6 +2130,7 @@ name = "figcraft-web"
version = "0.15.0"
dependencies = [
"base64 0.22.1",
"craft-web-host",
"eframe",
"egui",
"figcraft-collab-net",
+3 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -498,7 +498,8 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(failed)?;
// `create_dir`, not `create_dir_all`: a directory or link already at this predictable name is refused.
std::fs::create_dir(&dir).map_err(failed)?;
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
let mut lib_s = Session::with_document(lib_doc);
@@ -75,3 +75,11 @@ fn codes_are_found_through_the_index_as_before() {
assert_eq!(store.resolve_code(who(105), &codes[2]).unwrap_err(), CODE_GONE);
assert_eq!(store.resolve_code(who(106), &codes[3]).unwrap().0, files[3].0);
}
#[test]
fn a_name_taken_from_the_file_is_cleaned_like_a_given_one() {
let dir = TempDir::new("embedded-name");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("", &snapshot("Bad\u{1b}[2Jname\u{202E}")).unwrap();
assert_eq!(file.name, "Bad[2Jname");
}
+2 -1
View File
@@ -95,8 +95,9 @@ impl Store {
return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
}
let mut doc = replica.document().clone();
// The name the file keeps, whichever one it comes from, is cleaned.
let name = match clean_name(name) {
n if n.is_empty() => doc.name.clone(),
n if n.is_empty() => clean_name(&doc.name),
n => n,
};
doc.name = name.clone();
+3 -1
View File
@@ -19,6 +19,8 @@ figcraft-format = { workspace = true }
figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-text = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
base64 = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -30,4 +32,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage", "Event", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
+143
View File
@@ -0,0 +1,143 @@
//! Host modes: a page that runs FigCraft (as an `<iframe>`, or in its own document) opens
//! documents in it and stores what it saves instead of the browser downloading. The messages
//! and the two transports are `craft-web-host`'s (storytold/craft-libs): the same contract as
//! the other Craft apps' bridges. This module is FigCraft's part of it.
//!
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document) uses DOM
//! `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present.
//! - `figcraft:ready` (to the host) once the app is running and listening; send a file now.
//! - `figcraft:open` (from the host): `{ name, bytes }`. It opens exactly as a file picked with
//! File › Open or dropped on the window would (the app's own open queue, with its "Opening as
//! a new file" card). A payload over the browser's own open limit
//! ([`figcraft_ui_egui::browser_can_read`]'s 512 MB) is refused before its bytes are copied,
//! and anything malformed is ignored. FigCraft reads whole files, so it says `file: false` in
//! `ready` and a host sends `bytes`.
//! - `figcraft:save` (to the host): `{ name, saveAs, bytes }` for every file the app hands its
//! download service; in host mode nothing reaches the browser's downloads. `saveAs` says whether
//! the file becomes the document's file ([`crate::hostmode::save_as`]):
//! - A Save of the file the host opened, under its own name: `false`.
//! - A document file (`.figcraft` or `.json`, the formats `file.save` and `file.saveAs` write)
//! under a name the host didn't open — Save As, or a Save of a document the host never sent
//! (an untitled one): `true`. It becomes the document's file; the next Save sends its name
//! with `false`. Names compare without case.
//! - Everything else is a copy or export that never becomes the document: `false` — rendered
//! images, SVG and PDF, `.fig` copies, and the variables exports (a `.variables.json`
//! sidecar's `.json` extension doesn't make it a document).
//! - `figcraft:dirty` whenever unsaved changes appear or go away, `figcraft:failed` when the app
//! couldn't start.
//!
//! In host mode the app starts with no document (the home screen) and the host sends the file to
//! edit; `?author=` names the user for comments and board notes.
use std::cell::RefCell;
use std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
use figcraft_ui_egui::FigApp;
/// The app id in the host messages (`figcraft:ready`).
const APP: &str = "figcraft";
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
/// Files the host sent, on their way to the app's own open queue ([`figcraft_ui_egui::pending_open`]):
/// `(name, bytes)`.
pub type OpenQueue = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
#[derive(Clone)]
pub struct Bridge {
host: Rc<Host>,
/// The file the host opened, and after a `saveAs` hand-over the document's file: the name a
/// Save under (`saveAs: false`) is decided against.
host_file: Rc<RefCell<Option<String>>>,
}
impl Bridge {
/// The bridge for this page, or `None` when FigCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?), host_file: Rc::default() })
}
/// Hear the files the host sends, and queue each one on `opens` for the app's own open path
/// (the shell drains it into [`figcraft_ui_egui::pending_open`] every frame, so a host file
/// opens like a dropped one).
pub fn listen(&self, opens: OpenQueue, ctx: egui::Context) {
let host_file = self.host_file.clone();
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
deliver_open(&opens, &ctx, &host_file, &name, &data);
}
});
}
/// `?author=`: the host's name for the user signs comments and board notes.
pub fn set_author(app: &mut FigApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.prefs.user_name = author;
}
}
pub fn post_ready(&self) {
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
let _ = self.host.post_failed(error, None);
}
/// The document file the host opened or the last Save As made, if any.
pub fn host_file(&self) -> Option<String> {
self.host_file.borrow().clone()
}
/// Hand a file the app would otherwise download to the host; `false` when nothing was
/// delivered. The `saveAs` rule is this module's documentation's.
pub fn post_save(&self, path: &str, bytes: &[u8]) -> bool {
let name = file_name(path);
let host_file = self.host_file.borrow().clone();
let save_as = crate::hostmode::save_as(&name, host_file.as_deref());
let sent = self.host.post_save(&name, bytes, None, save_as).is_ok();
// After a Save As hand-over the host has the new file: the next Save of it sends its name
// with `saveAs: false`. A failed send changes nothing, so a retry is still a Save As.
if sent && save_as {
*self.host_file.borrow_mut() = Some(name);
}
sent
}
/// Report unsaved changes when they appear or go away, not every frame (the crate does not
/// repeat a state it already reported).
pub fn set_dirty(&self, dirty: bool) {
self.host.post_dirty(dirty);
}
}
/// The file an open message carries, queued for the app's own open path, or nothing when the
/// payload isn't one (a `File` the app doesn't read, or over the open limit).
fn deliver_open(opens: &OpenQueue, ctx: &egui::Context, host_file: &RefCell<Option<String>>, name: &str, data: &FileData) {
let name = crate::hostmode::open_name(name);
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; FigCraft reads bytes only; not opened");
return;
};
if figcraft_ui_egui::browser_can_read(&name, data.size()).is_err() {
log::warn!("host file {name} is too large to open in the browser; not opened");
return;
}
// The document's file is now the one the host sent: a Save of it under this name is
// `saveAs: false`.
*host_file.borrow_mut() = Some(name.clone());
opens.borrow_mut().push((name, array.to_vec()));
ctx.request_repaint();
}
/// The last component of `path`, or the whole of it when there is none.
fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
+92
View File
@@ -0,0 +1,92 @@
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
//! (like [`crate::query`]): the name the bridge fills in when an open request has none, and whether
//! a handed-over file becomes the document's file. Which pages host the app is `craft-web-host`'s.
/// The file name used when an open request carries no usable one.
pub const DEFAULT_OPEN_NAME: &str = "Untitled.figcraft";
/// The extensions of files that can be a document's own file: the formats `file.save` and
/// `file.saveAs` write (`file.open` opens both as native documents). A `.fig` file is an export
/// copy instead, and so is everything else the app hands over (rendered images, SVG, PDF,
/// variables).
const DOC_EXTS: &[&str] = &["figcraft", "json"];
/// The suffix `variable.export`'s downloads carry: a `.json` that is a variables sidecar, not a
/// document, so its extension alone can't tell the `saveAs` decision.
const VARIABLES_SIDECAR: &str = ".variables.json";
/// The name `craft-web-host` gives an open request that carries no usable one (it cleans a host's
/// name and falls back to this).
const NAMELESS: &str = "document";
/// A usable file name for an open request: the cleaned name the crate hands over, or
/// [`DEFAULT_OPEN_NAME`] when the host sent none.
pub fn open_name(name: &str) -> String {
if name == NAMELESS { DEFAULT_OPEN_NAME.to_string() } else { name.to_string() }
}
/// Whether the file `name` becomes the document's own file when handed over (`saveAs: true`),
/// given the file the host opened (`None`: the host never sent one). The rule:
///
/// - A document file (`.figcraft` or `.json`, [`DOC_EXTS`]'s formats) under a name the host didn't
/// open — Save As, or a Save of a document the host never sent — is the document's new file:
/// `true`. Names compare without case.
/// - A Save of the host's own file under its name is not: `false`.
/// - Everything else is a copy or export that never becomes the document — rendered images, SVG,
/// PDF, `.fig` copies, variables — and the variables sidecar counts as one whatever its `.json`
/// extension says: `false`.
pub fn save_as(name: &str, host_file: Option<&str>) -> bool {
if name.to_ascii_lowercase().ends_with(VARIABLES_SIDECAR) {
return false;
}
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str);
if !ext.is_some_and(|e| DOC_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e))) {
return false;
}
match host_file {
Some(host) => !host.eq_ignore_ascii_case(name),
None => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_names_fall_back_to_the_default_when_the_host_sent_none() {
assert_eq!(open_name("Poster.figcraft"), "Poster.figcraft");
assert_eq!(open_name("Poster 2.json"), "Poster 2.json");
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank name");
}
#[test]
fn a_save_of_the_host_s_file_is_not_save_as() {
assert!(!save_as("Poster.figcraft", Some("Poster.figcraft")));
assert!(!save_as("poster.FIGCRAFT", Some("Poster.figcraft")), "names compare without case");
assert!(!save_as("design.json", Some("design.json")));
}
#[test]
fn a_new_document_file_is_save_as() {
assert!(save_as("Poster 2.figcraft", Some("Poster.figcraft")));
assert!(save_as("Poster.json", Some("Poster.figcraft")), "the other format a document's file takes");
assert!(save_as("Untitled.figcraft", None), "the host never sent a file");
assert!(save_as("Poster.figcraft", Some("design.json")), "the opened .json saved as .figcraft is a new file");
}
#[test]
fn copies_and_exports_never_become_the_document_s_file() {
for name in ["Poster.png", "Poster@2x.jpg", "Poster.svg", "Poster.pdf", "Poster.fig", "Poster.csv", "Poster.variables.css"] {
assert!(!save_as(name, Some("Poster.figcraft")), "{name} is a copy");
assert!(!save_as(name, None), "{name} is a copy even without a host file");
}
}
#[test]
fn the_variables_sidecar_is_a_copy_whatever_its_extension() {
assert!(!save_as("Poster.variables.json", Some("Poster.figcraft")));
assert!(!save_as("Poster.variables.json", None));
assert!(!save_as("design.variables.json", Some("design.variables.json")));
}
}
+13 -1
View File
@@ -19,9 +19,17 @@
//! server…), but can't host a session. A page served over `https://` reaches only `wss://`
//! servers and the same computer (browsers block `ws://` from secure pages); serve it over
//! `http://` on a trusted network to reach plain `ws://` hosts on other machines.
//! - host mode: `?host=parent` (a same-origin page embedding FigCraft in an `<iframe>`) or
//! `data-host="page"` on the canvas (the page's own document) hands the page the app's files
//! instead of downloading them and reports unsaved changes (`host`);
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't
//! host one. A page served over `https://` can only reach sessions on the same computer
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it; `?host=parent`
//! (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the document to open
//! and stores what FigCraft saves. `&author=` names the user for comments and board notes in either
//! host mode.
//!
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
@@ -30,6 +38,10 @@
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
mod hostmode;
#[cfg(any(target_arch = "wasm32", test))]
mod kept;
#[cfg(target_arch = "wasm32")]
+74 -9
View File
@@ -32,6 +32,15 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing FigCraft, opens
// documents in it and stores what it saves (`host`); unframed there is no parent, so that URL
// on its own stays the normal app with downloads. `data-host="page"` on the canvas opts a
// page in instead: it runs FigCraft in its own document and talks over DOM `CustomEvent`s on
// the canvas.
let bridge = crate::host::Bridge::connect(&canvas);
let app_bridge = bridge.clone();
// Files the host sends, on their way into the app's own open queue each frame.
let opens: crate::host::OpenQueue = std::rc::Rc::default();
let mut options = eframe::WebOptions::default();
// `?webgl`: force the WebGL2 backend (troubleshooting, and headless browsers whose WebGPU
// adapter request returns nothing).
@@ -44,12 +53,15 @@ pub fn start() {
.start(
canvas,
options,
Box::new(|cc| {
Box::new(move |cc| {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("figcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
let mut session = Session::new();
// Host mode: the host sends the file to edit, so start with no document (the
// home screen), as the desktop app does without a file; otherwise the session
// starts with one new document.
let mut session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
// The host names its own server: the default for sharing, codes and the team list.
if let Some(server) = page_meta("figcraft-server").as_deref().and_then(crate::query::page_server) {
session.prefs.publish_server = server;
@@ -60,11 +72,18 @@ pub fn start() {
// whatever arrives on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone(), host: app_bridge.clone() }));
fig.note_recovered(recovered);
if let Err(e) = crate::pasted::install(cc.egui_ctx.clone()) {
log::error!("couldn't listen for pasted images: {e:?}");
}
// `?author=`: the host's name for the user signs this visit's comments and board
// notes (nothing is stored in the browser, so nothing needs keeping).
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
crate::host::Bridge::set_author(&mut fig, &author);
}
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
if let Some(link) = crate::query::join_link(&hash) {
@@ -78,10 +97,21 @@ pub fn start() {
log::error!("couldn't install window.figcraft: {e:?}");
}
}
Ok(Box::new(WebShell { fig }))
// The host's files arrive on the app's own open path (a dropped file's).
if let Some(bridge) = &app_bridge {
bridge.listen(opens.clone(), cc.egui_ctx.clone());
}
Ok(Box::new(WebShell { fig, host: app_bridge, opens }))
}),
)
.await;
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -141,20 +171,45 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
struct WebShell {
fig: FigApp,
/// The host bridge, when the page hosts the app (`?host=parent` or `data-host="page"`).
host: Option<crate::host::Bridge>,
/// Files the host sent, waiting to join the app's own open queue.
opens: crate::host::OpenQueue,
}
impl eframe::App for WebShell {
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
/// Run the browser shell's eframe logic callback: hand the host's files to the app's open
/// queue, guard `background_tick` so hidden tabs still advance autosave, live and control
/// deadlines without painting, and report unsaved changes to the host. `FigApp::ui` does
/// not repeat it.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Every frame, hidden or not, starts here: what the last one left is what a panic keeps.
UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok();
// Files the host sent join the app's own open queue (a dropped file's): the same
// "Opening as a new file" card, and `file.open` with the contents once read.
for (name, bytes) in std::mem::take(&mut *self.opens.borrow_mut()) {
let read: figcraft_ui_egui::ReadFuture = Box::pin(std::future::ready(Ok(bytes)));
self.fig.ui.pending_opens.push(figcraft_ui_egui::pending_open::PendingOpen {
label: name.clone(),
params: serde_json::json!({ "name": name }),
command: "file.open",
target: None,
read: Some(read),
});
}
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
// The host hears when unsaved changes appear or go away (crate::host).
if let Some(bridge) = &self.host {
bridge.set_dirty(self.fig.session.docs.iter().any(|d| d.is_dirty()));
}
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
@@ -182,12 +237,15 @@ impl eframe::App for WebShell {
/// Browser services. Files are opened and imported through the browser's file picker, which hands
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
/// paste events with their text or image (a page can't read the clipboard when it likes).
/// saved as downloads, or handed to the host page in host mode. Copies go to the system clipboard
/// through egui's output; pastes arrive as paste events with their text or image (a page can't
/// read the clipboard when it likes).
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
ctx: egui::Context,
/// The host bridge in host mode: files are handed over instead of downloaded ([`crate::host`]).
host: Option<crate::host::Bridge>,
}
impl Services for WebServices {
@@ -204,11 +262,15 @@ impl Services for WebServices {
fn downloads(&self) -> bool {
true
}
/// A Blob behind a temporary `<a download>` link, clicked and removed.
/// A Blob behind a temporary `<a download>` link, clicked and removed; in host mode the file
/// goes to the host page instead ([`crate::host`]).
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
if crate::api::capture(name, bytes) {
return true;
}
if let Some(bridge) = &self.host {
return bridge.post_save(name, bytes);
}
match download(name, bytes) {
Ok(()) => true,
Err(e) => {
@@ -217,6 +279,9 @@ impl Services for WebServices {
}
}
}
fn host_file(&self) -> Option<String> {
self.host.as_ref()?.host_file()
}
fn clipboard_set_text(&mut self, s: &str) {
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
}
+3
View File
@@ -101,3 +101,6 @@ sync_github_name = true
[people.aleksei-suholita]
real_name = "Oleksii Sukholita"
[people.helenkwok]
real_name = "Helen Kwok"
+11
View File
@@ -333,6 +333,17 @@ impl Change {
}
impl Entry {
/// The synced list this entry belongs to (`None` for entries kept in maps).
pub(crate) fn family(&self) -> Option<Family> {
match self {
Entry::Style(_) => Some(Family::Styles),
Entry::Comment(_) => Some(Family::Comments),
Entry::Collection(_) => Some(Family::Collections),
Entry::Variable(_) => Some(Family::Variables),
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
}
}
/// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live
/// session carry their creator's client id, ids from before and ids of other forms are in
/// space 0. `None` for entries keyed by something else (dev snapshots by layer, images by
+38 -7
View File
@@ -95,7 +95,8 @@ pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
let mut changes = fields;
let new: BTreeSet<NodeId> = added.iter().copied().collect();
// Creations, parents before children.
added.sort_by_cached_key(|id| (depth(to, *id), *id));
let mut memo = BTreeMap::new();
added.sort_by_cached_key(|id| (depth(to, *id, &mut memo), *id));
for id in &added {
if let Some(n) = to.node(*id) {
changes.push(create_change(n, replica.key(*id))?);
@@ -121,18 +122,30 @@ pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
Ok(Diff { changes, replica })
}
/// Depth of `id` in `doc` (pages are 0).
fn depth(doc: &Document, id: NodeId) -> usize {
let mut d = 0;
/// Depth of `id` in `doc` (pages are 0). `memo` holds the depths found so far, so a deep chain of
/// new layers is walked once rather than once per layer.
fn depth(doc: &Document, id: NodeId, memo: &mut BTreeMap<NodeId, usize>) -> usize {
let mut chain = vec![id];
let mut base = 0;
let mut cur = doc.node(id).and_then(|n| n.parent);
while let Some(p) = cur {
d += 1;
if d > 100_000 {
if let Some(d) = memo.get(&p) {
base = *d + 1;
break;
}
// An acyclic chain is no longer than the document: this only stops a cycle.
if chain.len() > doc.node_count() {
break;
}
chain.push(p);
cur = doc.node(p).and_then(|n| n.parent);
}
d
// `chain` runs from `id` up; the last entry's depth is `base` when it ends at a root or a memo hit.
let top = chain.len();
for (i, c) in chain.iter().enumerate() {
memo.insert(*c, base + (top - 1 - i));
}
memo.get(&id).copied().unwrap_or(0)
}
/// New keys for the children `kids` (in their new order) whose `old` key no longer fits: the
@@ -196,3 +209,21 @@ fn increasing_run(keys: &[Option<&str>]) -> Vec<bool> {
}
kept
}
#[cfg(test)]
mod depth_tests {
use super::*;
use figcraft_doc::NodeKind;
#[test]
fn a_chain_longer_than_a_fixed_bound_is_walked_whole() {
let mut doc = Document::new("File");
let mut parent = doc.first_page().unwrap();
let mut last = parent;
for i in 0..100_050 {
last = doc.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 1.0, 1.0).unwrap();
parent = last;
}
assert_eq!(depth(&doc, last, &mut BTreeMap::new()), 100_050);
}
}
+11
View File
@@ -310,3 +310,14 @@ fn many_sibling_placements_keep_the_order_of_counting_them() {
assert_eq!(r.document().node(p).unwrap().children, kids[&p], "children of {p}");
}
}
#[test]
fn a_chain_of_new_nested_layers_is_created_parents_first() {
let (a, _, frame, _) = setup();
let mut b = a.document().clone();
let mut parent = frame;
for i in 0..400 {
parent = b.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 10.0, 10.0).unwrap();
}
assert_eq!(round_trip(&a, &b).iter().filter(|c| matches!(c, Change::Create { .. })).count(), 400);
}
+3 -1
View File
@@ -236,7 +236,9 @@ impl Replica {
self.doc.insert_page(node).map_err(doc_err)?;
}
(false, Some(p)) => {
let at = self.slot(&self.doc.get(*p).map_err(doc_err)?.children, *id, position);
// A new id sits in no sibling list yet: no scan for it, just the binary search.
let siblings = &self.doc.get(*p).map_err(doc_err)?.children;
let at = siblings.partition_point(|c| (self.key(*c), *c) < (position.as_str(), *id));
self.doc.insert(node, *p, Some(at)).map_err(doc_err)?;
}
_ => return Err(Error::Invalid("pages, and only pages, have no parent".into())),
+18 -6
View File
@@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque};
use figcraft_doc::{Document, NodeId};
use serde::{Deserialize, Serialize};
use crate::change::{Change, Entry, Prop, Target};
use crate::change::{Change, Entry, Family, Prop, Target};
use crate::message::{
Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_comments, reads_signals,
};
@@ -413,10 +413,14 @@ impl Server {
// client learned what happened to it from the catch-up.
ClientMsg::Changes { batch_id, .. } if self.issued.get(&from).is_some_and(|p| batch_id <= p.last_batch) => Vec::new(),
ClientMsg::Changes { base_seq, batch_id, changes } => {
let mut denied = true;
let result = match self.limited.get(&from) {
Some(Access::View) => Err(Error::Invalid(VIEW_ONLY.into())),
Some(Access::Comment) if !changes.iter().all(Change::is_comment) => Err(Error::Invalid(COMMENT_ONLY.into())),
_ => self.accept(from, base_seq, &changes),
_ => {
denied = false;
self.accept(from, base_seq, &changes)
}
};
if let Some(p) = self.issued.get_mut(&from) {
p.note(batch_id, result.is_err());
@@ -430,7 +434,9 @@ impl Server {
}
Err(e) => {
self.note(Journal::Refused { client: from, batch_id });
let fixes = self.fixes(&changes).unwrap_or_default();
// A refusal for lack of permission costs no repair: the client's confirmed copy
// already is the server's state, so the fixes would only repeat it.
let fixes = if denied { Vec::new() } else { self.fixes(&changes).unwrap_or_default() };
vec![Outgoing::To(from, ServerMsg::Reject { batch_id, reason: reason(&e), fixes })]
}
}
@@ -557,6 +563,7 @@ impl Server {
fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result<Vec<(Entry, bool)>, Error> {
let before = self.state.document();
let mut touched: BTreeSet<Entry> = BTreeSet::new();
let mut listed: BTreeMap<Family, (BTreeSet<Entry>, BTreeSet<Entry>)> = BTreeMap::new();
for c in changes {
match c {
Change::Put { entry, .. } | Change::Remove { entry } if entry.id_space().is_some() => {
@@ -565,8 +572,10 @@ impl Server {
// A whole list replaced (the fallback for lists whose ids aren't unique).
Change::SetDoc { prop, .. } => {
if let Some(Ok(family)) = prop.list() {
touched.extend(docprops::entries(before, family));
touched.extend(docprops::entries(after, family));
let (b, a) = (docprops::entries(before, family), docprops::entries(after, family));
touched.extend(b.iter().chain(&a).cloned());
// Looked up in sets below: a linear search per entry would be quadratic.
listed.entry(family).or_insert_with(|| (b.into_iter().collect(), a.into_iter().collect()));
}
}
_ => {}
@@ -574,7 +583,10 @@ impl Server {
}
let mut out = Vec::new();
for entry in touched {
let (was, now) = (docprops::has(before, &entry), docprops::has(after, &entry));
let (was, now) = match entry.family().and_then(|f| listed.get(&f)) {
Some((b, a)) => (b.contains(&entry), a.contains(&entry)),
None => (docprops::has(before, &entry), docprops::has(after, &entry)),
};
if was == now {
continue;
}
+20
View File
@@ -182,6 +182,26 @@ fn deleted_nodes_can_come_back_but_ids_are_never_handed_out_twice() {
assert_eq!(s.document(), gone.document());
}
#[test]
fn a_viewers_refused_deletion_gets_no_repair() {
let (d, _, frame) = doc();
let mut s = Server::new(d, "tok", Limits::default());
let (id, out) = s.join_as(hello("tok"), crate::message::Access::View).unwrap();
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!() };
let mut v = Client::join(welcome).unwrap();
let del = edit(&mut v, |d| d.remove(frame).unwrap());
let (reason, fixes) = rejected(
&mut s,
id,
match del {
ClientMsg::Changes { changes, .. } => changes,
_ => panic!(),
},
);
assert_eq!(reason, crate::VIEW_ONLY);
assert!(fixes.is_empty(), "{fixes:?}");
}
#[test]
fn fixes_roll_a_rejected_client_back_to_the_server_state() {
let (d, _, frame) = doc();
+19 -2
View File
@@ -574,6 +574,19 @@ impl Document {
Ok(())
}
/// Remove every child of `id` with its subtree, in one pass over `id`'s child list (removing
/// them one by one would search that list once per child).
pub fn remove_children(&mut self, id: NodeId) -> DocResult<()> {
let kids = std::mem::take(&mut self.get_mut(id)?.children);
for c in kids {
for d in self.subtree(c) {
self.nodes.remove(&d);
self.dirty.touch(d);
}
}
Ok(())
}
/// Whether sections may go in `parent`: a page or another section (never a frame or a group).
pub fn takes_sections(&self, parent: NodeId) -> bool {
self.node(parent).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
@@ -1085,6 +1098,9 @@ impl Document {
// lookups: a wide parent or a long page list doesn't make validation quadratic.
let edges: std::collections::BTreeSet<(NodeId, NodeId)> = self.nodes.iter().flat_map(|(p, n)| n.children.iter().map(|c| (*p, *c))).collect();
let page_ids: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
if page_ids.len() != self.pages.len() {
note(&mut issues, || "a page is listed twice".to_string());
}
for (id, n) in self.nodes.iter() {
if n.id != *id {
note(&mut issues, || format!("node key {id} has id {}", n.id));
@@ -1178,9 +1194,10 @@ impl Document {
if store != self.libraries.store {
Arc::make_mut(&mut self.libraries).store = store;
}
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store);
// A page listed twice would be imported, exported and walked once per listing.
let mut listed = std::collections::BTreeSet::new();
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store && listed.insert(*p));
// Unlisted pages join the list; a set keeps many pages from making this quadratic (#1078).
let mut listed: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
for (k, n) in self.nodes.iter() {
if n.is_page() && Some(*k) != store && listed.insert(*k) {
self.pages.push(*k);
+25 -3
View File
@@ -12,6 +12,8 @@ use crate::{CollectionId, ModeId, VariableValue, Variables};
pub const MAX_EXPR_DEPTH: usize = 32;
/// Longest string an expression builds (characters).
const MAX_STRING: usize = 10_000;
/// The most string bytes one operation's operands may hold together (generous: results cap at `MAX_STRING`).
const MAX_OPERAND_BYTES: usize = 1 << 20;
/// An expression over variables.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -115,7 +117,19 @@ impl Expr {
}
Expr::Op { op, args } => (*op, args),
};
let vals: Vec<VariableValue> = args.iter().map(|a| a.eval_at(vars, modes, depth + 1)).collect::<Option<_>>()?;
// Retained operands are bounded by bytes, so repeated references to one long string can't multiply it.
let mut text_bytes = 0usize;
let mut vals = Vec::with_capacity(args.len());
for a in args {
let v = a.eval_at(vars, modes, depth + 1)?;
if let VariableValue::String(s) = &v {
text_bytes = text_bytes.saturating_add(s.len());
if text_bytes > MAX_OPERAND_BYTES {
return None;
}
}
vals.push(v);
}
apply(op, &vals)
}
@@ -259,10 +273,18 @@ fn apply(op: ExprOp, vals: &[VariableValue]) -> Option<VariableValue> {
match op {
Add if vals.iter().any(|v| matches!(v, VariableValue::String(_))) => {
let mut s = String::new();
let mut chars = 0;
for v in vals {
s.push_str(&as_text(v)?);
// Every operand is checked (a colour makes the whole expression None); only the
// characters it adds are counted, and appending stops at the cap.
let t = as_text(v)?;
if chars < MAX_STRING {
let before = s.len();
s.extend(t.chars().take(MAX_STRING - chars));
chars += s[before..].chars().count();
}
}
Some(VariableValue::String(s.chars().take(MAX_STRING).collect()))
Some(VariableValue::String(s))
}
Add => fold(|a, b| Some(a + b)),
Subtract => fold(|a, b| Some(a - b)),
+16
View File
@@ -124,6 +124,13 @@ fn deep_and_hostile_expressions_are_bounded() {
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");
// Past the cap a colour operand still fails the expression, and empty operands add nothing.
let color = Expr::Value(VariableValue::Color(Color::BLACK));
let mut ops = vec![s(&"x".repeat(10_000))];
ops.extend((0..50).map(|_| s("")));
assert!(matches!(eval(&Expr::op(ExprOp::Add, ops.clone())), Some(VariableValue::String(t)) if t.chars().count() == 10_000));
ops.push(color);
assert_eq!(eval(&Expr::op(ExprOp::Add, ops)), None, "a colour past the cap is still a type error");
}
fn nav(id: u64) -> Action {
@@ -199,3 +206,12 @@ fn serde_round_trip_and_old_files() {
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 });
}
#[test]
fn operands_holding_too_many_string_bytes_are_refused() {
let long = "x".repeat(300_000);
let few = Expr::op(ExprOp::Add, vec![s(&long), s(&long)]);
assert!(eval(&few).is_some());
let many = Expr::op(ExprOp::Add, (0..8).map(|_| s(&long)).collect());
assert_eq!(eval(&many), None);
}
+28
View File
@@ -348,6 +348,20 @@ fn repair_drops_cycles_and_repeated_children() {
assert_eq!(bad.subtree(p), vec![p, r]);
}
#[test]
fn repair_lists_a_page_once_however_often_the_file_lists_it() {
let mut d = Document::new("x");
let p = page(&d);
let q = d.add_page("Two");
let mut v = serde_json::to_value(&d).unwrap();
v["pages"] = serde_json::json!([p, q, p, q, p]);
let mut bad: Document = serde_json::from_value(v).unwrap();
assert!(bad.validate().iter().any(|i| i.contains("listed twice")), "{:?}", bad.validate());
bad.repair();
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
assert_eq!(bad.pages, vec![p, q]);
}
#[test]
fn validate_caps_the_issue_list() {
let mut d = Document::new("x");
@@ -846,3 +860,17 @@ fn the_colour_walker_visits_calculation_literals() {
let ColorOperand::Literal { literal } = from else { panic!("literal operand") };
assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour");
}
#[test]
fn remove_children_removes_every_subtree_and_keeps_the_parent() {
let mut doc = Document::new("T");
let page = doc.first_page().unwrap();
let frame = doc.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
let inner = doc.create(frame, NodeKind::Frame, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap();
let leaf = doc.create(inner, NodeKind::Rectangle, "Leaf", 0.0, 0.0, 5.0, 5.0).unwrap();
let other = doc.create(page, NodeKind::Rectangle, "Other", 0.0, 0.0, 5.0, 5.0).unwrap();
doc.remove_children(frame).unwrap();
assert!(doc.contains(frame) && doc.contains(other));
assert!(!doc.contains(inner) && !doc.contains(leaf));
assert!(doc.get(frame).unwrap().children.is_empty());
}
+4
View File
@@ -147,6 +147,10 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
/// main layer's, for files from before `modes` was an override group. Copies follow their main
/// layer's modes, so a layer on other modes chose them itself.
pub fn mark_layer_modes(doc: &mut Document) {
// Without an instance nothing can be inside one: skip the per-layer ancestry lookups.
if !doc.nodes().any(|n| matches!(n.kind, NodeKind::Instance { .. })) {
return;
}
let chose: Vec<NodeId> = doc
.nodes()
.filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
+9 -1
View File
@@ -277,8 +277,16 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele
fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>) -> BTreeSet<VariableId> {
let mut out = BTreeSet::new();
let mut stack: Vec<VariableId> = seeds.into_iter().collect();
if stack.is_empty() {
return out;
}
// Indexed once (the first of equal ids, as `get_by_id` finds it): a scan per step is quadratic on a long alias chain.
let mut by_id = std::collections::HashMap::new();
for v in &vars.variables {
by_id.entry(v.id.as_str()).or_insert(v);
}
while let Some(cur) = stack.pop() {
let Some(v) = vars.get_by_id(&cur) else { continue };
let Some(v) = by_id.get(cur.as_str()).copied() else { continue };
if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) {
continue;
}
+4 -1
View File
@@ -79,10 +79,13 @@ pub fn usage(doc: &Document, library: &str) -> Usage {
let records: std::collections::BTreeMap<NodeId, NodeId> =
doc.libraries.components.iter().filter(|r| r.library == library).map(|r| (r.root, r.key)).collect();
if !records.is_empty() {
// One component's asset root is looked up once, however many instances use it.
let mut roots = std::collections::BTreeMap::<NodeId, Option<NodeId>>::new();
for n in doc.nodes() {
if let NodeKind::Instance { component, .. } = &n.kind
&& store.is_none_or(|st| doc.page_of(n.id) != Some(st))
&& let Some(key) = crate::library::asset_root(doc, *component).and_then(|r| records.get(&r))
&& let Some(key) =
roots.entry(*component).or_insert_with(|| crate::library::asset_root(doc, *component)).and_then(|r| records.get(&r))
{
let c = counts.entry(*key).or_default();
*c = c.saturating_add(1);
+8 -2
View File
@@ -156,14 +156,20 @@ impl BatchScope {
lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))
}
/// Most step names a missing-reference error lists.
const NAME_HINTS: usize = 12;
fn known_names(&self) -> String {
let mut names: Vec<_> = self.names.iter().collect();
names.sort_by_key(|(_, i)| **i);
if names.is_empty() {
return "no step has an `as` name".into();
}
let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
list.join(", ")
// A few of them: the list is built for every missing reference and, with `onError:
// continue`, kept for each, so a long table would cost its length per failed step.
let list: Vec<_> = names.iter().take(Self::NAME_HINTS).map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
let more = names.len().saturating_sub(Self::NAME_HINTS);
if more > 0 { format!("{}, {more} more", list.join(", ")) } else { list.join(", ") }
}
}
+3 -1
View File
@@ -109,7 +109,9 @@ pub fn eligible(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
out.push(id);
}
}
let ids: Vec<NodeId> = ids.iter().copied().filter(|i| doc.contains(*i)).collect();
// Each id counts once: a repeated group must not be walked once per repeat.
let mut seen = std::collections::BTreeSet::new();
let ids: Vec<NodeId> = ids.iter().copied().filter(|i| doc.contains(*i) && seen.insert(*i)).collect();
let mut out = Vec::new();
for id in roots(doc, &ids) {
walk(doc, id, 0, &mut out);
+4 -3
View File
@@ -373,10 +373,11 @@ fn listed<'s>(cmd: &str, s: &'s Session, p: &Value, who: &str, people: &[&str])
/// Add the names `text` @mentions to `named` as `{name, known, you}`, each once.
fn push_mentions(named: &mut Vec<Value>, text: &str, people: &[&str], user: &str) {
// `known` and `you` follow from the name, so the name alone tells a repeat.
let mut seen: std::collections::BTreeSet<String> = named.iter().filter_map(|v| v.get("name")?.as_str().map(str::to_string)).collect();
for (name, known) in mentioned(text, people) {
let v = json!({"name": name, "known": known, "you": known && name.eq_ignore_ascii_case(user)});
if !named.contains(&v) {
named.push(v);
if seen.insert(name.to_string()) {
named.push(json!({"name": name, "known": known, "you": known && name.eq_ignore_ascii_case(user)}));
}
}
}
+3 -1
View File
@@ -115,6 +115,8 @@ pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
let norm: String = {
let mut out = String::new();
let mut up = false;
// Once, not per character: a long value made this quadratic before it was refused.
let no_lowercase = s.chars().all(|c| !c.is_lowercase() || !c.is_alphabetic());
for (i, ch) in s.trim().chars().enumerate() {
if ch == '_' || ch == '-' || ch == ' ' {
up = true;
@@ -125,7 +127,7 @@ pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
} else if up {
out.extend(ch.to_uppercase());
up = false;
} else if s.chars().all(|c| !c.is_lowercase() || !c.is_alphabetic()) {
} else if no_lowercase {
out.extend(ch.to_lowercase());
} else {
out.push(ch);
+11 -4
View File
@@ -189,14 +189,21 @@ fn fill_layers(s: &mut Session, copy: NodeId, (key, value): (&str, &Value), miss
/// Export each copy into `dir` as `<name>.<format>` at `scale`, adding `files` (and `errors`) to
/// `out`.
fn export_copies(s: &mut Session, ids: &[(NodeId, String)], dir: &str, (format, scale): (&str, f64), out: &mut Value) {
let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::<String>::new());
let (mut files, mut errors) = (Vec::new(), Vec::new());
let mut used = std::collections::BTreeSet::<String>::new();
// The next number to try per base, so many copies of one name don't retry from 2 each time.
let mut next = std::collections::BTreeMap::<String, u64>::new();
for (copy, name) in ids {
// One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively,
// as macOS and Windows file systems compare names).
let base = file_name(name);
let stem =
(1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base);
used.push(stem.to_lowercase());
let from = next.get(&base.to_lowercase()).copied().unwrap_or(1);
let (k, stem) = (from..)
.map(|k| (k, if k == 1 { base.clone() } else { format!("{base} {k}") }))
.find(|(_, n)| !used.contains(&n.to_lowercase()))
.unwrap_or((from, base.clone()));
next.insert(base.to_lowercase(), k + 1);
used.insert(stem.to_lowercase());
let path = std::path::Path::new(dir).join(format!("{stem}.{format}")).to_string_lossy().to_string();
match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": scale, "path": path})) {
Ok(_) => files.push(path),
+1 -1
View File
@@ -34,7 +34,7 @@ pub use css::{CssNames, css_var_name, css_var_of};
use css::{css, tok};
pub(crate) use flutter::{FLUTTER_IMPORT, Place, flutter, flutter_indent};
use html::html;
pub(crate) use html::{Markup, class_name};
pub(crate) use html::{Markup, Names, class_name};
pub(crate) use swift::swift;
pub fn specs() -> Vec<CommandSpec> {
+30 -8
View File
@@ -30,7 +30,7 @@ fn kind_word(n: &Node) -> &str {
/// `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` (per-codepoint transliteration, the slug
/// libraries' way); a name left with nothing (symbols only) takes `kind`, and one starting with a
/// digit keeps the `layer-` prefix.
pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec<String>) -> String {
pub(crate) fn class_name(name: &str, kind: &str, used: &mut Names) -> String {
let mut base = String::new();
for c in deunicode::deunicode(name).chars().flat_map(char::to_lowercase) {
if c.is_ascii_alphanumeric() {
@@ -57,11 +57,30 @@ pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec<String>) -> Stri
} else {
base.to_string()
};
let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
used.push(name.clone());
let from = used.next.get(&base).copied().unwrap_or(1);
let (k, name) = (from..)
.map(|k| (k, if k == 1 { base.clone() } else { format!("{base}-{k}") }))
.find(|(_, n)| !used.used.contains(n))
.unwrap_or((from, base.clone()));
used.next.insert(base, k + 1);
used.insert(name.clone());
name
}
/// The names [`class_name`] has given out, and the next suffix to try per base, so many layers
/// sharing one name don't each retry every suffix from 2 (that was cubic in their number).
#[derive(Default)]
pub(crate) struct Names {
used: std::collections::BTreeSet<String>,
next: std::collections::BTreeMap<String, u64>,
}
impl Names {
pub fn insert(&mut self, name: String) {
self.used.insert(name);
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('\n', "<br>")
}
@@ -90,7 +109,10 @@ pub(super) fn html(doc: &Document, id: NodeId) -> String {
let ctx = Ctx::new(doc, id);
let mut out = Markup::new(&ctx, CodeLang::Html);
out.node(id, None, 0, "");
format!("{}\n<style>\n{}{}</style>\n", out.markup, out.tokens.root_rule(), out.styles)
// Document text can reach the stylesheet through any property: `<\/` reads back as `</` inside a CSS
// string and can never close the element early.
let css = format!("{}{}", out.tokens.root_rule(), out.styles).replace("</", "<\\/");
format!("{}\n<style>\n{css}</style>\n", out.markup)
}
/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue
@@ -101,7 +123,7 @@ pub(crate) struct Markup<'c, 'a> {
ctx: &'c Ctx<'a>,
/// Html, React, Vue or Leptos.
lang: CodeLang,
used: Vec<String>,
used: Names,
pub markup: String,
pub styles: String,
pub tokens: Tokens,
@@ -158,7 +180,7 @@ impl<'c, 'a> Markup<'c, 'a> {
Markup {
ctx,
lang,
used: Vec::new(),
used: Names::default(),
markup: String::new(),
styles: String::new(),
tokens: Tokens::default(),
@@ -273,7 +295,7 @@ impl<'c, 'a> Markup<'c, 'a> {
let root = n.id == self.ctx.root;
let class = match self.root_class.take().filter(|_| root) {
Some(c) => {
self.used.push(c.clone());
self.used.insert(c.clone());
c
}
None => {
@@ -329,7 +351,7 @@ impl<'c, 'a> Markup<'c, 'a> {
let open = open("div");
let slot_ident = slot.map(|p| match self.ctx.comp {
Some(c) => crate::component_codegen::slot_name(c, p),
None => class_name(&p.ident, "slot", &mut Vec::new()),
None => class_name(&p.ident, "slot", &mut Names::default()),
});
if let Some(p) = slot.filter(|_| kids.is_empty()) {
let content = if leptos {
+8 -6
View File
@@ -11,7 +11,9 @@ use figcraft_doc::{Document, NodeId, NodeKind, PropDef};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::{CodeLang, Ctx, FLUTTER_IMPORT, Markup, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift};
use crate::codegen::{
CodeLang, Ctx, FLUTTER_IMPORT, Markup, Names, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift,
};
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
use crate::params::{id_of, string, targets};
use crate::{bad, spec};
@@ -616,14 +618,14 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
/// The `<slot name="…">` of a slot parameter: unique among the component's slot parameters, unlike
/// `class_name` called with a fresh list per parameter (two idents can lowercase alike).
pub(crate) fn slot_name(c: &Component, p: &Param) -> String {
let mut used = Vec::new();
let mut used = Names::default();
for q in c.params.iter().filter(|q| matches!(q.kind, ParamKind::Slot)) {
let name = class_name(&q.ident, "slot", &mut used);
if q.ident == p.ident {
return name;
}
}
class_name(&p.ident, "slot", &mut Vec::new())
class_name(&p.ident, "slot", &mut Names::default())
}
/// The values an instance sets for `c`'s parameters, in order (`None` for other layers): variant
@@ -720,7 +722,7 @@ pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, dept
.filter(|(name, _, _)| is_slot(name))
.map(|(name, expr, _)| {
let slot = c.params.iter().find(|q| q.ident == *name);
let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Vec::new()));
let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Names::default()));
format!("{pad} <template #{name}>\n{expr}{pad} </template>\n")
})
.collect();
@@ -823,7 +825,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
/// `data-*` attribute names for variant parameters, unique among them (in order).
fn data_attrs(variants: &[&Param]) -> Vec<String> {
let mut used = Vec::new();
let mut used = Names::default();
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, "prop", &mut used))).collect()
}
@@ -832,7 +834,7 @@ fn data_attrs(variants: &[&Param]) -> Vec<String> {
fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) {
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
let root_class = class_name(&root_name, "component", &mut Vec::new());
let root_class = class_name(&root_name, "component", &mut Names::default());
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
let attr_names = data_attrs(&variants);
let pass = |root: NodeId| {
+4 -2
View File
@@ -288,8 +288,10 @@ pub fn layer_changes(before: &Document, now: &Document, id: NodeId) -> Vec<PropC
fn order(before: &Document, now: &Document, id: NodeId) -> Option<(usize, usize)> {
let parent = now.node(id)?.parent?;
let (pb, pa) = (before.node(parent)?, now.node(parent)?);
let common_b: Vec<NodeId> = pb.children.iter().rev().copied().filter(|c| pa.children.contains(c)).collect();
let common_a: Vec<NodeId> = pa.children.iter().rev().copied().filter(|c| pb.children.contains(c)).collect();
let (in_b, in_a): (std::collections::BTreeSet<NodeId>, std::collections::BTreeSet<NodeId>) =
(pb.children.iter().copied().collect(), pa.children.iter().copied().collect());
let common_b: Vec<NodeId> = pb.children.iter().rev().copied().filter(|c| in_a.contains(c)).collect();
let common_a: Vec<NodeId> = pa.children.iter().rev().copied().filter(|c| in_b.contains(c)).collect();
let (ib, ia) = (common_b.iter().position(|c| *c == id)?, common_a.iter().position(|c| *c == id)?);
(ib != ia).then_some((ib + 1, ia + 1))
}
+5 -2
View File
@@ -127,12 +127,15 @@ pub fn topics(v: Option<&Value>) -> Result<Vec<String>, String> {
for t in list {
let Some(s) = t.as_str() else { return Err("`topics` must be an array of strings".into()) };
match s {
// Each once: the list is scanned for every retained event under the log's lock, so
// a long list of repeats would multiply that work.
"comments" | "document" | "all" if out.iter().any(|t| t == s) => {}
"comments" | "document" | "all" => out.push(s.to_string()),
other => return Err(format!("unknown topic `{other}` (comments, document, all)")),
}
}
if out.is_empty() {
out.push("all".into());
if out.is_empty() || out.iter().any(|t| t == "all") {
out = vec!["all".into()];
}
Ok(out)
}
+4
View File
@@ -193,6 +193,10 @@ fn topics_parse_and_reject_unknown_names() {
assert_eq!(events::topics(Some(&json!([]))).unwrap(), vec!["all".to_string()]);
assert_eq!(events::topics(Some(&json!(["comments", "document"]))).unwrap(), vec!["comments".to_string(), "document".to_string()]);
assert!(events::topics(Some(&json!(["nope"]))).is_err());
// Repeats collapse (the list is scanned per event under the log's lock), and `all` is all.
let many: Vec<&str> = std::iter::repeat_n("comments", 10_000).chain(["document"]).collect();
assert_eq!(events::topics(Some(&json!(many))).unwrap(), vec!["comments".to_string(), "document".to_string()]);
assert_eq!(events::topics(Some(&json!(["comments", "all", "document"]))).unwrap(), vec!["all".to_string()]);
assert!(events::topics(Some(&json!("comments"))).is_err());
}
+10
View File
@@ -149,6 +149,16 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
if DENIED.iter().any(|p| command.starts_with(p)) && !ALLOWED.contains(&command) {
return Some(format!("`{command}` isn't available to plugins"));
}
// A preview runs its inner command, which the plugin's permissions and the denied list must cover too.
// An inner command that can't be previewed (a nested preview) is refused by the preview itself,
// before it runs: descending into its parameters first would only rescan them.
if command == "text.preview"
&& let Some(inner) = get(params, "command").and_then(Value::as_str)
&& crate::textpreview::PREVIEWABLE.contains(&inner)
&& let Some(why) = refusal(m, inner, get(params, "params").unwrap_or(&Value::Null))
{
return Some(why);
}
// A widget's own data is FigCraft's to write, so no plugin can forge or retarget a widget.
if writes_widget_data(params, 0) {
return Some(format!("plugins can't write `{}…` layer data", crate::widgets::DATA_PREFIX));
+3 -4
View File
@@ -157,8 +157,9 @@ fn state_of(doc: &Document, w: NodeId) -> Map<String, Value> {
/// fields), each once, in layer order.
fn names(doc: &Document, w: NodeId, key: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(key).cloned()) {
if !out.contains(&t) {
if seen.insert(t.clone()) {
out.push(t);
}
}
@@ -243,9 +244,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
if !is_widget(n) || n.plugin_data.get(PLUGIN).map(String::as_str) != Some(plugin) {
return Err(bad(cmd, "the layer is no longer this plugin's widget"));
}
for c in n.children.clone() {
doc.remove(c)?;
}
doc.remove_children(w)?;
crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?;
let n = doc.get_mut(w)?;
n.locked = false;
+27
View File
@@ -73,6 +73,20 @@ fn non_ascii_layer_names_transliterate_into_readable_classes() {
assert!(code.contains("<p class=\"rogeuin\">") && code.contains("<p class=\"rogeuin-2\">"), "{code}");
}
#[test]
fn many_layers_with_one_name_get_numbered_in_linear_time() {
// Each name used to retry every suffix from 2 against a list: cubic in their number.
let mut used = crate::codegen::Names::default();
let start = std::time::Instant::now();
let names: Vec<String> = (0..20_000).map(|_| crate::codegen::class_name("Card / Title", "frame", &mut used)).collect();
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
assert_eq!(names[0], "card-title");
assert_eq!(names[1], "card-title-2");
assert_eq!(names[19_999], "card-title-20000");
let distinct: std::collections::BTreeSet<&String> = names.iter().collect();
assert_eq!(distinct.len(), names.len());
}
#[test]
fn other_scripts_and_symbol_names_transliterate_or_fall_back_to_the_kind() {
let mut s = Session::new();
@@ -163,3 +177,16 @@ fn rem_preserves_text_property_default() {
}
}
}
#[test]
fn the_stylesheet_never_contains_a_closing_style_tag_from_document_text() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let t = id(s.execute("text.create", &json!({"text": "x", "x": 0, "y": 0})).unwrap());
s.execute("node.set", &json!({"id": t, "props": {"fontFamily": "a</style><script>alert(1)</script>"}})).unwrap();
s.execute("node.reparent", &json!({"ids": [t], "parent": f})).unwrap();
let code = code_for(s.doc().unwrap(), f, CodeLang::Html);
let (_, css) = code.split_once("<style>").unwrap();
assert_eq!(css.matches("</style>").count(), 1, "{code}");
assert!(!code.contains("<script>"), "{code}");
}
@@ -277,6 +277,11 @@ fn renaming_several_layers_by_the_documented_codes() {
assert_eq!(r(None, "$n", Some(1e30), "a"), "1000000000000");
assert_eq!(r(None, "$n", Some(f64::NAN), "a"), "1");
assert_eq!(r(Some("é"), "e", None, "café é"), "cafe e");
// A template is expanded once per match, so its length is capped (1,000 characters).
let long = "$nn".repeat(400);
assert!(crate::rename::BatchRename::new(Some("a"), false, &long, None, None).is_err());
assert!(crate::rename::BatchRename::new(None, false, "x", Some(&long), None).is_err_and(|e| e.contains("pattern")));
assert!(crate::rename::BatchRename::new(Some("a"), false, &"$nn".repeat(300), None, None).is_ok());
}
#[test]
+24
View File
@@ -153,6 +153,30 @@ fn read_only_plugins_and_allowlists_are_enforced() {
assert_eq!(node(&s, a).name, "Rectangle");
}
#[test]
fn a_preview_is_held_to_the_permissions_of_the_command_it_shows() {
let mut s = Session::new();
let a = rect(&mut s, 0.0);
// Only text.preview is allowed: the node.set it would run is not.
install(
&mut s,
manifest("org.test.previewer", "write", &["text.preview"]),
&script(&[("text.preview", json!({"command": "node.set", "params": {"id": a, "props": {"name": "x"}}}))], OnRefusal::Fail, ""),
);
let e = run_err(&mut s, "org.test.previewer");
assert!(e.contains("`node.set` isn't in the commands"), "{e}");
assert_eq!(node(&s, a).name, "Rectangle");
}
#[test]
fn a_nested_preview_is_refused_by_the_preview_itself() {
let mut s = Session::new();
let nested = json!({"command": "text.preview", "params": {"command": "node.set", "params": {"props": {"name": "x"}}}});
install(&mut s, manifest("org.test.nested", "write", &["text.preview"]), &script(&[("text.preview", nested)], OnRefusal::Fail, ""));
let e = run_err(&mut s, "org.test.nested");
assert!(e.contains("can't be previewed"), "{e}");
}
#[test]
fn files_dialogs_history_and_plugins_are_off_limits() {
let mut s = Session::new();
+9
View File
@@ -9,6 +9,9 @@ use figcraft_doc::{Document, NodeId};
/// Longest match pattern accepted (characters).
const MAX_FIND: usize = 1_000;
/// Longest replacement or pattern template accepted (characters): each is expanded again for
/// every match, so its length multiplies the work of a rename.
const MAX_TEMPLATE: usize = 1_000;
/// Longest name a rename produces (characters).
const MAX_NAME: usize = 10_000;
/// Widest zero padding of a number code.
@@ -50,6 +53,12 @@ impl BatchRename {
}
Some(f) => Some(Find::Text(f.to_string())),
};
if replace.chars().count() > MAX_TEMPLATE {
return Err(format!("the replacement is longer than {MAX_TEMPLATE} characters"));
}
if pattern.is_some_and(|p| p.chars().count() > MAX_TEMPLATE) {
return Err(format!("the pattern is longer than {MAX_TEMPLATE} characters"));
}
let start = start.filter(|s| s.is_finite()).map_or(1, |s| s.clamp(0.0, 1e12).round() as u64);
Ok(Self { find, replace: replace.to_string(), pattern: pattern.map(str::to_string), start })
}
+9
View File
@@ -217,6 +217,15 @@ fn remove_mode_and_escape() {
assert!(s.execute("shapeBuilder.mode", &json!({"mode": "sideways"})).is_err());
}
#[test]
fn a_group_named_many_times_is_walked_once() {
let mut s = Session::new();
let (a, b) = squares(&mut s);
let g = id(&ex(&mut s, "object.group", json!({"ids": [a, b]})));
let r = ex(&mut s, "shapeBuilder.regions", json!({"ids": vec![g; 500]}));
assert_eq!(r["sources"], json!([a, b]));
}
#[test]
fn groups_rotated_and_ineligible_layers() {
let mut s = Session::new();
+5
View File
@@ -1028,6 +1028,11 @@ pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Resul
let tmp = std::path::PathBuf::from(name);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(mut file) => {
// The replacement keeps the destination's permissions from the start, so a private
// file's content is never readable under default ones (the open handle still writes).
if let Ok(old) = std::fs::metadata(path) {
let _ = file.set_permissions(old.permissions());
}
if let Err(e) = file.write_all(bytes) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
+5
View File
@@ -226,11 +226,16 @@ impl<'b> Archive<'b> {
};
let refs = document.get("pages").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
let mut pages = Vec::new();
let mut seen = std::collections::HashSet::new();
for r in refs.iter().take(MAX_PAGES) {
let Some(id) = r.get("_ref").and_then(Value::as_str).and_then(|s| s.strip_prefix("pages/")).filter(|id| valid_id(id)) else {
warnings.push("pages the document lists without a valid reference were skipped".into());
continue;
};
// A page listed twice is read once: a repeated reference must not repeat the unpacking.
if !seen.insert(id) {
continue;
}
match json_member(&mut z, &format!("pages/{id}.json"), max_member, &mut total) {
Ok(page) if page.is_object() => pages.push((id.to_string(), page)),
Ok(_) => warnings.push("pages that aren't JSON objects were skipped".into()),
+7
View File
@@ -35,6 +35,13 @@ fn pages_become_pages_in_document_order() {
assert_eq!(page_names(&doc), ["Beta", "Alpha"]);
}
#[test]
fn a_page_listed_twice_is_imported_once() {
let z = sketch_zip("50.2", 103, document(&["A", "A", "A"]), &[page("A", "Alpha", vec![])], &[]);
let (doc, _) = import(&z, "f").unwrap();
assert_eq!(page_names(&doc), ["Alpha"]);
}
#[test]
fn older_than_48_is_refused() {
let z = sketch_zip("47.1", 95, document(&["P"]), &[page("P", "P", vec![])], &[]);
+14
View File
@@ -123,3 +123,17 @@ fn a_failed_write_leaves_the_destination_alone_and_cleans_up() {
}
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn a_replaced_file_keeps_its_permissions() {
use std::os::unix::fs::PermissionsExt;
let d = dir("mode");
let path = d.join("a.figcraft");
std::fs::write(&path, b"old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
write_file_atomic(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
let _ = std::fs::remove_dir_all(&d);
}
+12
View File
@@ -629,14 +629,26 @@ fn distribute(items: &mut [Item], mut open: Vec<usize>, free: f64) {
.collect();
let violation: f64 = sized.iter().map(|(_, want, v)| v - want).sum();
let frozen = |want: f64, v: f64| violation.abs() < 1e-9 || (violation > 0.0 && v > want + 1e-9) || (violation < 0.0 && v < want - 1e-9);
let mut progressed = false;
for (i, want, v) in &sized {
if frozen(*want, *v)
&& let Some(it) = items.get_mut(*i)
{
it.main = *v;
free -= *v;
progressed = true;
}
}
if !progressed {
// Several clampings each under the tolerance added up past it, so none froze: settle
// them all as clamped rather than go round again with nothing changed.
for (i, _, v) in &sized {
if let Some(it) = items.get_mut(*i) {
it.main = *v;
}
}
break;
}
free = free.max(0.0);
open = sized.into_iter().filter(|(_, want, v)| !frozen(*want, *v)).map(|(i, _, _)| i).collect();
}
+21
View File
@@ -447,3 +447,24 @@ fn grid_hostile_values() {
assert!(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite());
}
}
#[test]
fn fill_children_whose_tiny_clampings_add_up_past_the_tolerance_still_settle() {
// Three fill children sharing 300 px, each with a minimum 5e-10 over its 100 px share: no
// single clamping passes the 1e-9 tolerance, but together they do, so no child froze and the
// distribution loop went round forever without changing anything.
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 300.0, 100.0).unwrap();
d.get_mut(f).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Horizontal, gap: 0.0, padding: [0.0; 4], ..Default::default() });
let kids: Vec<NodeId> = (0..3).map(|i| d.create(f, NodeKind::Rectangle, "K", 0.0, 0.0, 10.0, 20.0 + i as f64).unwrap()).collect();
for k in &kids {
let n = d.get_mut(*k).unwrap();
n.child_layout.sizing_h = Sizing::Fill;
n.child_layout.min_w = Some(100.0 + 5e-10);
}
layout_frame(&mut d, f, &NoMeasure);
for k in &kids {
assert!((pos(&d, *k).2 - 100.0).abs() < 1e-6, "{:?}", pos(&d, *k));
}
}
+44 -2
View File
@@ -128,7 +128,7 @@ pub fn get(name: &str, args: &Value) -> Result<Value, String> {
if !(args.is_object() || args.is_null()) {
return Err("`arguments` must be an object of strings".into());
}
let mut text = p.template.replace("{verify}", VERIFY);
let mut values: Vec<(&str, String)> = vec![("verify", VERIFY.to_string())];
for a in p.args {
let v = match args.get(a.name) {
Some(Value::String(s)) if !s.trim().is_empty() => s.clone(),
@@ -142,7 +142,49 @@ pub fn get(name: &str, args: &Value) -> Result<Value, String> {
Some(Value::String(_)) => p.defaults.iter().find(|(k, _)| *k == a.name).map(|(_, d)| (*d).to_string()).unwrap_or_default(),
Some(_) => return Err(format!("argument `{}` must be a string", a.name)),
};
text = text.replace(&format!("{{{}}}", a.name), &v);
values.push((a.name, v));
}
// One pass over the template, so an argument's text is never read as `{…}` syntax itself (a
// value holding placeholders used to be expanded again by every later replacement).
let mut text = String::with_capacity(p.template.len());
let mut rest = p.template;
while let Some(open) = rest.find('{') {
text.push_str(rest.get(..open).unwrap_or_default());
let after = rest.get(open..).unwrap_or_default();
let found = after.find('}').and_then(|close| {
let key = after.get(1..close)?;
values.iter().find(|(k, _)| *k == key).map(|(_, v)| (close, v.as_str()))
});
match found {
Some((close, v)) => {
text.push_str(v);
rest = after.get(close + 1..).unwrap_or_default();
}
None => {
text.push('{');
rest = after.get(1..).unwrap_or_default();
}
}
}
text.push_str(rest);
Ok(json!({"description": p.description, "messages": [{"role": "user", "content": {"type": "text", "text": text}}]}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arguments_are_text_not_template_syntax() {
let text = |args: Value| get("mobile_screen", &args).unwrap()["messages"][0]["content"]["text"].as_str().unwrap().to_string();
let plain = text(json!({"screen": "login"}));
assert!(plain.contains("Design a login screen") && plain.contains("iPhone 16") && plain.ends_with(VERIFY), "{plain}");
assert!(!plain.contains("{screen}") && !plain.contains("{verify}"), "{plain}");
// A value made of placeholders stays as typed, however many there are: it is never
// expanded again by the replacements after it (that multiplied the output without bound).
let bomb = "{device}{brand_color}{verify}".repeat(1000);
let out = text(json!({"screen": bomb.clone()}));
assert!(out.contains(&bomb));
assert!(out.len() < 3 * bomb.len(), "{}", out.len());
}
}
+13 -3
View File
@@ -1,7 +1,7 @@
//! Text: real, selectable text in embedded (subset) fonts, positioned glyph by glyph from the
//! `figcraft_text` layout the renderer uses, with outline fallbacks.
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::ops::Range;
use std::sync::Arc;
@@ -56,14 +56,24 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
let styles: BTreeSet<usize> = layout.glyphs.iter().map(|g| g.style).chain(layout.decorations.iter().map(|d| d.style)).collect();
// Every run's fills, then the inner shadows over them, then the strokes on top.
let mut stroked = Vec::new();
// Glyphs and decorations are grouped by style in one pass; each style's paths are built (and
// dropped) inside its own iteration, so only one style's paths are held at a time.
let mut decoration_of: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (i, d) in layout.decorations.iter().enumerate() {
decoration_of.entry(d.style).or_default().push(i);
}
let mut glyph_of: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (i, g) in layout.glyphs.iter().enumerate() {
glyph_of.entry(g.style).or_default().push(i);
}
for k in styles {
let fills = layout.styles.get(k).and_then(|st| st.fills.as_ref()).unwrap_or(&n.fills);
let mut decorations = BezPath::new();
for d in layout.decorations.iter().filter(|d| d.style == k) {
for d in decoration_of.remove(&k).unwrap_or_default().into_iter().filter_map(|i| layout.decorations.get(i)) {
decorations.extend(d.path());
}
let mut outlines = BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.style == k) {
for g in glyph_of.remove(&k).unwrap_or_default().into_iter().filter_map(|i| layout.glyphs.get(i)) {
outlines.extend(g.path());
}
outlines.extend(decorations.iter());
+5
View File
@@ -1031,6 +1031,11 @@ fn masked_area(ctx: &Ctx, pix: &Pixmap, mask_id: NodeId, content: &[NodeId], xf:
/// Mask `m` drawn as if it weren't a mask into a `w` × `h` pixmap at `xf` (a vector mask: its
/// outline, fully opaque).
fn mask_pixels(ctx: &Ctx, m: &Node, (w, h): (u32, u32), xf: Affine, depth: usize) -> Option<Pixmap> {
// A mask's content is drawn here, not through `draw_layer`, so a chain of masks inside
// masks needs the nesting limit checked here too, or it runs down the stack.
if depth > crate::MAX_EXPORT_DEPTH {
return None;
}
let mut mask_px = Pixmap::new(w, h)?;
let mut m2 = m.clone();
m2.is_mask = false;
+27
View File
@@ -78,6 +78,20 @@ fn decoding_is_capped() {
assert!(images::decode_frames(&huge).is_none());
}
#[test]
fn a_large_partial_frame_plays_when_two_whole_frames_fit() {
// Canvas 4000 x 3000 (48 MB a frame, two fit the cap) with a 3500 x 2800 second frame: the
// decoder's canvas, composited output and partial frame together need more than the cap.
let mut out = Vec::new();
{
let mut enc = GifEncoder::new(&mut out);
enc.set_repeat(Repeat::Infinite).unwrap();
let frame = |w, h, c| Frame::from_parts(RgbaImage::from_pixel(w, h, image::Rgba(c)), 0, 0, Delay::from_numer_denom_ms(100, 1));
enc.encode_frames([frame(4000, 3000, RED), frame(3500, 2800, GREEN)]).unwrap();
}
assert_eq!(images::decode_frames(&out).map(|f| f.len()), Some(2));
}
#[test]
fn the_first_frame_draws_unless_time_moves_on() {
let mut d = Document::new("t");
@@ -98,3 +112,16 @@ fn the_first_frame_draws_unless_time_moves_on() {
d.get_mut(id).unwrap().visible = false;
assert!(images::animation_clock(&d, page, 0.15).is_none(), "hidden layers don't keep the presenter awake");
}
#[test]
fn a_frame_descriptor_bigger_than_the_canvas_is_refused_before_it_is_allocated() {
// A 4 × 4 canvas whose first frame descriptor claims 16,383 × 16,383 (a gigabyte of RGBA):
// the decoder's limits refuse the frame buffer instead of allocating it.
let mut bytes = gif(&[(RED, 100), (GREEN, 100)], 4, 4);
let at = bytes.windows(9).position(|w| w == [0x2c, 0, 0, 0, 0, 4, 0, 4, 0]).expect("an image descriptor for a 4 × 4 frame");
let (w, h) = ((16_383u16).to_le_bytes(), (16_383u16).to_le_bytes());
bytes[at + 5..at + 9].copy_from_slice(&[w[0], w[1], h[0], h[1]]);
let start = std::time::Instant::now();
assert!(images::decode_frames(&bytes).is_none());
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
}
+10 -1
View File
@@ -293,12 +293,21 @@ pub fn decode_frames(bytes: &[u8]) -> Option<Vec<(Pixmap, f64)>> {
if image::guess_format(bytes).ok()? != image::ImageFormat::Gif {
return None;
}
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
let mut dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
let (w, h) = dec.dimensions();
let frame_bytes = u64::from(w) * u64::from(h) * 4;
if frame_bytes == 0 || w > MAX_SIDE || h > MAX_SIDE {
return None;
}
// A frame's own descriptor can ask for a far larger buffer than the canvas: the decoder
// reserves each frame and canvas buffer against these limits before allocating it.
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_SIDE);
limits.max_image_height = Some(MAX_SIDE);
// The decoder charges its canvas, its composited output and the partial frame against this
// together, besides the frames kept (capped by `most` below): one frame of working room.
limits.max_alloc = Some(MAX_ANIMATION_BYTES.saturating_add(frame_bytes));
dec.set_limits(limits).ok()?;
let most = (MAX_ANIMATION_BYTES / frame_bytes).min(MAX_FRAMES as u64) as usize;
let mut out = Vec::new();
for f in dec.into_frames().take(most) {
+21
View File
@@ -139,3 +139,24 @@ fn a_deep_nest_of_isolated_layers_stays_within_the_budget() {
// The outer levels still draw (the innermost, past the budget, are left out).
assert_ne!(&pix.data()[..4], &[255, 255, 255, 255]);
}
#[test]
fn masks_nested_inside_masks_stop_at_the_nesting_limit() {
// A mask's content is drawn straight from `mask_pixels`, not through `draw_layer`, so a mask
// group inside a mask group inside… used to recurse past the limit and down the stack.
let mut d = Document::new("deep masks");
let p = d.first_page().unwrap();
let mut parent = p;
for _ in 0..2000 {
let g = d.create(parent, NodeKind::Group, "Masking group", 0.0, 0.0, 1.0, 1.0).unwrap();
let n = d.get_mut(g).unwrap();
n.is_mask = true;
n.mask_type = MaskType::Alpha;
parent = g;
}
shape(&mut d, parent, NodeKind::Rectangle, (0.0, 0.0, 100.0, 100.0), "2040E0");
shape(&mut d, p, NodeKind::Rectangle, (0.0, 0.0, 100.0, 100.0), "E04020");
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 50, height: 50 };
let pix = render(&d, p, &v, false);
assert_eq!(pix.width(), 50);
}
+25 -2
View File
@@ -103,11 +103,34 @@ pub(crate) struct Ctx<'a> {
pattern_depth: usize,
/// Tiles drawn so far, by pattern and scale (see [`paint`]'s `render_tile`).
tiles: HashMap<(*const usvg::Pattern, u64), Option<paint::Tile>>,
/// The document image key of each embedded image's bytes already added, so an image a
/// pattern paints on many shapes is hashed once, not once per shape.
image_keys: HashMap<*const Vec<u8>, String>,
}
impl<'a> Ctx<'a> {
pub(crate) fn new(doc: &'a mut Document) -> Self {
Ctx { doc, count: 0, anonymous: HashSet::new(), notes: BTreeSet::new(), fonts: None, pattern_depth: 0, tiles: HashMap::new() }
Ctx {
doc,
count: 0,
anonymous: HashSet::new(),
notes: BTreeSet::new(),
fonts: None,
pattern_depth: 0,
tiles: HashMap::new(),
image_keys: HashMap::new(),
}
}
/// [`add_image`] for `bytes`, once per distinct buffer.
pub(crate) fn image(&mut self, bytes: std::sync::Arc<Vec<u8>>, w: f64, h: f64) -> String {
if let Some(k) = self.image_keys.get(&std::sync::Arc::as_ptr(&bytes)) {
return k.clone();
}
let ptr = std::sync::Arc::as_ptr(&bytes);
let key = add_image(self.doc, bytes, w, h);
self.image_keys.insert(ptr, key.clone());
key
}
pub(crate) fn full(&self) -> bool {
@@ -605,7 +628,7 @@ fn image(cx: &mut Ctx, img: &usvg::Image, id: &str, parent: NodeId, base: Affine
let size = img.size();
let (iw, ih) = (f64::from(size.width()), f64::from(size.height()));
let (transform, w, h) = place(world, Point::ZERO, iw, ih).unwrap_or((world, iw, ih));
let hash = add_image(cx.doc, bytes, iw, ih);
let hash = cx.image(bytes, iw, ih);
let mut n = Node::new(cx.doc.alloc_id(), NodeKind::Rectangle, layer_name(id, "Image"), w, h);
n.transform = transform;
let mut fill = Paint::image(hash, ImageScaleMode::Fill);
+14 -1
View File
@@ -44,6 +44,11 @@ fn inflate(data: &[u8], limit: usize) -> Result<Vec<u8>, String> {
if out.len() > limit {
return Err(format!("compressed SVG expands past {} MiB", limit >> 20));
}
// usvg gunzips what it is handed, with no limit: one layer is unpacked here, within the
// budget, and what comes out must be the document itself.
if gzipped(&out) {
return Err("compressed SVG holds another compressed file, not a document".into());
}
Ok(out)
}
@@ -72,7 +77,15 @@ fn embedded(used: &Used, limits: &Limits, data: &[u8], opts: &Options) -> Option
return None;
}
let left = limits.embedded.saturating_sub(used.bytes.load(Ordering::Relaxed));
let plain = inflate(data, left).ok()?;
let plain = match inflate(data, left) {
Ok(plain) => plain,
// What a refused document unpacked before it was refused was work too: it uses up what is
// left, so the next image can't repeat it.
Err(_) => {
used.bytes.fetch_add(left, Ordering::Relaxed);
return None;
}
};
used.bytes.fetch_add(plain.len(), Ordering::Relaxed);
if super::too_deep(&plain) {
return None;
+28
View File
@@ -111,3 +111,31 @@ fn linked_files_and_other_mime_types_stay_unresolved() {
);
assert_eq!(layers(data.as_bytes(), Limits::default()).unwrap(), layers(svg("").as_bytes(), Limits::default()).unwrap());
}
#[test]
fn a_document_compressed_twice_is_refused_not_unpacked_again() {
// One gzip layer is unpacked within the budget; usvg would unpack a second one without any.
let twice = gzip(&gzip(svg(&" ".repeat(1 << 20)).as_bytes()));
let e = layers(&twice, Limits { document: 4 << 20, ..Limits::default() }).unwrap_err();
assert!(e.contains("another compressed file"), "{e}");
// Embedded, the same is dropped.
let inner = svg(r##"<rect width="10" height="10"/>"##);
let data = svg(&format!(
r##"<image width="10" height="10" xlink:href="data:image/svg+xml;base64,{}"/>"##,
base64::engine::general_purpose::STANDARD.encode(gzip(&gzip(inner.as_bytes())))
))
.into_bytes();
assert_eq!(layers(&data, Limits::default()).unwrap(), layers(svg("").as_bytes(), Limits::default()).unwrap());
}
#[test]
fn a_refused_embedded_bomb_uses_up_the_budget_for_the_images_after_it() {
let bomb = gzip(svg(&" ".repeat(1 << 20)).as_bytes());
let inner = svg(r##"<rect width="10" height="10"/>"##);
let image = |href: String| format!(r##"<image width="10" height="10" xlink:href="{href}"/>"##);
let bomb_uri = format!("data:image/svg+xml;base64,{}", base64::engine::general_purpose::STANDARD.encode(&bomb));
let data = svg(&format!("{}{}", image(bomb_uri).repeat(8), image(uri(inner.as_bytes())))).into_bytes();
let limits = Limits { embedded: 4096, ..Limits::default() };
let empty = layers(svg("").as_bytes(), limits).unwrap();
assert_eq!(layers(&data, limits).unwrap(), empty, "the first bomb spends the allowance; nothing after it unpacks");
}
+1 -1
View File
@@ -133,7 +133,7 @@ fn pattern(cx: &mut Ctx, pat: &usvg::Pattern, t: Target) -> Option<Paint> {
let s = img.size();
let (iw, ih) = (f64::from(s.width()), f64::from(s.height()));
let b = figcraft_geom::transform_rect_bbox(m, Rect::new(0.0, 0.0, iw, ih));
(tile_rect.contains(b.origin()) && tile_rect.contains(Point::new(b.x1, b.y1))).then(|| (add_image(cx.doc, bytes, iw, ih), iw, ih, m))
(tile_rect.contains(b.origin()) && tile_rect.contains(Point::new(b.x1, b.y1))).then(|| (cx.image(bytes, iw, ih), iw, ih, m))
});
let (hash, iw, ih, content) = match lone {
Some(l) => l,
+3 -2
View File
@@ -153,8 +153,9 @@ fn an_image_stretched_into_its_box_stays_stretched() {
#[test]
fn linked_files_are_never_read() {
let dir = std::env::temp_dir().join("figcraft-svg-linked");
std::fs::create_dir_all(&dir).unwrap();
let dir = std::env::temp_dir().join(format!("figcraft-svg-linked-{}", std::process::id()));
// `create_dir` refuses a directory or link someone else planted at this name.
std::fs::create_dir(&dir).unwrap();
let png = dir.join("secret.png");
std::fs::write(&png, base64::Engine::decode(&base64::engine::general_purpose::STANDARD, PNG).unwrap()).unwrap();
let href = png.to_string_lossy().replace('\\', "/");
+4 -1
View File
@@ -139,7 +139,10 @@ fn macos_font_assets_are_found_in_the_asset_store() {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let tmp = TempDir(std::env::temp_dir().join(format!("figcraft-font-assets-{}", std::process::id())));
// Created fresh: a directory or link already at this predictable name is refused, and not removed.
let root = std::env::temp_dir().join(format!("figcraft-font-assets-{}", std::process::id()));
std::fs::create_dir(&root).unwrap();
let tmp = TempDir(root);
let store = tmp.0.join("AssetsV2");
let fonts = store.join("com_apple_MobileAsset_Font7");
let data = |kind: &std::path::Path| kind.join("0f1e2d.asset").join("AssetData");
+13 -2
View File
@@ -1698,11 +1698,22 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
}
let (script_scale, script_shift) = synth.map_or((1.0, 0.0), |(_, up)| if up { face.superscript } else { face.subscript });
let n = raw.len();
// Cluster end: the next larger cluster value, else the segment end. A glyph in the same
// cluster as the next one shares its end, so many glyphs of one cluster aren't rescanned.
let mut cends = vec![0usize; n];
for gi in (0..n).rev() {
let Some(&(_, cl, ..)) = raw.get(gi) else { continue };
let cl = cl as usize;
let shared = raw.get(gi + 1).filter(|r| r.1 as usize == cl).and_then(|_| cends.get(gi + 1).copied());
let cend = shared.unwrap_or_else(|| raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1));
if let Some(slot) = cends.get_mut(gi) {
*slot = cend;
}
}
for gi in 0..n {
let Some(&(gid, cl, xa, xo, yo)) = raw.get(gi) else { continue };
let cl = cl as usize;
// Cluster end: the next larger cluster value, else the segment end.
let cend = raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1);
let cend = cends.get(gi).copied().unwrap_or(cl + 1);
let last_in_cluster = raw.get(gi + 1).is_none_or(|r| r.1 as usize != cl);
let ch = chars.get(cl).copied().unwrap_or(' ');
let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 } * script_scale;
+20 -6
View File
@@ -6,12 +6,7 @@ use super::{
impl FigApp {
pub fn save_as_dialog(&mut self) {
if self.services.downloads() {
if let Some(r) = self.run("file.saveAs", json!({"data": true}))
&& self.download(r["name"].as_str().unwrap_or_default(), &r)
{
self.run("file.markSaved", json!({}));
dialogs::file_saved(self, &r);
}
self.save_download(None);
return;
}
let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return };
@@ -43,6 +38,17 @@ impl FigApp {
});
}
/// Save by handing the file over as a download. `keep`: the name a Save of a host-opened
/// document keeps; `None` is a Save As, under the document's own name.
fn save_download(&mut self, keep: Option<&str>) {
if let Some(r) = self.run("file.saveAs", json!({"data": true}))
&& self.download(keep.unwrap_or_else(|| r["name"].as_str().unwrap_or_default()), &r)
{
self.run("file.markSaved", json!({}));
dialogs::file_saved(self, &r);
}
}
pub fn save(&mut self) {
// A branch tab saves its main file.
let tab = figcraft_engine::branches::file_tab(&self.session);
@@ -57,6 +63,9 @@ impl FigApp {
if self.run("file.save", json!({})).is_some() {
self.toast(i18n::tr("Saved"));
}
} else if let Some(name) = self.services.host_file().filter(|n| is_document_file(n)) {
// A host-opened document is saved as the file the host gave: the host updates that file.
self.save_download(Some(&name));
} else {
self.save_as_dialog();
}
@@ -299,3 +308,8 @@ impl FigApp {
failed
}
}
/// A `.figcraft` file name: the document format a Save writes (a `.fig` is an export copy).
fn is_document_file(name: &str) -> bool {
Path::new(name).extension().and_then(OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("figcraft"))
}
+1 -1
View File
@@ -138,7 +138,7 @@ pub(crate) fn save_file(app: &mut FigApp, data: &Value) {
let shown = name.clone();
app.pick_save(tr("Save file"), &name, &exts, move |app, path| {
let file = path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or(shown);
match std::fs::write(&path, &bytes) {
match figcraft_engine::file_access::write(&path, &bytes) {
Ok(()) => app.toast(trf("Saved {name}", &[("name", &file)])),
Err(_) => app.toast(tr("Couldn't save the file")),
}
+60
View File
@@ -0,0 +1,60 @@
//! In a page host the browser hands files to the host: a Save of the document the host opened goes
//! out under the host's file name, a Save As under the document's own name.
use std::sync::{Arc, Mutex};
use figcraft_engine::Session;
use serde_json::json;
use crate::{FigApp, Services};
type Saved = Arc<Mutex<Vec<String>>>;
/// Services that save by downloads, with a document file the host opened.
struct Host {
got: Saved,
file: Option<&'static str>,
}
impl Services for Host {
fn downloads(&self) -> bool {
true
}
fn download(&mut self, name: &str, _bytes: &[u8]) -> bool {
self.got.lock().unwrap().push(name.to_string());
true
}
fn host_file(&self) -> Option<String> {
self.file.map(str::to_string)
}
}
fn app(file: Option<&'static str>) -> (FigApp, Saved) {
let got = Saved::default();
let mut app = FigApp::new(Session::empty(), Box::new(Host { got: got.clone(), file }));
app.session.execute("file.new", &json!({"name": "Original"})).unwrap();
(app, got)
}
#[test]
fn save_keeps_the_name_the_host_opened() {
let (mut app, got) = app(Some("client.figcraft"));
app.save();
assert_eq!(got.lock().unwrap().as_slice(), ["client.figcraft"]);
}
#[test]
fn save_as_uses_the_document_s_own_name() {
let (mut app, got) = app(Some("client.figcraft"));
app.save_as_dialog();
assert_eq!(got.lock().unwrap().as_slice(), ["Original.figcraft"]);
}
#[test]
fn without_a_host_file_or_for_a_copy_format_save_is_a_save_as() {
for file in [None, Some("design.json"), Some("export.fig")] {
let (mut app, got) = app(file);
app.save();
assert_eq!(got.lock().unwrap().as_slice(), ["Original.figcraft"], "{file:?}");
}
}
+2
View File
@@ -124,6 +124,8 @@ mod home_recents_tests;
#[cfg(test)]
mod home_tests;
#[cfg(test)]
mod host_save_tests;
#[cfg(test)]
mod idle_tests;
#[cfg(test)]
mod ime_tests;
+5
View File
@@ -24,6 +24,11 @@ pub trait Services {
fn download(&mut self, _name: &str, _bytes: &[u8]) -> bool {
false
}
/// The document file a host page opened, where the app runs inside one (the browser's host
/// mode): a Save of that document keeps this name instead of the document's own.
fn host_file(&self) -> Option<String> {
None
}
/// A folder to write several files into.
fn pick_folder(&mut self, _title: &str) -> DialogFuture {
Box::pin(std::future::ready(None))
+39 -7
View File
@@ -24,6 +24,8 @@ pub(crate) struct Walker<'a> {
opts: &'a VdOptions,
warn: Warnings,
names: std::collections::BTreeSet<String>,
/// The next suffix to try for each base name (names only get taken, so none before it is free).
next_suffix: std::collections::BTreeMap<String, usize>,
}
/// Every coordinate of `p` finite and within [`MAX_COORD`].
@@ -54,7 +56,7 @@ fn normal(b: BlendMode) -> bool {
impl<'a> Walker<'a> {
pub fn new(doc: &'a Document, opts: &'a VdOptions) -> Self {
Self { doc, opts, warn: Warnings::default(), names: Default::default() }
Self { doc, opts, warn: Warnings::default(), names: Default::default(), next_suffix: Default::default() }
}
/// Reports that the export drew nothing at all (named after `layer`).
@@ -78,12 +80,25 @@ impl<'a> Walker<'a> {
fn unique(&mut self, name: &str) -> String {
let base: String = name.chars().take(200).collect();
let base = if base.trim().is_empty() { "layer".to_string() } else { base };
// Bounded: a set of n names leaves one of the first n + 1 candidates free.
let tries = self.names.len().saturating_add(1);
let name = (1..=tries)
.map(|k| if k == 1 { base.clone() } else { format!("{base}_{k}") })
.find(|n| !self.names.contains(n))
.unwrap_or_else(|| format!("{base}_{}", tries.saturating_add(1)));
let mut k = self.next_suffix.get(&base).copied().unwrap_or(1);
let mut stem = base.clone();
// Bounded: a set of n names leaves one of the first n + 1 candidates free. A counter
// that has run out (`usize::MAX`) continues from a longer stem instead of repeating.
let name = loop {
let candidate = if k == 1 { stem.clone() } else { format!("{stem}_{k}") };
if !self.names.contains(&candidate) {
k = k.saturating_add(1);
break candidate;
}
if k == usize::MAX {
stem = candidate;
k = 2;
} else {
k += 1;
}
};
let longer = stem.len() > base.len();
self.next_suffix.insert(base, if longer { usize::MAX } else { k });
self.names.insert(name.clone());
name
}
@@ -404,3 +419,20 @@ impl<'a> Walker<'a> {
}
}
}
#[cfg(test)]
mod unique_tests {
use super::*;
#[test]
fn an_exhausted_suffix_counter_still_finds_a_free_name() {
let (doc, opts) = (Document::new("File"), VdOptions::default());
let mut w = Walker::new(&doc, &opts);
w.next_suffix.insert("a".into(), usize::MAX);
w.names.insert(format!("a_{}", usize::MAX));
let first = w.unique("a");
let second = w.unique("a");
assert_ne!(first, second);
assert!(w.names.len() == 3 && w.names.contains(&first) && w.names.contains(&second));
}
}
+98
View File
@@ -107,6 +107,104 @@ Local test: `python3 -m http.server 8767` inside the folder, then open http://lo
(`allow-popups` lets Help links open in a new tab).
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
## Host mode (`?host=parent`, or the page's own document)
A page can embed FigCraft so that it hands files over instead of downloading them and reports
unsaved changes. Two opt-in transports carry the same messages:
- **`?host=parent`** on the iframe's URL (`https://example.com/figcraft/?host=parent`): a
**same-origin** framing page talks to the app over `postMessage`. Opened outside an iframe, the
URL gives the normal app with downloads; with a parent on another origin there is no host mode
either (messages to another origin are dropped silently, so saves would be lost while the
document looked saved).
- **`data-host="page"`** (the exact value `page`) on the canvas: a page that loads the module into
its own document gets the same behaviour over DOM `CustomEvent`s on the canvas, with no iframe
(see below).
| Message | Direction | Fields |
|---|---|---|
| `figcraft:ready` | app → page | `version`, `appVersion`: the app is running and listening; send a file now |
| `figcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open (a payload over the browser open limit of 512 MB is refused; anything malformed is ignored) |
| `figcraft:save` | app → page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download. The page stores it and reports failures itself. `saveAs` says whether the file becomes the document's file: `false` after Save of the file the page sent, under its own name; `true` for a document file (`.figcraft`, `.json`) under a name the page didn't send — Save As, or a Save of a document the page never sent — after which the next Save carries that name with `false`; `false` for copies and exports that don't become the document (rendered images, SVG, PDF, `.fig` copies, the variables exports) |
| `figcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |
| `figcraft:failed` | app → page | `error`: the app couldn't start (no WebGPU or WebGL2, for example) |
Files reach the page through the app's download service, the path this web build saves by: Save As
(and a Save of a document with no file yet), the `.fig` copy, the export panel and its dialog,
Export frames as PDF, plugin output and the variables export (Variables panel ▸ Export…). In host
mode each arrives as `figcraft:save` instead of a download.
In host mode the app starts with no document (the home screen), so the page sends the file to edit
and guards leaving the page while changes are unsaved. `&author=` on the URL names the user for
comments and board notes.
### Same-document host mode
The host application loads the wasm-bindgen module itself and gives FigCraft one of its own
canvases, so there is no iframe and no `?host=parent`. It opts in with `data-host="page"` on that
canvas — the exact value `page` — and the canvas keeps the id the app looks up
(`id="figcraft_canvas"`):
```html
<canvas id="figcraft_canvas" data-host="page" style="width: 100%; height: 720px; display: block;"></canvas>
```
The page and the app then talk with DOM `CustomEvent`s on the canvas instead of `postMessage`. The
app dispatches the same messages it would post to a parent — `figcraft:ready`, `figcraft:save`,
`figcraft:dirty`, `figcraft:failed` — with the message fields (without the type) as the event's
`detail`, and it opens a document when the page dispatches a `figcraft:open` event with
`detail: { name, bytes }`. Events do not bubble, so listen on the canvas itself (two editors on
one page can't hear each other).
Add the listeners before `init`: the app announces itself with `figcraft:ready` while `init` is
still running, and a file sent in reply would otherwise be missed.
```js
import init from "./figcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app
const canvas = document.getElementById("figcraft_canvas");
canvas.addEventListener("figcraft:ready", async () => {
// The app is listening; hand it a document now, if you have one.
const bytes = await documentBytes(); // an ArrayBuffer or Uint8Array
canvas.dispatchEvent(new CustomEvent("figcraft:open", {
detail: { name: "Poster.figcraft", bytes },
}));
});
// Every file FigCraft would otherwise download arrives here.
canvas.addEventListener("figcraft:save", (event) => {
const { name, saveAs, bytes } = event.detail; // bytes is an ArrayBuffer
// Store the file under `name`; `saveAs` says whether it becomes the document's file.
// Report your own errors.
storeFile(name, bytes, saveAs);
});
canvas.addEventListener("figcraft:dirty", (event) => {
unsaved = event.detail.dirty; // warn before leaving, as you like
});
canvas.addEventListener("figcraft:failed", (event) => {
showError(event.detail.error); // the app couldn't start
});
await init({ module_or_path: "./figcraft-web-<hash>_bg.wasm" });
```
- Saves are not downloads in this mode: the `bytes` in `figcraft:save` hold the file, and the host
decides where it goes.
- `?author=` on the page's URL still names the user for comments and board notes.
- An unframed page that uses `?host=parent` keeps the normal app with downloads.
`data-host="page"` turns this mode on only when `?host=parent` is absent: if `?host=parent` is
present it takes precedence, so a same-origin framed page uses the parent bridge, while an
unframed or cross-origin page gets no bridge and keeps normal downloads.
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `figcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. FigCraft reads bytes, so a host sends `bytes`, not a `File`.
### Trust and limits
- **Who is trusted.** In page host mode the page and FigCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin.
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MB) and a larger file is refused, and a name is cut to 255 characters.
- **The event and message names are the interface.** `figcraft:ready`, `figcraft:open`, `figcraft:save`, `figcraft:dirty` and `figcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
## Renderer selection and fallback flags
FigCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to