diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b7e0552a..8edace2a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -84,6 +84,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { "file.saveVersion" => crate::versions::start_save(app), "branch.create" => crate::versions::start_branch(app), "file.export" | "file.exportSvg" => app.ui.export_preview = true, + "file.exportFramesPdf" => export_frames_pdf(app), "edit.copyAsCss" => { let ids = app.session.selection(); if let (Some(first), Ok(doc)) = (ids.first(), app.session.doc()) { @@ -156,6 +157,15 @@ pub fn invoke(app: &mut FigApp, id: &str) { } } +/// Every visible top-level frame of the page as one PDF page each, through a save dialog. +fn export_frames_pdf(app: &mut FigApp) { + let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); + let Some(p) = app.services.pick_save(tr("Export"), &format!("{name}.pdf"), &["pdf"]) else { return }; + if app.run("file.exportFramesPdf", json!({"path": p.to_string_lossy()})).is_some() { + app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); + } +} + fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option { let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?; String::from_utf8(bytes).ok() diff --git a/crates/ui-egui/tests/file_menu_pdf.rs b/crates/ui-egui/tests/file_menu_pdf.rs new file mode 100644 index 00000000..140dbc6a --- /dev/null +++ b/crates/ui-egui/tests/file_menu_pdf.rs @@ -0,0 +1,68 @@ +//! File > Export frames to PDF: the menu asks where to save and writes the frames' PDF there, +//! one visible top-level frame per page. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; + +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, Services, menus}; +use serde_json::json; + +/// Services whose save dialog always returns the given path. +struct SaveTo(PathBuf); +impl Services for SaveTo { + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option { + Some(self.0.clone()) + } +} + +fn out_path(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("figcraft-{}-{name}.pdf", std::process::id())); + let _ = std::fs::remove_file(&p); + p +} + +fn frame(app: &mut FigApp, x: i32, y: i32) -> serde_json::Value { + app.session.execute("node.create", &json!({"type": "frame", "x": x, "y": y, "width": 100, "height": 80})).unwrap() +} + +/// The number of `/Type /Page` objects (not `/Pages`) in a PDF. +fn page_count(pdf: &[u8]) -> usize { + let s = String::from_utf8_lossy(pdf).replace("/Type/Page", "/Type /Page"); + s.match_indices("/Type /Page").filter(|(i, _)| !s[i + 11..].starts_with('s')).count() +} + +#[test] +fn frames_pdf_menu_saves_the_pdf() { + let path = out_path("one"); + let mut app = FigApp::new(Session::new(), Box::new(SaveTo(path.clone()))); + frame(&mut app, 0, 0); + menus::invoke(&mut app, "file.exportFramesPdf"); + assert!(app.ui.last_error.is_none(), "{:?}", app.ui.last_error); + let bytes = std::fs::read(&path).expect("the menu writes the PDF where the user chose"); + let _ = std::fs::remove_file(&path); + assert!(bytes.starts_with(b"%PDF")); + assert_eq!(page_count(&bytes), 1); + + // The command still returns the data when called without a destination. + let value = app.session.execute("file.exportFramesPdf", &json!({})).unwrap(); + assert_eq!(value["format"], "pdf"); + assert_eq!(value["pages"], 1); + assert!(value["data"].as_str().unwrap().starts_with("JVBERi0")); +} + +#[test] +fn frames_pdf_menu_one_page_per_visible_frame() { + let path = out_path("two"); + let mut app = FigApp::new(Session::new(), Box::new(SaveTo(path.clone()))); + frame(&mut app, 0, 0); + frame(&mut app, 300, 0); + let hidden = frame(&mut app, 600, 0); + let id = hidden["id"].clone(); + app.session.execute("node.set", &json!({"id": id, "visible": false})).unwrap(); + menus::invoke(&mut app, "file.exportFramesPdf"); + assert!(app.ui.last_error.is_none(), "{:?}", app.ui.last_error); + let bytes = std::fs::read(&path).expect("the menu writes the PDF where the user chose"); + let _ = std::fs::remove_file(&path); + assert_eq!(page_count(&bytes), 2); +}