diff --git a/apps/figcraft/src/apple_events.rs b/apps/figcraft/src/apple_events.rs index 6b792094..41ae5cc3 100644 --- a/apps/figcraft/src/apple_events.rs +++ b/apps/figcraft/src/apple_events.rs @@ -50,7 +50,7 @@ pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) { /// Open `paths` as File › Open does; a file that fails shows its error and the others still open. pub fn open_documents(fig: &mut FigApp, paths: Vec) { for p in paths { - if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() { + if fig.run_user("file.open", json!({"path": p.to_string_lossy()})).is_some() { fig.ui.home = false; } } diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index bd8a4204..8ee5c3aa 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -223,12 +223,25 @@ fn picked(pick: impl Future> + 'static) -> Dial Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) }) } +/// Native Save as filters; other callers keep their existing combined filter. +fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> { + if exts == ["figcraft", "fig"] { + vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])] + } else { + vec![("File", exts)] + } +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { picked(dialog(title).add_filter("Supported files", exts).pick_file()) } fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { - picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file()) + let mut dialog = dialog(title).set_file_name(name); + for (label, extensions) in save_filters(exts) { + dialog = dialog.add_filter(label, extensions); + } + picked(dialog.save_file()) } fn pick_folder(&mut self, title: &str) -> DialogFuture { picked(dialog(title).pick_folder()) @@ -241,6 +254,9 @@ impl Services for Desktop { fn clipboard_get_text(&mut self) -> Option { self.clipboard.as_mut()?.get_text().ok() } + fn clipboard_get_html(&mut self) -> Option { + self.clipboard.as_mut()?.get().html().ok() + } fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { if let Some(c) = self.clipboard.as_mut() { let _ = c.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(rgba) }); @@ -297,3 +313,24 @@ pub fn window_icon() -> Option { let (w, h) = img.dimensions(); Some(egui::IconData { rgba: img.into_raw(), width: w, height: h }) } + +#[cfg(test)] +mod save_filter_tests { + use super::save_filters; + + #[test] + fn native_and_fig_save_filters_are_separate_with_native_first() { + let extensions = ["figcraft", "fig"]; + assert_eq!( + save_filters(&extensions), + vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])] + ); + } + + #[test] + fn other_save_filters_keep_one_combined_group() { + for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] { + assert_eq!(save_filters(extensions), vec![("File", extensions)]); + } + } +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index b294151b..097dd189 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -215,6 +215,10 @@ pub struct ParaList { pub list: ListStyle, /// Nesting level (0 = outermost; plain paragraphs are always 0). pub indent: u8, + /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` + /// (items that go on counting ignore it; other paragraphs are always 0). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub start_offset: u32, } /// Character style. In runs, every field is an override; `None` means "inherit the base". @@ -637,13 +641,13 @@ impl TextData { /// Paragraph `p`'s list settings. pub fn para_list(&self, p: usize) -> ParaList { - self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 }) + self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) } /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). fn fill_paragraphs(&mut self) { let n = self.paragraph_count(); - let fill = ParaList { list: self.list, indent: 0 }; + let fill = ParaList { list: self.list, ..ParaList::default() }; self.paragraphs.resize(n, fill); } @@ -657,17 +661,23 @@ impl TextData { } /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain - /// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with - /// no nesting folds back into `list`. + /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at + /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { let (a, b) = self.para_span(start, end); self.fill_paragraphs(); for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { f(pl); - pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) }; + if pl.list == ListStyle::None { + pl.indent = 0; + } + if pl.list != ListStyle::Numbered { + pl.start_offset = 0; + } + pl.indent = pl.indent.min(MAX_LIST_INDENT); } if let Some(first) = self.paragraphs.first().copied() - && first.indent == 0 + && first == (ParaList { list: first.list, ..ParaList::default() }) && self.paragraphs.iter().all(|p| *p == first) { self.list = first.list; @@ -675,11 +685,12 @@ impl TextData { } } - /// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ. + /// The list style and level shared by the paragraphs chars `start..end` touch (the first + /// one's settings), or None when they differ. Start numbers don't count. pub fn common_para_list(&self, start: usize, end: usize) -> Option { let (a, b) = self.para_span(start, end); let first = self.para_list(a); - (a..=b).all(|p| self.para_list(p) == first).then_some(first) + (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) } /// Link chars `start..end` (all of them when the range is empty or the whole text) to text @@ -782,4 +793,29 @@ mod case_tests { assert_eq!(st.variations.keys().collect::>(), ["wdth"]); assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); } + + #[test] + fn list_starts_load_from_old_files_and_follow_edits() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + // Files written before start numbers load with none, and write the same bytes. + let old = r#"{"list":"numbered","indent":1}"#; + let pl: ParaList = serde_json::from_str(old).unwrap(); + assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); + assert_eq!(serde_json::to_string(&pl).unwrap(), old); + // One numbered paragraph with a start keeps it (it doesn't fold into `list`). + let mut t = TextData { characters: "Next".into(), ..Default::default() }; + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); + // Enter copies it to the new item; different starts don't make the list mixed. + t.replace_range(4, 4, "\nMore"); + assert_eq!(t.para_list(1).start_offset, 1); + t.set_para_lists(5, 5, |p| p.start_offset = 0); + assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); + // Bullets have no number, so no start. + t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); + assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); + // A plain paragraph has none. + t.set_para_lists(0, 0, |p| p.list = P); + assert_eq!(t.para_list(0), ParaList::default()); + } } diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index fd0a0f7e..1165bd60 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -468,9 +468,9 @@ impl VectorNetwork { start_dir(&self.outward(seg, at_a)?) } - /// Whether any point has a stroke cap or join of its own. - pub fn has_point_styles(&self) -> bool { - self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some()) + /// Whether any point has a stroke cap of its own or a join other than the layer's `layer_join`. + pub fn has_point_styles(&self, layer_join: StrokeJoin) -> bool { + self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|j| j != layer_join)) } /// The fill path: every region's loops. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2c767b93..21f09569 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -25,6 +25,13 @@ pub fn specs() -> Vec { spec!("edit.copy", "Copy", "{ids?}", false, copy), 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.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_design_layers + ), spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over), spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace), spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props), @@ -498,7 +505,20 @@ fn bump_name(n: &str) -> String { /// The copied form of the selection. pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; - let doc = &ds.doc; + let (nodes, images, bounds) = copied(&ds.doc, ids); + let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); + s.clipboard.source = Some(source); + s.clipboard.source_path = source_path; + s.clipboard.source_doc = uid; + s.clipboard.nodes = nodes; + s.clipboard.images = images; + s.clipboard.bounds = bounds; + Ok(()) +} + +type Copied = (Vec<(NodeId, Vec)>, BTreeMap, Option); + +fn copied(doc: &Document, ids: &[NodeId]) -> Copied { let ids = z_sorted(doc, &roots(doc, ids)); let mut nodes = Vec::new(); let mut images = BTreeMap::new(); @@ -521,16 +541,59 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { nodes.push((*i, sub)); } let bounds = doc.bounds_of(&ids); - let uid = ds.uid; - let source = Some(ds.doc.clone()); - let source_path = ds.path.clone(); - s.clipboard.source = source; - s.clipboard.source_path = source_path; - s.clipboard.nodes = nodes; - s.clipboard.images = images; - s.clipboard.bounds = bounds; - s.clipboard.source_doc = uid; - Ok(()) + (nodes, images, bounds) +} + +/// 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; + +/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. +fn clip_base64(text: &str) -> Option> { + use base64::Engine as _; + 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(); + 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> { + 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_design_layers(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.pasteDesignLayers"; + let bytes = match (string(p, "html"), string(p, "data")) { + (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 layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; + let doc = std::sync::Arc::new(other.doc); + let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); + if tops.is_empty() { + return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); + } + let (nodes, images, bounds) = copied(&doc, &tops); + let props = s.clipboard.props.take(); + // No open file has id 0. So paste treats this copy as a copy from a different file. + s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None }; + let mut out = paste(s, p)?; + if !other.issues.is_empty() { + out["warnings"] = json!(other.issues); + } + Ok(out) } fn copy(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 28124b7c..6ba1ba6c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { spec!( "file.open", "Open", - "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`", + "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", false, open ), @@ -41,8 +41,20 @@ pub fn specs() -> Vec { false, open_recent ), - spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save), - spec!("file.saveAs", "Save as", "{path}", false, save_as), + spec!( + "file.save", + "Save", + "{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + false, + save + ), + spec!( + "file.saveAs", + "Save as", + "{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + false, + save_as + ), spec!( "file.export", "Export", @@ -74,7 +86,7 @@ pub fn specs() -> Vec { spec!( "file.import", "Import", - "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`)", + "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)", true, import ), @@ -461,6 +473,11 @@ fn file_stem(path: &str) -> String { std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string() } +/// The counts the import report shows for a freshly loaded document. +fn import_summary(doc: &Document) -> Value { + json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()}) +} + /// Load a document from any supported file (REST API file JSON images come from an `images` /// folder next to the file). pub fn load(path: &str) -> Result<(Document, Vec)> { @@ -506,10 +523,8 @@ fn load_with( Ok((doc.into(), false)) } _ => { - if let Some(r) = figcraft_format::fig::try_import(bytes, &file_stem(name_hint)) { - let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; - link_instances(&mut doc); - return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); + if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); } if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; @@ -525,6 +540,15 @@ fn load_with( } } +/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. +pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { + let r = figcraft_format::fig::try_import(bytes, name)?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + link_instances(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + /// Link imported instances to their mains (layer sources, overrides found by comparing values), /// innermost first so outer instances settle last. fn link_instances(doc: &mut Document) { @@ -678,9 +702,10 @@ fn open(s: &mut Session, p: &Value) -> Result { more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]), }); } + let summary = import_summary(&loaded.doc); s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches)); s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active}); + let mut out = json!({"index": s.active, "summary": summary}); out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); Ok(out) } @@ -693,9 +718,10 @@ fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; let warnings = rest || ext(&name) == "svg"; + let summary = import_summary(&loaded.doc); s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active}); + let mut out = json!({"index": s.active, "summary": summary}); out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); Ok(out) } @@ -796,8 +822,20 @@ fn color_profile(s: &mut Session, p: &Value) -> Result { Ok(json!({"profile": profile.id(), "changed": changed})) } -/// Save the file of the active tab (the main file for a branch tab, with its branches). +/// Native saves act on the current document; a `.fig` copy exports the version shown, and a +/// pending Save As choice must leave it shown until its destination is known. +pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { + let path = + string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); + path.is_some_and(|path| ext(&path) != "fig") +} + +/// Save the file of the active tab (the main file for a branch tab, with its branches), or export +/// the active document as an experimental `.fig` copy without changing its native save state. fn save_to(s: &mut Session, path: String) -> Result { + if ext(&path) == "fig" { + return export_fig(s, &json!({"path": path})); + } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; let bytes = file_bytes(ds)?; @@ -816,6 +854,11 @@ fn save(s: &mut Session, p: &Value) -> Result { return save_to(s, path); } match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { + // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. + Some(path) if ext(&path) == "fig" => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) + } Some(path) => save_to(s, path), None => { s.request_ui("dialog.saveAs", json!({})); @@ -1236,8 +1279,9 @@ fn import(s: &mut Session, p: &Value) -> Result { }; let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None }; let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?; + let summary = import_summary(&other.doc); let pages = append_pages(s.doc_mut()?, &other.doc)?; - let mut out = json!({"pages": pages}); + let mut out = json!({"pages": pages, "summary": summary}); if rest { out["warnings"] = json!(other.issues); } diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 5643c058..f5dd3e0d 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -610,6 +610,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { for i in req_targets("vector.offset", s, p)? { let doc = s.doc_mut()?; let n = doc.get(i)?.clone(); + // A vector can't hold children: leave containers (frames, groups, instances) alone. + if !n.children.is_empty() { + continue; + } let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue }; let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() }; let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props); diff --git a/crates/engine/src/design_paste_tests.rs b/crates/engine/src/design_paste_tests.rs new file mode 100644 index 00000000..89de80ef --- /dev/null +++ b/crates/engine/src/design_paste_tests.rs @@ -0,0 +1,108 @@ +//! 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::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer}; + +const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; + +fn id(v: &Value) -> NodeId { + params::id_of(&v["id"]).unwrap() +} + +fn copied_file() -> Vec { + let mut s = Session::new(); + let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 40, "height": 20, "parent": frame, "name": "Badge"})).unwrap(); + s.execute("node.create", &json!({"type": "ellipse", "x": 300, "y": 0, "width": 50, "height": 50, "name": "Dot"})).unwrap(); + let r = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + B64.decode(r["data"].as_str().unwrap()).unwrap() +} + +fn clipboard_html(buffer: &[u8]) -> String { + let mut wrapped = B64.encode(buffer); + wrapped.insert(8, '\n'); + format!(r#""#) +} + +fn names(s: &Session, ids: &[NodeId]) -> Vec { + let doc = s.doc().unwrap(); + ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect() +} + +#[test] +fn buffer_is_found_in_the_html() { + let file = copied_file(); + assert_eq!(design_clipboard_buffer(&clipboard_html(&file)), Some(file)); + assert_eq!(design_clipboard_buffer("

plain

"), 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!("

see {CLIP_OPEN} and {CLIP_CLOSE} in the docs

")), 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.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let ids: Vec = 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); + let doc = s.doc().unwrap(); + assert!(ids.iter().all(|i| doc.node(*i).unwrap().parent == Some(page))); + let card = doc.node(ids[0]).unwrap(); + assert_eq!(names(&s, &card.children), ["Badge"]); + assert_eq!((card.width, card.height), (200.0, 100.0)); + assert!(s.undo().unwrap()); + assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i))); +} + +#[test] +fn a_second_paste_repeats_the_copied_layers() { + let mut s = Session::new(); + // `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 = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); + assert_eq!(names(&s, &ids), ["Card", "Dot"]); +} + +#[test] +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.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)); +} + +#[test] +fn other_content_is_refused_without_changes() { + let mut s = Session::new(); + let before = s.doc().unwrap().nodes().count(); + assert!(s.execute("edit.pasteDesignLayers", &json!({})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"html": "hi"})).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()); +} diff --git a/crates/engine/src/huge_stroke_tests.rs b/crates/engine/src/huge_stroke_tests.rs new file mode 100644 index 00000000..7efabb48 --- /dev/null +++ b/crates/engine/src/huge_stroke_tests.rs @@ -0,0 +1,28 @@ +//! An absurd stroke weight must not take the renderer down with it: saving (thumbnail) and exporting still work. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::NodeId; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +#[test] +fn huge_stroke_weight_with_round_caps_still_saves_and_exports() { + for weight in [1e6, 1e7, 1e10, 1e30] { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let v = id(s.execute("vector.create", &json!({"parent": f, "path": "M0 0.8333 L7.5 6.25 L15 0.8333"})).unwrap()); + s.execute("node.set", &json!({"id": v, "props": {"stroke": "#ff0000", "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round"}})) + .unwrap(); + let path = std::env::temp_dir().join(format!("figcraft-huge-stroke-{}-{weight}.figcraft", std::process::id())); + let saved = s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + assert!(saved.is_ok(), "saveAs at weight {weight}: {:?}", saved.err()); + let exported = s.execute("file.export", &json!({"ids": [f], "format": "png"})); + assert!(exported.is_ok(), "export at weight {weight}: {:?}", exported.err()); + } +} diff --git a/crates/engine/src/import_journal_tests.rs b/crates/engine/src/import_journal_tests.rs new file mode 100644 index 00000000..f8fa84ef --- /dev/null +++ b/crates/engine/src/import_journal_tests.rs @@ -0,0 +1,51 @@ +//! Imported file bytes already live in the document: the command log keeps no second copy. + +use base64::Engine as _; +use serde_json::{Value, json}; + +use crate::Session; + +const SVG: &str = r##""##; + +fn payload() -> String { + base64::engine::general_purpose::STANDARD.encode(SVG) +} + +fn holds_payload(journal: &[(String, Value)]) -> bool { + let payload = payload(); + journal.iter().any(|(_, params)| params.to_string().contains(&payload)) +} + +#[test] +fn byte_imports_keep_no_payload_in_the_journal() { + let mut s = Session::new(); + let before = s.doc().unwrap().node_count(); + s.execute("file.import", &json!({"data": payload(), "name": "pasted.svg"})).unwrap(); + assert!(!holds_payload(&s.journal), "the journal must not retain the Base64 file"); + // Not journaling is no change to the edit itself: still one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().node_count(), before); +} + +#[test] +fn batch_wrappers_do_not_retain_import_payloads() { + let mut s = Session::new(); + let nested = + json!({"command": "command.batch", "params": {"commands": [{"command": "file.import", "params": {"data": payload(), "name": "a.svg"}}]}}); + s.execute("command.batch", &json!({"commands": [nested]})).unwrap(); + assert!(!holds_payload(&s.journal)); +} + +#[test] +fn path_imports_and_ordinary_edits_are_still_journaled() { + let dir = std::env::temp_dir().join(format!("figcraft-import-journal-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("kit.svg"); + std::fs::write(&path, SVG).unwrap(); + let mut s = Session::new(); + s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let ids: Vec<_> = s.journal.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["file.import", "shape.rectangle"]); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 1f461c60..88bc015a 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -191,7 +191,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); } - // Lists: one style for the layer, or each paragraph's type and nesting level. + // Lists: one style for the layer, or each paragraph's type, nesting level and start. if t.paragraphs.is_empty() { if t.list != figcraft_doc::ListStyle::None { o.insert("listStyle".into(), json!(t.list)); @@ -199,6 +199,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { } else { o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); + if t.paragraphs.iter().any(|p| p.start_offset > 0) { + o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); + } } if t.list_spacing != 0.0 { o.insert("listSpacing".into(), json!(t.list_spacing)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6e0e8b12..acb087e0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -390,7 +390,8 @@ pub struct VersionPreview { /// Why edits are refused while a version is shown. pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore it or go back to the current version to edit"; -/// Commands that first end a version preview: they act on the document itself. +/// Commands that first end a version preview: they act on the document itself. Saving does so +/// only with a native destination; export copies and pending format choices keep the preview. const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -723,7 +724,8 @@ pub struct Session { /// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no /// theme, so what scripts make is the same everywhere. pub ui_dark: Option, - /// Command log (id + params) for actions/automation. + /// Command log (id + params) for actions/automation. File imports that carry the file bytes + /// (`file.import` with `data`) are not logged, so the log never holds a copy of a pasted file. pub journal: Vec<(String, Value)>, /// Recently used colours. pub recent_colors: Vec, @@ -748,6 +750,19 @@ impl Default for Session { } } +/// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`. +fn command_carries_import_bytes(id: &str, params: &Value) -> bool { + match id { + "file.import" => params::get(params, "data").is_some(), + "command.batch" => params.get("commands").and_then(Value::as_array).is_some_and(|commands| { + commands.iter().any(|c| { + c.get("command").and_then(Value::as_str).zip(c.get("params")).is_some_and(|(id, params)| command_carries_import_bytes(id, params)) + }) + }), + _ => false, + } +} + impl Session { /// A session with one new document. pub fn new() -> Self { @@ -872,6 +887,7 @@ impl Session { // Every other command acts on the document itself, not on a live text preview. textpreview::end(self); if ENDS_PREVIEW.contains(&id) + && (!matches!(id, "file.save" | "file.saveAs") || cmd::file::save_ends_preview(self, id, params)) && let Ok(ds) = self.ds_mut() { ds.end_preview(); @@ -887,9 +903,13 @@ impl Session { if spec.history && id != "edit.repeat" { self.last_command = Some((id.to_string(), params.clone())); } - self.journal.push((id.to_string(), params.clone())); - if self.journal.len() > 5000 { - self.journal.drain(..1000); + // Imported bytes already live in the document: a Base64 copy in the log would stay + // for the whole session. + if !command_carries_import_bytes(id, params) { + self.journal.push((id.to_string(), params.clone())); + if self.journal.len() > 5000 { + self.journal.drain(..1000); + } } } Err(_) => { @@ -1502,6 +1522,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; @@ -1550,8 +1572,12 @@ mod history_behaviour_tests; #[cfg(test)] mod html_codegen_tests; #[cfg(test)] +mod huge_stroke_tests; +#[cfg(test)] mod import_export_tests; #[cfg(test)] +mod import_journal_tests; +#[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] mod interaction_rollback_tests; @@ -1644,6 +1670,8 @@ mod rotated_select_tests; #[cfg(test)] mod rotation_origin_tests; #[cfg(test)] +mod save_format_tests; +#[cfg(test)] mod scale_tool_tests; #[cfg(test)] mod section_contents_tests; @@ -1712,6 +1740,8 @@ mod vector_behaviour_tests; #[cfg(test)] mod vector_multi_behaviour_tests; #[cfg(test)] +mod vector_offset_tests; +#[cfg(test)] mod version_preview_tests; #[cfg(test)] mod version_tests; diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs new file mode 100644 index 00000000..4473ef7c --- /dev/null +++ b/crates/engine/src/save_format_tests.rs @@ -0,0 +1,366 @@ +//! Save As can write an experimental `.fig` copy without replacing native save state. + +use std::path::PathBuf; +use std::sync::Arc; + +use figcraft_format::Version; +use serde_json::{Value, json}; + +use crate::{DocState, EngineError, Session}; + +struct SaveFiles { + dir: PathBuf, + files: Vec, +} + +impl SaveFiles { + fn new() -> Self { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + Self { dir, files: Vec::new() } + } + + fn path(&mut self, name: &str) -> PathBuf { + let path = self.dir.join(name); + self.files.push(path.clone()); + path + } +} + +impl Drop for SaveFiles { + fn drop(&mut self) { + for path in &self.files { + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(path); + } + let _ = std::fs::remove_dir(&self.dir); + } +} + +fn rect(s: &mut Session, name: &str) { + s.execute("shape.rectangle", &json!({"name": name, "x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); +} + +fn assert_versions_unchanged(after: &[Version], before: &[Version]) { + assert_eq!(after.len(), before.len()); + for (after, before) in after.iter().zip(before) { + assert_eq!( + (&after.id, &after.name, &after.description, after.time, after.auto), + (&before.id, &before.name, &before.description, before.time, before.auto) + ); + assert_eq!(*after.document().unwrap(), *before.document().unwrap()); + } +} + +fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String]) { + let after = s.ds().unwrap(); + assert_eq!(after.path, before.path); + assert_eq!(after.is_dirty(), before.is_dirty()); + assert_eq!(after.versions_changed, before.versions_changed); + assert!(Arc::ptr_eq(&after.doc, &before.doc)); + assert!(Arc::ptr_eq(&after.saved_doc, &before.saved_doc)); + assert_eq!( + after.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)), + before.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)) + ); + if let (Some(after), Some(before)) = (&after.preview, &before.preview) { + assert!(Arc::ptr_eq(&after.doc, &before.doc)); + } + assert_eq!((&after.selection, after.page), (&before.selection, before.page)); + assert_versions_unchanged(&after.versions, &before.versions); + assert_eq!(after.branches.len(), before.branches.len()); + for (after, before) in after.branches.iter().zip(&before.branches) { + assert_eq!( + (&after.id, &after.name, after.time, after.client, after.state), + (&before.id, &before.name, before.time, before.client, before.state) + ); + assert_eq!(*after.base, *before.base); + assert_eq!(*after.head, *before.head); + assert_versions_unchanged(&after.versions, &before.versions); + } + for (after, before) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] { + assert_eq!(after.len(), before.len()); + for (after, before) in after.iter().zip(before) { + assert_eq!((&after.label, &after.selection, after.page), (&before.label, &before.selection, before.page)); + assert!(Arc::ptr_eq(&after.doc, &before.doc)); + } + } + assert_eq!(s.prefs.recent_files, recent); +} + +fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document { + let bytes = std::fs::read(path).unwrap(); + assert_eq!(result["format"], "fig"); + assert_eq!(result["bytes"], bytes.len()); + assert!(result["warnings"].is_array()); + assert!(figcraft_format::load_bytes(&bytes).is_err(), "a .fig copy must not contain native FigCraft data"); + figcraft_format::fig::import(&bytes, "Copy").unwrap().0 +} + +#[test] +fn fig_save_as_keeps_native_path_dirty_state_and_history() { + let mut files = SaveFiles::new(); + let native = files.path("design.figcraft"); + let copy = files.path("copy.fig"); + let mut s = Session::new(); + rect(&mut s, "Saved rectangle"); + s.execute("file.saveAs", &json!({"path": native.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&native).unwrap(); + rect(&mut s, "New rectangle"); + s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Copy warning"})).unwrap(); + s.execute("file.saveVersion", &json!({"name": "Before copy"})).unwrap(); + rect(&mut s, "Undone rectangle"); + s.undo().unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + assert!(before.is_dirty()); + assert!(!before.history.redo.is_empty()); + + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + let imported = read_fig(©, &result); + assert!(imported.nodes().any(|n| n.name == "New rectangle")); + assert!(!imported.nodes().any(|n| n.name == "Undone rectangle")); + assert!(result["warnings"].as_array().unwrap().iter().any(|w| w == "comments were left out")); + assert_save_state_unchanged(&s, &before, &recent); + assert_eq!(std::fs::read(&native).unwrap(), saved); + + s.execute("file.save", &json!({})).unwrap(); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(persisted.doc.nodes().any(|n| n.name == "New rectangle")); + assert_eq!(persisted.versions[0].name, "Before copy"); + assert_eq!(std::fs::read(©).unwrap().len(), result["bytes"].as_u64().unwrap() as usize); +} + +#[test] +fn unsaved_fig_copy_remains_unsaved() { + let mut files = SaveFiles::new(); + let copy = files.path("unsaved.fig"); + let mut s = Session::new(); + rect(&mut s, "Unsaved rectangle"); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(read_fig(©, &result).nodes().any(|n| n.name == "Unsaved rectangle")); + assert_save_state_unchanged(&s, &before, &recent); + assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs"); +} + +#[test] +fn empty_document_fig_copy_keeps_clean_state() { + let mut files = SaveFiles::new(); + let copy = files.path("empty.fig"); + let mut s = Session::new(); + let before = s.ds().unwrap().clone(); + assert!(!before.is_dirty()); + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + let imported = read_fig(©, &result); + assert_eq!(imported.pages.len(), 1); + assert_eq!(imported.node_count(), 1); + assert_save_state_unchanged(&s, &before, &[]); +} + +#[test] +fn fig_save_commands_accept_uppercase_extension() { + let mut files = SaveFiles::new(); + for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() { + let copy = files.path(&format!("copy-{index}.FIG")); + let mut s = Session::new(); + rect(&mut s, command); + let before = s.ds().unwrap().clone(); + let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(read_fig(©, &result).nodes().any(|n| n.name == command)); + assert_save_state_unchanged(&s, &before, &[]); + } +} + +#[test] +fn fig_copy_from_branch_exports_active_document_without_saving_main() { + let mut files = SaveFiles::new(); + let native = files.path("main.figcraft"); + let copy = files.path("branch.fig"); + let mut s = Session::new(); + rect(&mut s, "Main rectangle"); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&native).unwrap(); + s.execute("branch.create", &json!({"name": "Explore"})).unwrap(); + rect(&mut s, "Branch rectangle"); + s.execute("file.saveVersion", &json!({"name": "Branch version"})).unwrap(); + // Branch edits normally sync to main before commands; capture that state before exporting. + s.execute("branch.list", &json!({})).unwrap(); + let branch = s.ds().unwrap().clone(); + let main = s.docs[0].clone(); + let recent = s.prefs.recent_files.clone(); + + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(read_fig(©, &result).nodes().any(|n| n.name == "Branch rectangle")); + assert_save_state_unchanged(&s, &branch, &recent); + assert_eq!(s.docs[0].path, main.path); + assert_eq!(s.docs[0].is_dirty(), main.is_dirty()); + assert!(Arc::ptr_eq(&s.docs[0].saved_doc, &main.saved_doc)); + assert!(Arc::ptr_eq(&s.docs[0].doc, &main.doc)); + assert_eq!(std::fs::read(&native).unwrap(), saved); + + s.execute("file.save", &json!({})).unwrap(); + let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle")); + assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle")); + assert_eq!(persisted.branches[0].versions[0].name, "Branch version"); + assert!(!s.ds().unwrap().is_dirty()); + assert!(!s.docs[0].is_dirty()); +} + +#[test] +fn fig_copy_write_failure_keeps_save_state() { + let mut files = SaveFiles::new(); + let blocked = files.path("blocked.fig"); + std::fs::create_dir(&blocked).unwrap(); + let missing = files.path("missing/copy.fig"); + let mut s = Session::new(); + rect(&mut s, "Kept rectangle"); + s.execute("file.saveVersion", &json!({"name": "Kept version"})).unwrap(); + rect(&mut s, "Later rectangle"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + for command in ["file.save", "file.saveAs"] { + for path in [&blocked, &missing] { + assert!(s.execute(command, &json!({"path": path.to_string_lossy()})).is_err()); + assert_save_state_unchanged(&s, &before, &[]); + assert!(!PathBuf::from(format!("{}.tmp-figcraft", path.display())).exists()); + } + } +} + +#[test] +fn save_as_without_extension_stays_native() { + let mut files = SaveFiles::new(); + let requested = files.dir.join("native"); + let native = files.path("native.figcraft"); + let mut s = Session::new(); + rect(&mut s, "Native rectangle"); + let result = s.execute("file.saveAs", &json!({"path": requested.to_string_lossy()})).unwrap(); + assert_eq!(result["path"], native.to_string_lossy().as_ref()); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + assert!(!s.ds().unwrap().is_dirty()); + assert!(!requested.exists()); + let loaded = figcraft_format::load_bytes(&std::fs::read(native).unwrap()).unwrap(); + assert!(loaded.0.nodes().any(|n| n.name == "Native rectangle")); +} + +#[test] +fn fig_copy_of_version_preview_keeps_visible_version_and_save_state() { + let mut files = SaveFiles::new(); + for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() { + let native = files.path(&format!("current-{index}.figcraft")); + let copy = files.path(&format!("version-{index}.FIG")); + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&native).unwrap(); + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + assert!(before.preview.is_some()); + assert!(before.is_dirty()); + + let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap(); + let imported = read_fig(©, &result); + assert!(imported.nodes().any(|n| n.name == "A")); + assert!(!imported.nodes().any(|n| n.name == "B"), "the copy must export the version shown"); + assert_save_state_unchanged(&s, &before, &recent); + assert_eq!(std::fs::read(&native).unwrap(), saved); + s.execute("version.exitPreview", &json!({})).unwrap(); + assert!(s.doc().unwrap().nodes().any(|n| n.name == "B")); + } +} + +#[test] +fn pending_save_choice_keeps_version_preview() { + let mut files = SaveFiles::new(); + for (index, (command, native_path)) in [("file.saveAs", false), ("file.saveAs", true), ("file.save", false)].into_iter().enumerate() { + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + if native_path { + let native = files.path(&format!("saved-{index}.figcraft")); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + } + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + assert!(matches!(s.execute(command, &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs"); + assert_save_state_unchanged(&s, &before, &recent); + } +} + +#[test] +fn native_save_from_version_preview_writes_current_document() { + let mut files = SaveFiles::new(); + for (index, (command, explicit)) in [("file.save", false), ("file.save", true), ("file.saveAs", true)].into_iter().enumerate() { + let native = files.path(&format!("native-version-{index}.figcraft")); + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let undo = s.ds().unwrap().history.undo.len(); + let params = if explicit { json!({"path": native.to_string_lossy()}) } else { json!({}) }; + s.execute(command, ¶ms).unwrap(); + assert!(s.ds().unwrap().preview.is_none()); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().history.undo.len(), undo); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(loaded.doc.nodes().any(|n| n.name == "A")); + assert!(loaded.doc.nodes().any(|n| n.name == "B")); + assert!(!loaded.versions[0].document().unwrap().nodes().any(|n| n.name == "B")); + } +} + +#[test] +fn implicit_save_protects_legacy_fig_destination() { + let mut files = SaveFiles::new(); + for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() { + let legacy = files.path(&format!("legacy-{index}.FIG")); + let native = files.path(&format!("corrected-{index}.figcraft")); + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + // Earlier builds could write native data under .fig; keep those bytes safe on plain Save. + let sentinel = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap(); + std::fs::write(&legacy, &sentinel).unwrap(); + s.ds_mut().unwrap().path = Some(legacy.to_string_lossy().into_owned()); + s.ds_mut().unwrap().mark_saved(); + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + + assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs"); + assert_save_state_unchanged(&s, &before, &recent); + assert_eq!(std::fs::read(&legacy).unwrap(), sentinel); + + s.execute(command, &json!({"path": native.to_string_lossy()})).unwrap(); + assert!(s.ds().unwrap().preview.is_none()); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(loaded.doc.nodes().any(|n| n.name == "A")); + assert!(loaded.doc.nodes().any(|n| n.name == "B")); + assert_eq!(loaded.versions[0].name, "A only"); + rect(&mut s, "C"); + s.execute("file.save", &json!({})).unwrap(); + let loaded = figcraft_format::load_bytes(&std::fs::read(&native).unwrap()).unwrap(); + assert!(loaded.0.nodes().any(|n| n.name == "C")); + assert_eq!(std::fs::read(&legacy).unwrap(), sentinel); + } +} diff --git a/crates/engine/src/text_tail_tests.rs b/crates/engine/src/text_tail_tests.rs index b951bfc7..81d05ed2 100644 --- a/crates/engine/src/text_tail_tests.rs +++ b/crates/engine/src/text_tail_tests.rs @@ -57,6 +57,24 @@ fn list_buttons_act_on_the_selected_paragraphs() { assert_eq!(data(&s, t).list, P, "toggled off"); } +#[test] +fn a_list_with_a_start_number_toggles_off() { + // An imported list that starts at 2 has the start on its first item only; it is still one + // numbered list, so the button turns it back to plain (#686). + let mut s = Session::new(); + let t = text(&mut s, "two\nthree\nfour"); + if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { + let n = |start_offset| ParaList { list: N, indent: 0, start_offset }; + td.paragraphs = vec![n(1), n(0), n(0)]; + } + let j = crate::inspect::node_json(s.doc().unwrap(), t, 0); + assert_eq!(j["listStartOffsets"], json!([1, 0, 0]), "inspect reports the start"); + s.execute("text.numberList", &json!({"ids": [t]})).unwrap(); + assert_eq!(lists(&s, t), [(P, 0); 3]); + assert_eq!(data(&s, t).para_list(0).start_offset, 0, "a plain paragraph has no start"); + assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("listStartOffsets").is_none()); +} + #[test] fn typed_list_starts() { // Typing "- " or "* " at the start of a paragraph starts a bulleted list, "1. " a numbered one @@ -117,7 +135,7 @@ fn list_keys_at_list_items() { for (chars, before, caret, k, shift, after, want, why) in cases { let mut s = Session::new(); let t = text(&mut s, chars); - let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent }).collect(); + let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect(); if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { td.paragraphs = ps; } @@ -141,7 +159,7 @@ fn paragraph_lists_follow_edits() { // A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy // the one they split from; joined paragraphs keep the first one's. let mut t = TextData::new("ab\ncd\nef"); - t.paragraphs = vec![ParaList { list: B, indent: 1 }, ParaList::default(), ParaList { list: N, indent: 0 }]; + t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }]; let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::>(); t.replace_range(1, 1, "x\ny"); assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it"); @@ -156,7 +174,7 @@ fn paragraph_lists_follow_edits() { (seed >> 33) as usize % n.max(1) }; let mut t = TextData::new("a\nb\nc\nd"); - t.paragraphs = vec![ParaList { list: B, indent: 2 }; 4]; + t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4]; for _ in 0..500 { let n = t.char_count(); let (a, b) = (rnd(n + 2), rnd(n + 2)); diff --git a/crates/engine/src/vector_offset_tests.rs b/crates/engine/src/vector_offset_tests.rs new file mode 100644 index 00000000..e47158f2 --- /dev/null +++ b/crates/engine/src/vector_offset_tests.rs @@ -0,0 +1,49 @@ +//! `vector.offset` on layers with children: containers are left alone. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::{NodeId, NodeKind}; + +fn id(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +/// No node that holds children may be a non-container. +fn assert_children_only_in_containers(s: &Session, ids: &[NodeId]) { + let doc = s.doc().unwrap(); + for &i in ids { + let n = doc.node(i).unwrap(); + assert!(n.kind.is_container() || n.kind.is_frame_like() || n.children.is_empty(), "a non-container layer now holds children"); + } +} + +#[test] +fn offset_path_leaves_containers_with_children_alone() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let inner = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 220, "y": 0, "width": 10, "height": 10})).unwrap()); + let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap()); + for target in [f, g] { + s.execute("vector.offset", &json!({"ids": [target], "offset": 5})).unwrap(); + assert_children_only_in_containers(&s, &[f, g, inner, a, b]); + } + let doc = s.doc().unwrap(); + assert!(doc.node(f).unwrap().kind.is_container()); + assert_eq!(doc.node(f).unwrap().children, vec![inner]); + assert!(doc.node(g).unwrap().kind.is_container()); + assert_eq!(doc.node(g).unwrap().children.len(), 2); +} + +#[test] +fn offset_path_still_converts_a_plain_rectangle() { + let mut s = Session::new(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + s.execute("vector.offset", &json!({"ids": [r], "offset": 5})).unwrap(); + let n = s.doc().unwrap().node(r).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Vector { .. })); + assert!(n.width > 10.0); +} diff --git a/crates/format/src/fig/connector_label_tests.rs b/crates/format/src/fig/connector_label_tests.rs index 2aa6d893..de0c4f2f 100644 --- a/crates/format/src/fig/connector_label_tests.rs +++ b/crates/format/src/fig/connector_label_tests.rs @@ -102,7 +102,8 @@ fn synthetic_connector(version: f32, include_text: bool, text_x: f32) -> Kv<'sta } fn map_synthetic(version: f32, include_text: bool, text_x: f32) -> (serde_json::Value, Vec) { - map::to_rest(&synthetic_connector(version, include_text, text_x), "synthetic") + let (rest, warnings, _) = map::to_rest(&synthetic_connector(version, include_text, text_x), "synthetic", crate::rest::MAX_NODES); + (rest, warnings) } fn find_node<'a>(doc: &'a figcraft_doc::Document, name: &str) -> &'a figcraft_doc::Node { @@ -176,7 +177,7 @@ fn synthetic_connector_unsupported_version_uses_legacy_warning() { fn synthetic_connector_malformed_label_bounds_or_transform_keep_vector() { let mut malformed_bounds = synthetic_connector(1.0, true, 8.5); set_override_field(&mut malformed_bounds, "derivedImmutableFrameData", 1, "size", point(f32::NAN, 24.0)); - let (rest, warnings) = map::to_rest(&malformed_bounds, "synthetic"); + let (rest, warnings, _) = map::to_rest(&malformed_bounds, "synthetic", crate::rest::MAX_NODES); let connector = &rest["document"]["children"][0]["children"][0]; assert_eq!(connector["children"].as_array().unwrap().len(), 1); assert_eq!(connector["children"][0]["type"], "VECTOR"); @@ -184,7 +185,7 @@ fn synthetic_connector_malformed_label_bounds_or_transform_keep_vector() { let mut malformed_transform = synthetic_connector(1.0, true, 8.5); set_override_field(&mut malformed_transform, "derivedImmutableFrameData", 1, "transform", transform(f32::NAN, 3.0)); - let (rest, warnings) = map::to_rest(&malformed_transform, "synthetic"); + let (rest, warnings, _) = map::to_rest(&malformed_transform, "synthetic", crate::rest::MAX_NODES); let connector = &rest["document"]["children"][0]["children"][0]; assert_eq!(connector["children"].as_array().unwrap().len(), 1); assert_eq!(connector["children"][0]["type"], "VECTOR"); @@ -195,7 +196,7 @@ fn synthetic_connector_malformed_label_bounds_or_transform_keep_vector() { fn synthetic_connector_authored_hidden_background_is_retained() { let mut message = synthetic_connector(1.0, true, 8.5); set_override_field(&mut message, "nodeGenerationData", 2, "fillPaints", Kv::Array(vec![paint()])); - let (rest, _) = map::to_rest(&message, "synthetic"); + let (rest, _, _) = map::to_rest(&message, "synthetic", crate::rest::MAX_NODES); let connector = &rest["document"]["children"][0]["children"][0]; let background = connector["children"].as_array().unwrap().iter().find(|child| child["type"] == "RECTANGLE").expect("authored background"); assert_eq!(background["visible"], false); diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index c74459c6..de1705ea 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1564,6 +1564,20 @@ impl<'d> Exporter<'d> { data.extend([("characterStyleIDs", arr(ids)), ("styleOverrideTable", arr(entries))]); } let lists: Vec = (0..td.paragraph_count()).map(|p| td.para_list(p).list).collect(); + let first = |i: usize, l: &ListStyle| *l != ListStyle::None && (i == 0 || lists.get(i - 1) != Some(l)); + // Where each line's numbered list starts counting, less one: its first item's start, on + // every line of the list. + let mut run = 0; + let starts: Vec = lists + .iter() + .enumerate() + .map(|(i, l)| { + if first(i, l) { + run = td.para_list(i).start_offset; + } + if *l == ListStyle::Numbered { run } else { 0 } + }) + .collect(); // One entry per paragraph, each with every field a reader expects. let line = |(i, l): (usize, &ListStyle)| { let ty = match l { @@ -1572,14 +1586,15 @@ impl<'d> Exporter<'d> { ListStyle::Bulleted => "UNORDERED_LIST", }; let listed = *l != ListStyle::None; - let first = listed && (i == 0 || lists.get(i - 1) != Some(l)); + let begins = first(i, l); + let start = starts.get(i).copied().unwrap_or(0); o(vec![ ("lineType", e(ty)), ("styleId", int(0)), ("indentationLevel", int(i64::from(listed))), ("sourceDirectionality", e("AUTO")), - ("listStartOffset", int(0)), - ("isFirstLineOfList", b(first)), + ("listStartOffset", int(i64::from(start))), + ("isFirstLineOfList", b(begins)), ]) }; data.push(("lines", arr(lists.iter().enumerate().map(line).collect()))); diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 67215b0c..53cfaf91 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -680,7 +680,7 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) { for r in &mut td.runs { px(&mut r.style); } - td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { list: td.para_list(p).list, indent: 0 }).collect(); + td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { indent: 0, ..td.para_list(p) }).collect(); td.list = ListStyle::None; } NodeKind::Vector { network } => { diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 528dbbf0..5c735b28 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -10,8 +10,16 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; -/// Most values decoded from one message (every scalar, array element and field counts). -const MAX_VALUES: usize = 20_000_000; +/// Most values decoded from one message (every scalar, array element and field counts): at least +/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a +/// large real file decodes while a small one can't expand into a huge tree, however far its +/// chunks inflate. 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is +/// lower: past it the file is refused cleanly instead of running out of memory. +const MIN_VALUES: usize = 20_000_000; +#[cfg(not(target_pointer_width = "32"))] +const MAX_VALUES: usize = 128 << 20; +#[cfg(target_pointer_width = "32")] +const MAX_VALUES: usize = 32 << 20; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; @@ -284,13 +292,23 @@ impl Schema { self.defs.iter().position(|d| d.name == name) } - /// Decode a message of definition `root`. + /// Decode a message of definition `root`, within [`MIN_VALUES`] values. pub fn decode_message<'s>(&'s self, root: usize, data: &[u8]) -> Result, KiwiError> { - let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget: MAX_VALUES }; + self.decode_message_within(root, data, MIN_VALUES) + } + + /// [`Self::decode_message`] within `budget` values (see [`value_budget`]). + pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result, KiwiError> { + let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget }; d.def(root, 0) } } +/// Most values decoded from a file whose compressed canvas is `packed` bytes. +pub(crate) fn value_budget(packed: usize) -> usize { + packed.clamp(MIN_VALUES, MAX_VALUES) +} + struct Decoder<'s, 'b> { schema: &'s Schema, r: Reader<'b>, @@ -327,6 +345,8 @@ impl<'s> Decoder<'s, '_> { loop { let id = self.r.var_uint()?; if id == 0 { + // Grown by doubling: trimmed, a large file's tree takes a good part less. + out.shrink_to_fit(); return Ok(Kv::Object(out)); } let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?; diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 7a3dab5c..5c32c583 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -6,9 +6,10 @@ //! are expanded here from the main component (overrides and derived layout matched by id path, //! component property values applied) so the import looks as saved. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::rc::Rc; +use figcraft_doc::VectorNetwork; use figcraft_doc::geom::{Affine, Point}; use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH}; use serde_json::{Map, Value, json}; @@ -18,8 +19,7 @@ use super::network; type Obj = Map; -/// Most layers produced (instance expansion included) and deepest nesting followed. -const MAX_NODES: usize = crate::rest::MAX_NODES; +/// Deepest nesting followed. const MAX_DEPTH: usize = 256; /// Most path commands read from one geometry blob. const MAX_COMMANDS: usize = 200_000; @@ -30,11 +30,15 @@ const NO_STYLE: &str = "4294967295:4294967295"; /// Longest asset `key` or `version` kept for resolving references given as assets. const MAX_ASSET_PART: usize = 256; -/// The REST API file JSON for message `msg`, with the warnings about what was skipped. -pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { - let mut m = Mapper::new(msg); +/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the +/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]). +pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec, Vec>) { + let mut m = Mapper::new(msg, max_nodes); let doc = m.document(); - let mut out = json!({"name": name, "document": doc}); + // Large values are moved in, never passed through `json!`, which serialises its arguments: + // a deep copy of the whole tree, briefly holding it twice. + let mut out = json!({"name": name}); + out["document"] = doc; if !m.library_contexts.is_empty() { out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts)); } @@ -54,7 +58,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { if !styles.is_empty() { out["styleDefinitions"] = Value::Array(styles); } - (out, m.warnings) + (out, m.warnings, m.networks) } #[cfg(test)] @@ -374,20 +378,27 @@ struct Mapper<'a> { sets: Obj, /// Layers and keys of each main, built when an instance of it first needs them. override_scopes: BTreeMap>, - /// Entries held by `override_scopes` (bounded by [`MAX_NODES`] together). + /// Entries held by `override_scopes` (bounded by `max_nodes` together). override_scope_entries: usize, /// Component-bearing branches of the file's internal canvases (cached library definitions). library_contexts: Vec, /// Layers visited while scanning internal canvases, across all of them. library_scan_count: usize, - /// Most layers produced or scanned; the one place the cap comes from. - max_nodes: usize, warnings: Vec, count: usize, + /// Most layers produced or scanned, instance expansion included; the one place the cap comes + /// from (see [`super::layer_budget`]). + max_nodes: usize, + /// Vector networks, each built once: the layers that show one (every instance of a main + /// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's + /// networks took gigabytes. + networks: Vec>, + /// Index in `networks` by what a network is made from: its blob, its scale and its style table. + network_ids: HashMap<(usize, u64, u64, usize), usize>, } impl<'a> Mapper<'a> { - fn new(msg: &'a Kv<'a>) -> Self { + fn new(msg: &'a Kv<'a>, max_nodes: usize) -> Self { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); @@ -447,9 +458,11 @@ impl<'a> Mapper<'a> { override_scope_entries: 0, library_contexts: Vec::new(), library_scan_count: 0, - max_nodes: MAX_NODES, warnings: Vec::new(), count: 0, + max_nodes, + networks: Vec::new(), + network_ids: HashMap::new(), } } @@ -498,7 +511,7 @@ impl<'a> Mapper<'a> { let mut pending: Vec<(String, usize)> = self.children(main).into_iter().map(|id| (id, 1)).collect(); let mut complete = true; while let Some((id, depth)) = pending.pop() { - if depth > MAX_DEPTH || visited.len() >= MAX_NODES { + if depth > MAX_DEPTH || visited.len() >= self.max_nodes { complete = false; break; } @@ -523,7 +536,7 @@ impl<'a> Mapper<'a> { } let scope = Rc::new(OverrideScope { current, aliases }); let cost = scope.current.len() + scope.aliases.len(); - if cost <= MAX_NODES.saturating_sub(self.override_scope_entries) { + if cost <= self.max_nodes.saturating_sub(self.override_scope_entries) { self.override_scope_entries += cost; self.override_scopes.insert(main.to_string(), Rc::clone(&scope)); } @@ -563,7 +576,9 @@ impl<'a> Mapper<'a> { if out.is_empty() { self.warn("the file has no pages"); } - json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out}) + let mut doc = json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT"}); + doc["children"] = Value::Array(out); + doc } /// The ids of the components the visible pages use, directly or through the components they @@ -777,8 +792,9 @@ impl<'a> Mapper<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; } - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; the rest were skipped")); return None; } let base = *self.nodes.get(id)?; @@ -813,7 +829,7 @@ impl<'a> Mapper<'a> { let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view); // Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector. let connector_terminal = - connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= MAX_NODES); + connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.max_nodes); // A supported connector snapshot has its own note below instead of the generic stand-in one. let stand = stand_in(own).filter(|_| !connector_snapshot); let ty = match own { @@ -947,7 +963,7 @@ impl<'a> Mapper<'a> { fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option { let shape = v.str("shapeWithTextType").unwrap_or("SQUARE"); let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty(); - if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= MAX_NODES { + if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes { return None; } let mut s = Obj::new(); @@ -979,7 +995,7 @@ impl<'a> Mapper<'a> { /// layer filling it, inset a little; centred unless it's a note. fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option { let chars = own_text(v)?; - if self.count >= MAX_NODES { + if self.count >= self.max_nodes { return None; } self.count += 1; @@ -1013,7 +1029,7 @@ impl<'a> Mapper<'a> { if connector_has_saved_label(v) { self.warn("connector labels were skipped"); } - if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { + if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { return; } let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid); @@ -1114,7 +1130,7 @@ impl<'a> Mapper<'a> { out.insert("opacity".into(), json!(root_opacity * line_opacity)); } if connector_has_saved_label(v) { - let reason = if self.count >= MAX_NODES { "node budget" } else { "depth limit" }; + let reason = if self.count >= self.max_nodes { "node budget" } else { "depth limit" }; self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children")); } } @@ -1144,8 +1160,8 @@ impl<'a> Mapper<'a> { /// Snapshot the straight, manual connector contract carried by its generated children. fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec { - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; connector snapshot children were skipped")); + if self.count >= self.max_nodes { + self.warn(format!("the file has more than {} layers; connector snapshot children were skipped", self.max_nodes)); return Vec::new(); } let Some(vector) = self.connector_snapshot_vector(v, rest_id, Affine::IDENTITY, true) else { @@ -1207,8 +1223,8 @@ impl<'a> Mapper<'a> { }; let background_view = View::of(background).with([background_style]); if !background_view.list("fillPaints").is_empty() { - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; connector label background was skipped")); + if self.count >= self.max_nodes { + self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes)); } else { let mut rect = Obj::new(); rect.insert("id".into(), json!(format!("{rest_id};label-background"))); @@ -1235,8 +1251,8 @@ impl<'a> Mapper<'a> { transform: Affine, view: &View<'a>, ) { - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; connector label was skipped")); + if self.count >= self.max_nodes { + self.warn(format!("the file has more than {} layers; connector label was skipped", self.max_nodes)); return; } let mut text = Obj::new(); @@ -1510,10 +1526,8 @@ impl<'a> Mapper<'a> { o.insert("style".into(), Value::Object(style)); let Some(td) = td else { return }; char_styles(o, td, size, &mut |w| self.warn(w)); - let lines: Vec<&str> = td - .get("lines") - .map(Kv::list) - .unwrap_or_default() + let raw = td.get("lines").map(Kv::list).unwrap_or_default(); + let lines: Vec<&str> = raw .iter() .map(|l| match l.get("lineType").and_then(Kv::str) { Some("ORDERED_LIST") => "ORDERED", @@ -1524,6 +1538,12 @@ impl<'a> Mapper<'a> { if lines.iter().any(|l| *l != "NONE") { o.insert("lineTypes".into(), json!(lines)); } + // Where a numbered list starts counting, less one (not a REST field). + let starts: Vec = + raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect(); + if starts.iter().any(|n| *n > 0) { + o.insert("listStartOffsets".into(), json!(starts)); + } } /// Carry supported text-path data through the private .fig REST ingress only. @@ -1648,7 +1668,8 @@ impl<'a> Mapper<'a> { /// False when there's none or it's damaged (the outlines stand in). fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; - let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; + let Some(blob_id) = num(vd, "vectorNetworkBlob").map(|i| i as usize) else { return false }; + let Some(blob) = self.blobs.get(blob_id).copied() else { return false }; let Some(net) = network::decode(blob) else { self.warn("damaged vector networks import as outlines"); return false; @@ -1686,34 +1707,24 @@ impl<'a> Mapper<'a> { size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) }; let (sx, sy) = (scale("x"), scale("y")); - let vertices: Vec = net - .vertices - .iter() - .map(|p| { - let mut out = json!({"x": p.x * sx, "y": p.y * sy}); - let st = style(p.style); - if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { - out["radius"] = json!(r); - } - match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { - Some("ANGLE") => out["mirror"] = json!("angle"), - Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), - Some("NONE") => out["mirror"] = json!("none"), - _ => {} - } - out - }) - .collect(); - let segments: Vec = - net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect(); - let regions: Vec = - net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); + // An absent layer join is the source default. + let layer_join = v.str("strokeJoin").unwrap_or("MITER"); + let point_join = |p: &network::Vertex| style(p.style).and_then(|s| s.get("strokeJoin")); + let known = |j: &str| matches!(j, "MITER" | "BEVEL" | "ROUND"); + let unresolved_style = net.vertices.iter().any(|p| p.style != 0 && style(p.style).is_none()); + let unknown_join = net.vertices.iter().any(|p| point_join(p).is_some_and(|j| !j.str().is_some_and(known))); + let unsupported_join = net.vertices.iter().any(|p| point_join(p).and_then(Kv::str).is_some_and(|j| known(j) && j != layer_join)); if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) { self.warn("filled areas of a vector with their own fill use the layer's fill"); } - let join = v.str("strokeJoin"); - if net.vertices.iter().any(|p| style(p.style).and_then(|s| s.get("strokeJoin")).and_then(Kv::str).is_some_and(|j| Some(j) != join)) { - self.warn("points with their own stroke join use the layer's"); + if unresolved_style { + self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"); + } + if unknown_join { + self.warn("vector points with unknown stroke joins use the layer join"); + } + if unsupported_join && (o.contains_key("strokeDashes") || o.contains_key("strokeWidthProfile")) { + self.warn("point stroke joins were preserved; dashed or variable-width strokes render with the layer join"); } // The stroke starts at the first end point and stops at the last (as the editor draws it). let mut degree = vec![0usize; net.vertices.len()]; @@ -1733,7 +1744,49 @@ impl<'a> Mapper<'a> { o.insert("strokeCapStart".into(), json!(cap_name(first.unwrap_or(node)))); o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node)))); } - o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments, "regions": regions})); + // Every instance of a main shows the same network: it is written once and shared. + let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize); + let id = match self.network_ids.get(&key) { + Some(id) => *id, + None => { + let vertices: Vec = net + .vertices + .iter() + .map(|p| { + let mut out = json!({"x": p.x * sx, "y": p.y * sy}); + let st = style(p.style); + if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { + out["radius"] = json!(r); + } + match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { + Some("ANGLE") => out["mirror"] = json!("angle"), + Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), + Some("NONE") => out["mirror"] = json!("none"), + _ => {} + } + if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) { + out["join"] = json!(join.to_ascii_lowercase()); + } + out + }) + .collect(); + let segments: Vec = net + .segments + .iter() + .map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})) + .collect(); + let regions: Vec = + net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); + let mut entry = json!({}); + entry["vertices"] = Value::Array(vertices); + entry["segments"] = Value::Array(segments); + entry["regions"] = Value::Array(regions); + self.networks.push(crate::rest::network(&entry)); + self.network_ids.insert(key, self.networks.len() - 1); + self.networks.len() - 1 + } + }; + o.insert("vectorNetworkRef".into(), json!(id)); true } @@ -2741,6 +2794,7 @@ fn text_style(v: &View, size: Option) -> Obj { if let Some(skip_ink) = v.flag("textDecorationSkipInk") { s.insert("textDecorationSkipInk".into(), json!(skip_ink)); } + copy_str(&mut s, v, "leadingTrim", "leadingTrim"); let font_size = v.num("fontSize").or(size).unwrap_or(12.0); if let Some(ls) = v.get("letterSpacing") { let val = num(ls, "value").unwrap_or(0.0); diff --git a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs index a14e671c..ca47578d 100644 --- a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs +++ b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs @@ -1,7 +1,8 @@ //! Connector snapshot flattening stays within the node and depth budgets. -use super::{MAX_DEPTH, MAX_NODES, Mapper, View}; +use super::{MAX_DEPTH, Mapper, View}; use crate::fig::kiwi::Kv; +use crate::rest::MAX_NODES; use figcraft_doc::geom::Affine; fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { @@ -106,7 +107,7 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { let message = message(); let node = message.get("nodeChanges").unwrap().list().first().unwrap(); let view = View::of(node); - let mut mapper = Mapper::new(&message); + let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES - 1; let children = mapper.connector_snapshot_children(&view, "1:123"); assert_eq!(children.len(), 1, "the final available slot preserves the saved vector"); @@ -116,7 +117,7 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { assert_eq!(mapper.count, MAX_NODES); assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); - let mut mapper = Mapper::new(&message); + let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES; let children = mapper.connector_snapshot_children(&view, "1:123"); assert!(children.is_empty()); @@ -127,14 +128,14 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { #[test] fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { let message = message(); - let mut mapper = Mapper::new(&message); + let mut mapper = Mapper::new(&message, MAX_NODES); let depth_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); assert_eq!(depth_limited["type"], "VECTOR"); assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty()); assert!(depth_limited.get("children").is_none()); assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); - let mut mapper = Mapper::new(&message); + let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES - 1; let budget_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, 0).expect("last allowed connector"); assert_eq!(budget_limited["type"], "VECTOR"); diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 0d341307..97f35f63 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -6,7 +6,8 @@ //! zstd: a binary schema in the kiwi format ([`kiwi`]), then a message encoded with it. Every file //! carries its own schema, so the message is decoded generically and read by field name: a list //! of node changes (each with its id, parent id and position among its siblings) that make up the -//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`]). [`map`] +//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`], and the bytes +//! of images that travel inside the message, [`embedded_images`]). [`map`] //! turns that into the REST API file JSON shape and the REST importer builds the document, so both //! imports share one mapping. Layer kinds the editor doesn't have (sticky notes, connectors, //! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped @@ -38,6 +39,7 @@ mod text_skip_ink_tests; #[cfg(test)] mod text_style_tests; +use std::collections::BTreeMap; use std::io::Read; use figcraft_doc::Document; @@ -45,7 +47,7 @@ use figcraft_doc::Document; pub use export::export; use crate::FormatError; -use kiwi::Schema; +use kiwi::{Kv, Schema}; /// Largest `canvas.fig` member read, and largest decompressed chunk. const MAX_CANVAS: u64 = 256 * 1024 * 1024; @@ -54,6 +56,18 @@ const MAX_CHUNK: u64 = 512 * 1024 * 1024; const MAX_IMAGE: u64 = 256 * 1024 * 1024; /// Most chunks read from `canvas.fig`. const MAX_CHUNKS: usize = 16; +/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its +/// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances +/// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the +/// budget follows what the file takes, not what it unpacks to: a small file can't claim a large +/// count, however it's built; a large one gets room. +const PACKED_BYTES_PER_LAYER: usize = 32; +const MAX_FIG_NODES: usize = 2_000_000; + +/// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes. +pub(crate) fn layer_budget(packed: usize) -> usize { + (packed / PACKED_BYTES_PER_LAYER).clamp(crate::rest::MAX_NODES, MAX_FIG_NODES) +} /// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas. pub fn is_fig(bytes: &[u8]) -> bool { @@ -72,14 +86,24 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option Result<(Document, Vec), FormatError> { - let archive = Archive::open(bytes)?; - let (schema, message) = decode_canvas(&archive.canvas)?; + let mut archive = Archive::open(bytes)?; + // The budgets follow the compressed canvas, which the file itself bounds (its zip member may + // inflate too). + let packed = archive.canvas.len().min(bytes.len()); + // Each stage's input is dropped as soon as the next one is built: in a large file every one + // of them takes gigabytes, and holding them all at once runs out of memory. + let (schema, message) = decode_canvas(&std::mem::take(&mut archive.canvas))?; let root = schema.find("Message").ok_or_else(|| corrupt("the schema has no Message definition"))?; - let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?; + let msg = schema.decode_message_within(root, &message, kiwi::value_budget(packed)).map_err(|e| corrupt(e.to_string()))?; + let max_nodes = layer_budget(packed); + drop(message); let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); - let (json, mut warnings) = map::to_rest(&msg, file_name); - let images = |r: &str| archive.image(r); - let (doc, more) = crate::rest::import_fig(&json, &images)?; + let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes); + // The message goes before the layers are built, so the images it carries are copied out first. + let embedded: BTreeMap> = embedded_images(&msg).into_iter().map(|(hash, bytes)| (hash, bytes.to_vec())).collect(); + drop(msg); + let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned()); + let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) } @@ -109,6 +133,41 @@ fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!(".fig file: {}", msg.into())) } +/// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's +/// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed +/// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The +/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. The +/// bytes stay borrowed from the message. +fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap { + let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default(); + let mut found = BTreeMap::new(); + let mut pending = vec![msg]; + while let Some(value) = pending.pop() { + match value { + Kv::Object(fields) => { + if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) { + found.entry(hash).or_insert(bytes); + } + pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field)); + } + Kv::Array(items) => pending.extend(items), + _ => {} + } + } + found +} + +/// The hash and bytes of an `Image` value whose `dataBlob` points at a usable blob. +fn embedded_image<'k>(image: &Kv, blobs: &'k [Kv]) -> Option<(String, &'k [u8])> { + let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?; + let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None }; + let bytes = blobs.get(usize::try_from(*index).ok()?)?.get("bytes").and_then(Kv::bytes)?; + if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE { + return None; + } + Some((hash.iter().map(|b| format!("{b:02x}")).collect(), bytes)) +} + /// The parts of a `.fig` archive the import reads. struct Archive<'b> { canvas: Vec, diff --git a/crates/format/src/fig/slot_tests.rs b/crates/format/src/fig/slot_tests.rs index 670e2f6f..17c75464 100644 --- a/crates/format/src/fig/slot_tests.rs +++ b/crates/format/src/fig/slot_tests.rs @@ -24,7 +24,7 @@ fn node(local: u32, kind: &'static str, parent: Option, mut fields: Vec<(&' } fn mapped(msg: &Kv<'_>) -> Value { - to_rest(msg, "Synthetic resources").0 + to_rest(msg, "Synthetic resources", crate::rest::MAX_NODES).0 } fn named_id<'b>(v: &'b Value, id: &str) -> Option<&'b Value> { @@ -210,12 +210,12 @@ fn unassigned_slot_keeps_default_and_bad_content_target_warns() { assert_eq!(slot["children"].as_array().unwrap().len(), 1); assert!(slot.get("overrides").is_none()); - let (rest, warnings) = to_rest(&slot_message(Some(999), false), "Synthetic"); + let (rest, warnings, _) = to_rest(&slot_message(Some(999), false), "Synthetic", crate::rest::MAX_NODES); assert!(warnings.iter().any(|w| w.contains("slot content assignment target is missing or is not slot content")), "{warnings:?}"); let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); assert_eq!(slot["children"].as_array().unwrap().len(), 1); - let (rest, warnings) = to_rest(&slot_message(Some(33), true), "Synthetic"); + let (rest, warnings, _) = to_rest(&slot_message(Some(33), true), "Synthetic", crate::rest::MAX_NODES); let main = named_id(&rest["document"], "9:10").unwrap(); assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT"); assert!(warnings.iter().any(|w| w.contains("preferred slot component keys cannot be resolved")), "{warnings:?}"); diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs index 6667e4a2..927b0227 100644 --- a/crates/format/src/fig/source_identity_tests.rs +++ b/crates/format/src/fig/source_identity_tests.rs @@ -379,7 +379,7 @@ fn public_rest_import_ignores_private_fig_provenance() { assert!(doc.library_store().is_none() && doc.nodes().all(|node| node.name != "Must not import")); // Valid provenance on mapped layers links nothing either. for message in [message(false), external_namespace_message()] { - let (json, _) = super::map::to_rest(&message, "Identity"); + let (json, _, _) = super::map::to_rest(&message, "Identity", crate::rest::MAX_NODES); let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); assert!(doc.nodes().all(|n| n.source.is_none())); } @@ -806,7 +806,7 @@ fn edit_node(value: &mut Value, id: &str, edit: &mut dyn FnMut(&mut Value)) { #[test] fn missing_foreign_self_and_wrong_namespace_provenance_is_not_guessed() { for (origin, id) in [("1:99999", "I1:70;1:40"), ("1:12", "I1:70;1:40"), ("1:70", "1:70"), ("1:40", "I1:70;1:40")] { - let (mut json, _) = super::map::to_rest(&message(false), "Identity"); + let (mut json, _, _) = super::map::to_rest(&message(false), "Identity", crate::rest::MAX_NODES); edit_node(&mut json["document"]["children"][0], id, &mut |node| { node[crate::rest::FIG_SOURCE_FIELD]["origin"] = json!(origin); if id == "1:70" { @@ -825,7 +825,7 @@ fn missing_foreign_self_and_wrong_namespace_provenance_is_not_guessed() { #[test] fn forward_main_references_and_duplicate_ids_are_conservative() { - let (mut json, _) = super::map::to_rest(&message(false), "Identity"); + let (mut json, _, _) = super::map::to_rest(&message(false), "Identity", crate::rest::MAX_NODES); json["document"]["children"][0]["children"].as_array_mut().unwrap().rotate_right(1); let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); assert!(doc.node(named(&doc, "Copy")).unwrap().source.is_some()); @@ -838,7 +838,7 @@ fn forward_main_references_and_duplicate_ids_are_conservative() { #[test] fn missing_contextual_targets_do_not_fall_back_to_a_global_slot() { - let (mut json, _) = super::map::to_rest(&message(false), "Identity"); + let (mut json, _, _) = super::map::to_rest(&message(false), "Identity", crate::rest::MAX_NODES); edit_node(&mut json["document"]["children"][0], "1:40", &mut |node| { node["children"].as_array_mut().unwrap().retain(|n| n["id"] != "I1:40;1:60"); }); @@ -1019,7 +1019,7 @@ fn absent_external_mains_keep_known_owned_instances_and_contextual_copies() { #[test] fn external_namespace_is_private_and_requires_actual_wire_instances() { for mode in 0..3 { - let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External"); + let (mut json, _, _) = super::map::to_rest(&external_namespace_message(), "External", crate::rest::MAX_NODES); if mode == 0 { let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); assert!(doc.nodes().all(|n| n.source.is_none())); @@ -1042,7 +1042,7 @@ fn external_namespace_is_private_and_requires_actual_wire_instances() { #[test] fn external_namespace_does_not_cover_present_wrong_kind_or_blocked_main() { for mode in 0..3 { - let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External"); + let (mut json, _, _) = super::map::to_rest(&external_namespace_message(), "External", crate::rest::MAX_NODES); let page = &mut json["document"]["children"][0]; match mode { 0 => page["children"].as_array_mut().unwrap().push(json!({"id":"1:99", "type":"FRAME", "name":"Wrong kind", @@ -1066,7 +1066,7 @@ fn external_namespace_does_not_cover_present_wrong_kind_or_blocked_main() { #[test] fn external_namespace_rejects_foreign_siblings_duplicates_and_bad_paths() { for mode in 0..4 { - let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External"); + let (mut json, _, _) = super::map::to_rest(&external_namespace_message(), "External", crate::rest::MAX_NODES); let page = &mut json["document"]["children"][0]; match mode { 0 => { @@ -1113,7 +1113,7 @@ fn an_explicit_no_style_detaches_and_beats_the_older_style_field() { node(7, Some(2), "RECTANGLE", "Linked", vec![("inheritFillStyleID", g(5))]), ]), )]); - let (json, _) = super::map::to_rest(&message, "Detach"); + let (json, _, _) = super::map::to_rest(&message, "Detach", crate::rest::MAX_NODES); let find = |name: &str| json["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["name"] == name).unwrap().clone(); assert!(find("Box")["styles"].get("fill").is_none(), "{}", find("Box")["styles"]); assert!(find("Linked")["styles"].get("fill").is_some(), "the older field still links when nothing detaches"); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index a15334a5..abc90d73 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -3,7 +3,7 @@ //! fixtures built in code with the parent module's helpers. use figcraft_doc::proto::Action; -use figcraft_doc::{HandleMirroring, StrokeCap, WindingRule}; +use figcraft_doc::{HandleMirroring, StrokeCap, StrokeJoin, WindingRule}; use super::super::network::{self, Network, Region, Segment, Vertex}; use super::*; @@ -19,7 +19,7 @@ fn vtx(style: u32, x: f64, y: f64) -> Vertex { /// A square with one curved side and a rounded, mirrored corner, filled even-odd. fn square_blob() -> Vec { network::encode(&Network { - vertices: vec![vtx(0, 0.0, 0.0), vtx(1, 50.0, 0.0), vtx(0, 50.0, 50.0), vtx(0, 0.0, 50.0)], + vertices: vec![vtx(0, 0.0, 0.0), vtx(1, 50.0, 0.0), vtx(2, 50.0, 50.0), vtx(3, 0.0, 50.0)], segments: vec![ seg(0, 1, (10.0, 0.0), (-10.0, 0.0)), seg(1, 2, (0.0, 0.0), (0.0, 0.0)), @@ -39,6 +39,15 @@ fn arrow_blob() -> Vec { }) } +/// An open path with one missing style ID and one unsupported point join. +fn join_fallback_blob() -> Vec { + network::encode(&Network { + vertices: vec![vtx(4, 0.0, 0.0), vtx(5, 50.0, 0.0), vtx(0, 100.0, 0.0)], + segments: vec![seg(0, 1, (0.0, 0.0), (0.0, 0.0)), seg(1, 2, (0.0, 0.0), (0.0, 0.0))], + regions: vec![], + }) +} + fn vector_data(blob: u64, nx: f64, ny: f64, styles: Vec>) -> Kv<'static> { o(vec![("vectorNetworkBlob", u(blob)), ("normalizedSize", xy(nx, ny)), ("styleOverrideTable", arr(styles))]) } @@ -121,13 +130,24 @@ fn tail_message() -> Kv<'static> { ("transform", at(700.0, 0.0)), ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), ("fillGeometry", outline(0)), + ("strokeJoin", e("MITER")), + ("dashPattern", arr(vec![f(4.0), f(2.0)])), ( "vectorData", vector_data( 1, 100.0, 50.0, - vec![o(vec![("styleID", u(1)), ("cornerRadius", f(4.0)), ("handleMirroring", e("ANGLE_AND_LENGTH"))])], + vec![ + o(vec![ + ("styleID", u(1)), + ("cornerRadius", f(4.0)), + ("handleMirroring", e("ANGLE_AND_LENGTH")), + ("strokeJoin", e("BEVEL")), + ]), + o(vec![("styleID", u(2)), ("strokeJoin", e("MITER"))]), + o(vec![("styleID", u(3)), ("strokeJoin", e("ROUND"))]), + ], ), ), ], @@ -146,6 +166,19 @@ fn tail_message() -> Kv<'static> { ("vectorData", vector_data(2, 100.0, 0.0, vec![o(vec![("styleID", u(2)), ("strokeCap", e("ARROW_LINES"))])])), ], ), + node( + 42, + Some((1, "#")), + "VECTOR", + "Join Fallbacks", + vec![ + ("size", xy(100.0, 10.0)), + ("transform", at(0.0, 330.0)), + stroke(), + ("strokeJoin", e("MITER")), + ("vectorData", vector_data(4, 100.0, 10.0, vec![o(vec![("styleID", u(5)), ("strokeJoin", u(999))])])), + ], + ), node( 32, Some((1, "#")), @@ -223,7 +256,7 @@ fn tail_message() -> Kv<'static> { ]; let mut broken = square_blob(); broken.truncate(30); - let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken]; + let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken, join_fallback_blob()]; o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(blobs.into_iter().map(|b| o(vec![("bytes", Kv::Bytes(b))])).collect()))]) } @@ -247,6 +280,11 @@ fn vectors_import_as_editable_networks() { assert_eq!(pts, [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); assert_eq!((wave.vertices[1].radius, wave.vertices[1].mirror), (4.0, Some(HandleMirroring::AngleAndLength))); assert_eq!(wave.vertices[0].mirror, None); + assert_eq!( + wave.vertices.iter().map(|v| v.join).collect::>(), + [None, Some(StrokeJoin::Bevel), Some(StrokeJoin::Miter), Some(StrokeJoin::Round)] + ); + assert_eq!(named(&doc, "Wave").stroke.join, StrokeJoin::Miter); assert_eq!((wave.segments[0].ta, wave.segments[0].tb), ((20.0, 0.0), (-20.0, 0.0))); assert_eq!(wave.regions.len(), 1); assert_eq!((wave.regions[0].loops.clone(), wave.regions[0].winding), (vec![vec![0, 1, 2, 3]], WindingRule::EvenOdd)); @@ -260,6 +298,17 @@ fn vectors_import_as_editable_networks() { // A damaged network falls back to the outline. assert_eq!(network_of(&doc, "Broken").regions.len(), 1); assert!(warnings.iter().any(|w| w.contains("damaged vector networks import as outlines")), "{warnings:?}"); + let fallback = network_of(&doc, "Join Fallbacks"); + assert!(fallback.vertices.iter().all(|v| v.join.is_none())); + assert_eq!(named(&doc, "Join Fallbacks").stroke.join, StrokeJoin::Miter); + assert!( + warnings + .iter() + .any(|w| w.contains("vector points refer to point settings missing from the file; they use the layer's joins and plain corners")), + "{warnings:?}" + ); + assert!(warnings.iter().any(|w| w.contains("unknown stroke joins use the layer join")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("dashed or variable-width strokes render with the layer join")), "{warnings:?}"); } #[test] @@ -446,8 +495,8 @@ fn mangled_tail_files_never_panic() { m[i] = r.next() as u8; } if let Ok(kv) = sc.decode_message(root, &m) { - let (json, _) = map::to_rest(&kv, "x"); - let _ = crate::rest::import(&json, &crate::rest::no_images); + let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); } } } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 24baef30..b0c3c5fe 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -33,12 +33,13 @@ enum NavigationType NAVIGATE OVERLAY enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE enum EasingType IN_CUBIC OUT_CUBIC enum ConstraintType MIN CENTER MAX STRETCH SCALE +enum LeadingTrim NONE CAP_HEIGHT struct GUID uint sessionID, uint localID struct Color float r, float g, float b, float a struct Vector float x, float y struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 struct ColorStop Color color, float position -message Image byte[] hash, string name +message Image byte[] hash, string name, uint dataBlob message AssetRef string key, string version message VariableID GUID guid, AssetRef assetRef message PropRefValue GUID defId @@ -72,7 +73,7 @@ message Event InteractionType interactionType message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions message Interaction Event event, Action[] actions message StartingPoint string name, string position -message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; @@ -283,6 +284,7 @@ fn message() -> Kv<'static> { ("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])), ("fontSize", f(16.0)), ("fontName", font("Regular")), + ("leadingTrim", e("CAP_HEIGHT")), ("textData", text), ], ), @@ -646,6 +648,7 @@ fn imports_the_fixture() { let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") }; assert_eq!(t.characters, "Hi there"); assert_eq!(t.style.font_size, 16.0); + assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)"); assert_eq!(t.runs.len(), 1); assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold")); // The group's children keep their place on the page (group at 100, 100). @@ -708,6 +711,53 @@ fn bare_canvas_and_zlib_chunks_open() { assert!(import(&out, "Z").is_ok()); } +/// The fixture message with the photo's image bytes in its own blobs, as copies written by plugins +/// and tools carry them: the PNG goes at the end of `blobs` and `image.dataBlob` points `shift` +/// places past it (0 is the PNG itself). +fn message_with_image_in_blobs(shift: u64) -> Kv<'static> { + fn point_images_at(value: &mut Kv<'static>, blob: u64) { + match value { + Kv::Object(fields) => { + for (name, field) in fields.iter_mut() { + match field { + Kv::Object(image) if *name == "image" => image.push(("dataBlob", u(blob))), + _ => point_images_at(field, blob), + } + } + } + Kv::Array(items) => items.iter_mut().for_each(|item| point_images_at(item, blob)), + _ => {} + } + } + let mut msg = message(); + let index = { + let Kv::Object(fields) = &mut msg else { panic!("the message is an object") }; + let Some((_, Kv::Array(blobs))) = fields.iter_mut().find(|(name, _)| *name == "blobs") else { panic!("no blobs") }; + blobs.push(o(vec![("bytes", Kv::Bytes(png()))])); + blobs.len() as u64 - 1 + }; + point_images_at(&mut msg, index + shift); + msg +} + +#[test] +fn a_bare_canvas_reads_images_carried_in_its_blobs() { + // The clipboard holds a bare canvas (no `images/`); the image comes from `image.dataBlob`. + let c = canvas(&schema(), &message_with_image_in_blobs(0)); + let (doc, warnings) = import(&c, "Pasted").unwrap(); + let photo = named(&doc, "Photo"); + let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; + assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + +#[test] +fn a_data_blob_past_the_blobs_stays_a_placeholder() { + let c = canvas(&schema(), &message_with_image_in_blobs(5)); + let (_, warnings) = import(&c, "Pasted").unwrap(); + assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + #[test] fn other_files_are_not_fig() { assert!(try_import(b"{\"document\": {}}", "x").is_none()); @@ -757,7 +807,7 @@ fn mangled_kiwi_never_panics() { } for cut in (0..msg.len()).step_by(3) { if let Ok(kv) = sc.decode_message(root, &msg[..cut]) { - let _ = map::to_rest(&kv, "x"); + let _ = map::to_rest(&kv, "x", crate::rest::MAX_NODES); } } for _ in 0..2000 { @@ -767,8 +817,8 @@ fn mangled_kiwi_never_panics() { m[i] = r.next() as u8; } if let Ok(kv) = sc.decode_message(root, &m) { - let (json, _) = map::to_rest(&kv, "x"); - let _ = crate::rest::import(&json, &crate::rest::no_images); + let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); } let mut sbytes = schema_bytes.clone(); let i = (r.next() as usize) % sbytes.len(); @@ -790,6 +840,61 @@ fn mangled_kiwi_never_panics() { } } +#[test] +fn large_files_get_a_larger_value_budget() { + // Small files keep the fixed floor: a few bytes can't expand into a huge tree. + assert_eq!(kiwi::value_budget(0), 20_000_000); + assert_eq!(kiwi::value_budget(1 << 20), 20_000_000); + // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds + // 30.5 million values was refused as "too many values". + assert!(kiwi::value_budget(83_491_232) > 30_514_899); + // A real 194 MB design-system file (UX4G): a 39.5 MB canvas holding 29.7 million values. + assert!(kiwi::value_budget(39_528_420) > 29_703_730); + // Still bounded by the largest canvas read. + assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); +} + +#[test] +fn the_layer_cap_follows_the_file_size() { + // A small (or broken) file keeps the REST import's cap: instances expand into a layer per + // layer of their component, and a few bytes mustn't claim millions of layers. + assert_eq!(layer_budget(0), crate::rest::MAX_NODES); + assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); + // A real 894 004-layer file (an 83.5 MB canvas) stopped at 200 000 and came in with 27 of + // its 30 pages empty. + assert!(layer_budget(83_491_232) > 894_004); + // No file gets more than the ceiling. + assert_eq!(layer_budget(MAX_CANVAS as usize), MAX_FIG_NODES); +} + +#[test] +fn a_small_file_that_unpacks_large_gets_no_larger_budgets() { + // 40 MB of zeros in a blob pack into a few KB. Keyed to the unpacked message, they lifted the + // layer cap above the 231 231 layers its instances expand into; keyed to the file, the cap + // stays at the REST import's. + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(10.0, 10.0))])); + nodes.extend((10..1010).map(|i| node(i, Some((5, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + nodes.extend((2000..2230).map(|i| node(i, Some((1, "!")), "INSTANCE", "I", vec![("symbolData", o(vec![("symbolID", g(5))]))]))); + let blob = o(vec![("bytes", Kv::Bytes(vec![0; 40 << 20]))]); + let file = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![blob]))])); + assert!(file.len() < 1 << 20, "{} bytes", file.len()); + let (doc, warnings) = import(&file, "x").unwrap(); + assert!(warnings.iter().any(|w| w.contains("more than 200000 layers")), "{warnings:?}"); + assert!(doc.node_count() <= crate::rest::MAX_NODES + 10); +} + +#[test] +fn the_mapper_stops_at_the_layer_cap_it_is_given() { + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.extend((2..12).map(|i| node(i, Some((1, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + let msg = o(vec![("nodeChanges", arr(nodes))]); + let (_, warnings, _) = map::to_rest(&msg, "x", 5); + assert!(warnings.iter().any(|w| w.contains("more than 5 layers")), "{warnings:?}"); + let (_, warnings, _) = map::to_rest(&msg, "x", crate::rest::MAX_NODES); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); +} + #[test] fn hostile_input_is_refused() { // A message that contains itself, nested past the depth cap. @@ -1045,6 +1150,9 @@ mod tail; #[path = "image_filter_tests.rs"] mod image_filters; +#[path = "text_list_tests.rs"] +mod text_list; + /// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). pub(super) fn samples() -> Vec { let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; @@ -1088,7 +1196,7 @@ fn dump_samples() { std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap(); let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap(); - let (rest, _) = map::to_rest(&msg, &stem); + let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len())); std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap(); } } diff --git a/crates/format/src/fig/text_list_tests.rs b/crates/format/src/fig/text_list_tests.rs new file mode 100644 index 00000000..eca7b0dc --- /dev/null +++ b/crates/format/src/fig/text_list_tests.rs @@ -0,0 +1,93 @@ +//! Numbered lists that start past 1 (#686): each text line says where its list starts counting, +//! less one (`listStartOffset`). Fixtures built in code with the parent module's helpers. + +use figcraft_doc::{ListStyle, NodeKind, ParaList}; + +use super::*; + +const LIST_SCHEMA: &str = " +enum NodeType NONE DOCUMENT CANVAS TEXT +enum LineType PLAIN ORDERED_LIST UNORDERED_LIST +struct GUID uint sessionID, uint localID +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +message ParentIndex GUID guid, string position +message TextLineData LineType lineType, int indentationLevel, int listStartOffset, bool isFirstLineOfList +message TextData string characters, TextLineData[] lines +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData +message Message NodeChange[] nodeChanges +"; + +/// A list line: type, start offset, first of its list. +fn line(ty: &'static str, offset: i64, first: bool) -> Kv<'static> { + let level = i64::from(ty != "PLAIN"); + o(vec![("lineType", e(ty)), ("indentationLevel", Kv::Int(level)), ("listStartOffset", Kv::Int(offset)), ("isFirstLineOfList", Kv::Bool(first))]) +} + +fn text_node(id: u64, name: &str, chars: &str, lines: Vec>) -> Kv<'static> { + let pos = ["!", "\"", "#", "$", "%", "&"][id as usize % 6]; + node( + id, + Some((1, pos)), + "TEXT", + name, + vec![ + ("size", xy(200.0, 60.0)), + ("transform", at(0.0, 0.0)), + ("fontSize", f(16.0)), + ("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])), + ], + ) +} + +fn starts(doc: &figcraft_doc::Document, name: &str) -> Vec<(ListStyle, u32)> { + let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") }; + (0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start_offset)).collect() +} + +#[test] +fn numbered_lists_keep_their_start() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + text_node(2, "From 1", "Start here", vec![line("ORDERED_LIST", 0, true)]), + text_node(3, "From 2", "Next try this", vec![line("ORDERED_LIST", 1, true)]), + text_node(4, "First only", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 0, false), line("ORDERED_LIST", 0, false)]), + text_node(5, "Every line", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 3, false), line("ORDERED_LIST", 3, false)]), + text_node(6, "Mixed", "x\na\nb", vec![line("PLAIN", 0, false), line("ORDERED_LIST", 4, true), line("UNORDERED_LIST", 2, true)]), + text_node(7, "Junk", "a", vec![line("ORDERED_LIST", -5, true)]), + ]; + let sc = Schema::parse(LIST_SCHEMA).unwrap(); + let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(starts(&doc, "From 1"), [(N, 0)]); + assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2."); + assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]); + assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]); + assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 0)], "bullets have no number, so no start"); + assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1"); +} + +#[test] +fn the_export_writes_where_a_list_starts() { + let mut doc = figcraft_doc::Document::new("Lists"); + let page = doc.first_page().unwrap(); + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot"); + let pl = |list, start_offset| ParaList { list, indent: 0, start_offset }; + td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)]; + doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap(); + let (bytes, _) = super::super::export(&doc, None).unwrap(); + let a = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&a.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("List")).unwrap(); + let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap(); + let offsets: Vec = lines.iter().map(|l| l.get("listStartOffset").and_then(Kv::f64).unwrap()).collect(); + // Every line of a numbered list carries its first item's start (a reader that takes it per + // line or per list counts the same); bullets and plain lines carry none. + assert_eq!(offsets, [1.0, 1.0, 0.0, 5.0, 0.0]); + let (back, _) = import(&bytes, "x").unwrap(); + assert_eq!(starts(&back, "List"), [(N, 1), (N, 1), (P, 0), (N, 5), (B, 0)]); +} diff --git a/crates/format/src/fig/text_path_tests.rs b/crates/format/src/fig/text_path_tests.rs index 66148930..c94c6aa3 100644 --- a/crates/format/src/fig/text_path_tests.rs +++ b/crates/format/src/fig/text_path_tests.rs @@ -154,7 +154,7 @@ fn linked_underline_message() -> Kv<'static> { #[test] fn mapped_text_path_reaches_native_model_with_runs_and_no_standin_warning() { let message = message(true); - let (json, warnings) = super::map::to_rest(&message, "Text path"); + let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES); assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap(); assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); @@ -171,7 +171,7 @@ fn mapped_text_path_reaches_native_model_with_runs_and_no_standin_warning() { #[test] fn public_rest_import_ignores_private_fig_text_path_field() { let message = message(true); - let (json, _) = super::map::to_rest(&message, "Text path"); + let (json, _, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES); // The mapper's private field is accepted only by import_fig, never by public REST import. let path = json["document"]["children"][0]["children"][0]["_figTextPath"].clone(); assert!(path.is_object()); @@ -195,7 +195,7 @@ fn serialized_kiwi_canvas_import_decodes_start_and_builds_native_path() { #[test] fn malformed_text_path_falls_back_with_specific_warning() { let message = message(false); - let (json, warnings) = super::map::to_rest(&message, "Text path"); + let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES); assert!(warnings.iter().any(|w| w.contains("text path has no start data") && w.contains("text imports off its path")), "{warnings:?}"); let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); let node = doc.nodes().find(|n| n.name == "broken path").unwrap(); @@ -205,7 +205,7 @@ fn malformed_text_path_falls_back_with_specific_warning() { #[test] fn linked_text_style_decoration_warns_from_effective_mapped_style() { let message = linked_underline_message(); - let (json, warnings) = super::map::to_rest(&message, "Text path"); + let (json, warnings, _) = super::map::to_rest(&message, "Text path", crate::rest::MAX_NODES); assert_eq!(json["document"]["children"][0]["children"][0]["style"]["textDecoration"], "UNDERLINE"); assert!(warnings.iter().any(|w| w == "text path underline or strikethrough decoration is not rendered"), "{warnings:?}"); } diff --git a/crates/format/src/fig/text_skip_ink_tests.rs b/crates/format/src/fig/text_skip_ink_tests.rs index 2a1ff61d..48566384 100644 --- a/crates/format/src/fig/text_skip_ink_tests.rs +++ b/crates/format/src/fig/text_skip_ink_tests.rs @@ -56,7 +56,7 @@ fn raw(base: Option>, runs: Vec>, chars: &str, ids: Vec< } nodes.push(node(11, "TEXT", "Label", Some(2), layer)); let message = object(vec![("nodeChanges", Kv::Array(nodes))]); - super::map::to_rest(&message, "Synthetic skip ink").0 + super::map::to_rest(&message, "Synthetic skip ink", crate::rest::MAX_NODES).0 } fn imported(value: &Value) -> Document { @@ -162,7 +162,7 @@ fn instance_path_override_preserves_explicit_false_over_true_main() { instance, ]), )]); - let value = super::map::to_rest(&message, "Instance skip ink").0; + let value = super::map::to_rest(&message, "Instance skip ink", crate::rest::MAX_NODES).0; let copy = value["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["name"] == "Copy").unwrap(); let label = copy["children"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap(); assert_eq!(label["style"]["textDecorationSkipInk"], false); diff --git a/crates/format/src/fig/text_style_tests.rs b/crates/format/src/fig/text_style_tests.rs index a5f135e7..b2c4ed59 100644 --- a/crates/format/src/fig/text_style_tests.rs +++ b/crates/format/src/fig/text_style_tests.rs @@ -43,7 +43,7 @@ fn chars(text: &str) -> Kv<'static> { fn rest(nodes: Vec>) -> Value { let base = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; let message = object(vec![("nodeChanges", Kv::Array(base.into_iter().chain(nodes).collect()))]); - super::map::to_rest(&message, "Synthetic text").0 + super::map::to_rest(&message, "Synthetic text", crate::rest::MAX_NODES).0 } fn import(value: &Value) -> Document { diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 3ff352ac..889074f1 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -202,3 +202,20 @@ fn the_header_is_read_on_its_own() { let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap(); assert_eq!(index, Some(["ab".to_string()].into_iter().collect())); } + +#[test] +fn document_json_may_unpack_in_proportion_to_its_packed_size() { + const MB: u64 = 1024 * 1024; + // A small archive keeps the member cap: it can't unpack into gigabytes. + assert_eq!(document_limit(0, 0), MAX_MEMBER); + assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER); + // A real 900 000-layer document saved by us packs 1 477 139 810 bytes into 244 362 923, and + // didn't open again under the 512 MB cap. + if cfg!(not(target_pointer_width = "32")) { + assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810); + } + // The packed size an archive claims counts only up to the archive's own size. + assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER); + // Never more than the ceiling. + assert_eq!(document_limit(u64::MAX, u64::MAX), MAX_DOCUMENT); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f88416e2..9f7e51bc 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -39,6 +39,15 @@ pub const FORMAT: &str = "figcraft"; pub const VERSION: u32 = 1; /// Largest archive member we read (guards against zip bombs). const MAX_MEMBER: u64 = 512 * 1024 * 1024; +/// `document.json` may unpack to [`DOCUMENT_RATIO`] times what it takes in the archive, within +/// [`MAX_MEMBER`] and [`MAX_DOCUMENT`]: a large document saved by us (a 900 000-layer file packs +/// 1.48 GB into 244 MB) opens again, while a small archive still can't unpack into gigabytes. +/// 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is lower. +const DOCUMENT_RATIO: u64 = 16; +#[cfg(not(target_pointer_width = "32"))] +const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024; /// Most versions a file keeps: saving more is refused, extra entries in a file are skipped. pub const MAX_VERSIONS: usize = 100; /// Longest version name and description kept (chars). @@ -455,6 +464,11 @@ pub(crate) fn read_member(z: &mut zip::ZipArchive, name: &str Ok(data) } +/// Largest `document.json` read from an archive of `len` bytes in which it takes `packed`. +pub(crate) fn document_limit(packed: u64, len: u64) -> u64 { + packed.min(len).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT) +} + /// How many images, and how many bytes of them together, one file may give. #[derive(Clone, Copy, Debug)] pub(crate) struct ImageBudget { @@ -476,7 +490,9 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result