mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 18:36:44 -06:00
#647: edit.pasteDesignLayers, named neutrally; design data capped at 256 MB before decoding, data accepts wrapped or unpadded base64
This commit is contained in:
@@ -26,11 +26,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("edit.cut", "Cut", "{ids?}", true, cut),
|
||||
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
|
||||
spec!(
|
||||
"edit.pasteFromFigma",
|
||||
"Paste from Figma",
|
||||
"{html (the clipboard's HTML) | data (base64 of the copied buffer), parent?, x?, y?} layers copied in Figma, read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes",
|
||||
"edit.pasteDesignLayers",
|
||||
"Paste design layers",
|
||||
"{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes",
|
||||
true,
|
||||
paste_from_figma
|
||||
paste_design_layers
|
||||
),
|
||||
spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over),
|
||||
spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace),
|
||||
@@ -544,35 +544,42 @@ fn copied(doc: &Document, ids: &[NodeId]) -> Copied {
|
||||
(nodes, images, bounds)
|
||||
}
|
||||
|
||||
const MAX_FIGMA_CLIP: usize = 512 * 1024 * 1024;
|
||||
/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them.
|
||||
pub const CLIP_OPEN: &str = "(figma)";
|
||||
pub const CLIP_CLOSE: &str = "(/figma)";
|
||||
/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its
|
||||
/// base64 is checked first, so neither the text nor the decoded bytes can pass it.
|
||||
const MAX_CLIP_DATA: usize = 256 * 1024 * 1024;
|
||||
const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4;
|
||||
|
||||
/// Figma puts the copied layers in the HTML as `(figma)<base64>(/figma)`. This gives those bytes.
|
||||
pub fn figma_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
|
||||
/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`].
|
||||
fn clip_base64(text: &str) -> Option<Vec<u8>> {
|
||||
use base64::Engine as _;
|
||||
const OPEN: &str = "(figma)";
|
||||
let start = html.find(OPEN)? + OPEN.len();
|
||||
let len = html.get(start..)?.find("(/figma)")?;
|
||||
let text = html.get(start..start + len)?;
|
||||
if text.len() > MAX_FIGMA_CLIP {
|
||||
if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 {
|
||||
return None;
|
||||
}
|
||||
let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect();
|
||||
let bytes =
|
||||
base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok()?;
|
||||
base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok()
|
||||
}
|
||||
|
||||
/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in
|
||||
/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML
|
||||
/// holds no readable design data (it may just mention the marker).
|
||||
pub fn design_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
|
||||
let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len();
|
||||
let len = html.get(start..)?.find(CLIP_CLOSE)?;
|
||||
let bytes = clip_base64(html.get(start..start + len)?)?;
|
||||
figcraft_format::fig::is_fig(&bytes).then_some(bytes)
|
||||
}
|
||||
|
||||
fn paste_from_figma(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "edit.pasteFromFigma";
|
||||
fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "edit.pasteDesignLayers";
|
||||
let bytes = match (string(p, "html"), string(p, "data")) {
|
||||
(Some(html), _) => figma_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no layers copied in Figma"))?,
|
||||
(None, Some(d)) => {
|
||||
use base64::Engine as _;
|
||||
base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad(CMD, e.to_string()))?
|
||||
}
|
||||
(Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?,
|
||||
(None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?,
|
||||
(None, None) => return Err(bad(CMD, "pass `html` or `data`")),
|
||||
};
|
||||
let other = super::file::load_fig(&bytes, "Pasted from Figma").ok_or_else(|| bad(CMD, "the data is not layers copied in Figma"))??;
|
||||
let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??;
|
||||
let doc = std::sync::Arc::new(other.doc);
|
||||
let tops: Vec<NodeId> = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect();
|
||||
if tops.is_empty() {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Paste of layers copied from another design app: the clipboard's design data (a `.fig` canvas in
|
||||
//! its HTML) read through the `.fig` import and pasted as Paste pastes our own layers.
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::edit::figma_clipboard_buffer;
|
||||
use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer};
|
||||
|
||||
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
||||
|
||||
@@ -20,12 +23,9 @@ fn copied_file() -> Vec<u8> {
|
||||
}
|
||||
|
||||
fn clipboard_html(buffer: &[u8]) -> String {
|
||||
let meta = B64.encode(br#"{"dataType":"scene"}"#);
|
||||
let mut wrapped = B64.encode(buffer);
|
||||
wrapped.insert(8, '\n');
|
||||
format!(
|
||||
r#"<meta charset="utf-8"><span data-metadata="<!--(figmeta){meta}(/figmeta)-->"></span><span data-buffer="<!--(figma){wrapped}(/figma)-->"></span>"#
|
||||
)
|
||||
format!(r#"<meta charset="utf-8"><span data-buffer="<!--{CLIP_OPEN}{wrapped}{CLIP_CLOSE}-->"></span>"#)
|
||||
}
|
||||
|
||||
fn names(s: &Session, ids: &[NodeId]) -> Vec<String> {
|
||||
@@ -36,18 +36,31 @@ fn names(s: &Session, ids: &[NodeId]) -> Vec<String> {
|
||||
#[test]
|
||||
fn buffer_is_found_in_the_html() {
|
||||
let file = copied_file();
|
||||
assert_eq!(figma_clipboard_buffer(&clipboard_html(&file)), Some(file));
|
||||
assert_eq!(figma_clipboard_buffer("<p>plain</p>"), None);
|
||||
assert_eq!(figma_clipboard_buffer("(figma)not base64!(/figma)"), None);
|
||||
assert_eq!(figma_clipboard_buffer(&format!("(figma){}(/figma)", B64.encode(b"hello"))), None);
|
||||
assert_eq!(figma_clipboard_buffer("(figma)ZmlnLQ"), None);
|
||||
assert_eq!(design_clipboard_buffer(&clipboard_html(&file)), Some(file));
|
||||
assert_eq!(design_clipboard_buffer("<p>plain</p>"), None);
|
||||
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}not base64!{CLIP_CLOSE}")), None);
|
||||
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{}{CLIP_CLOSE}", B64.encode(b"hello"))), None);
|
||||
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}ZmlnLQ")), None);
|
||||
// Web text that only mentions the markers holds no design data.
|
||||
assert_eq!(design_clipboard_buffer(&format!("<p>see {CLIP_OPEN} and {CLIP_CLOSE} in the docs</p>")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_design_data_is_refused_before_decoding() {
|
||||
// Just past the cap (256 MB of data, as base64), made of valid base64 characters.
|
||||
let big = "A".repeat((256 * 1024 * 1024usize).div_ceil(3) * 4 + 4);
|
||||
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{big}{CLIP_CLOSE}")), None);
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("edit.pasteDesignLayers", &json!({ "data": big })).unwrap_err();
|
||||
assert!(e.to_string().contains("256 MB"), "{e}");
|
||||
assert!(s.clipboard.nodes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_paste_into_the_page_and_undo() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let r = s.execute("edit.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).unwrap();
|
||||
let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap();
|
||||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect();
|
||||
assert_eq!(names(&s, &ids), ["Card", "Dot"]);
|
||||
assert_eq!(s.selection(), ids);
|
||||
@@ -61,9 +74,12 @@ fn layers_paste_into_the_page_and_undo() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_paste_repeats_the_figma_layers() {
|
||||
fn a_second_paste_repeats_the_copied_layers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(copied_file())})).unwrap();
|
||||
// `data` may be wrapped and unpadded, like the HTML's base64.
|
||||
let mut data = B64.encode(copied_file()).trim_end_matches('=').to_string();
|
||||
data.insert(8, '\n');
|
||||
s.execute("edit.pasteDesignLayers", &json!({ "data": data })).unwrap();
|
||||
let r = s.execute("edit.paste", &json!({})).unwrap();
|
||||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect();
|
||||
assert_eq!(names(&s, &ids), ["Card", "Dot"]);
|
||||
@@ -74,7 +90,7 @@ fn pastes_into_the_selected_frame() {
|
||||
let mut s = Session::new();
|
||||
let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [host]})).unwrap();
|
||||
let r = s.execute("edit.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).unwrap();
|
||||
let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap();
|
||||
let first = params::id_of(&r["ids"][0]).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host));
|
||||
}
|
||||
@@ -83,10 +99,10 @@ fn pastes_into_the_selected_frame() {
|
||||
fn other_content_is_refused_without_changes() {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().nodes().count();
|
||||
assert!(s.execute("edit.pasteFromFigma", &json!({})).is_err());
|
||||
assert!(s.execute("edit.pasteFromFigma", &json!({"html": "<b>hi</b>"})).is_err());
|
||||
assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"PK not a zip")})).is_err());
|
||||
assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err());
|
||||
assert!(s.execute("edit.pasteDesignLayers", &json!({})).is_err());
|
||||
assert!(s.execute("edit.pasteDesignLayers", &json!({"html": "<b>hi</b>"})).is_err());
|
||||
assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"PK not a zip")})).is_err());
|
||||
assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err());
|
||||
assert_eq!(s.doc().unwrap().nodes().count(), before);
|
||||
assert!(s.clipboard.nodes.is_empty());
|
||||
}
|
||||
@@ -1500,6 +1500,8 @@ mod cursor_tests;
|
||||
#[cfg(test)]
|
||||
mod dark_page_text_tests;
|
||||
#[cfg(test)]
|
||||
mod design_paste_tests;
|
||||
#[cfg(test)]
|
||||
mod dev_resources_tests;
|
||||
#[cfg(test)]
|
||||
mod devcompare_tests;
|
||||
@@ -1528,8 +1530,6 @@ mod fig_export_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_import_tests;
|
||||
#[cfg(test)]
|
||||
mod figma_paste_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_guides_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_label_tests;
|
||||
|
||||
Reference in New Issue
Block a user