mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
Preserve browser import intent and document targets (#119)
* #89: preserve browser import context and PDF options * #91: reject PDF pages before narrowing on Wasm --------- Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
8 files changed
+383
-32
No files matched your search
Generated
+1
@@ -1114,6 +1114,7 @@ dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-fonts",
|
||||
"designcraft-geom",
|
||||
"designcraft-idml",
|
||||
"designcraft-render",
|
||||
"designcraft-tools",
|
||||
"egui",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use super::browser_policy::{has_query_flag, mime_for};
|
||||
use designcraft_engine::Session;
|
||||
use designcraft_ui_egui::{DesignApp, Inbox, Services};
|
||||
use designcraft_ui_egui::{DesignApp, ImportRequest, ImportedFile, Inbox, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const DOC_EXTS: &[&str] = &["designcraft", "idml"];
|
||||
@@ -88,13 +88,14 @@ impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let request = self.app.import_request("drop");
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes });
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
@@ -117,12 +118,12 @@ impl eframe::App for WebShell {
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
Services {
|
||||
open_async: Some(Box::new(move |purpose: &str| {
|
||||
open_async: Some(Box::new(move |request: ImportRequest| {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
let dialog = if purpose == "swatches" {
|
||||
let dialog = if request.purpose == "swatches" {
|
||||
rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"])
|
||||
} else if purpose == "place" {
|
||||
} else if request.purpose == "place" {
|
||||
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
|
||||
} else {
|
||||
rfd::AsyncFileDialog::new().add_filter("DesignCraft", DOC_EXTS)
|
||||
@@ -132,7 +133,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name: file.file_name(), bytes });
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
|
||||
@@ -297,10 +297,13 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) {
|
||||
Some(n) if designcraft_render::is_pdf(&bytes) => {
|
||||
let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1);
|
||||
if n == 0 || n as usize > count {
|
||||
return Err(bad("file.place", format!("the PDF has {count} page(s)")));
|
||||
let invalid_page = || bad("file.place", format!("the PDF has {count} page(s)"));
|
||||
let one_based = usize::try_from(n).map_err(|_| invalid_page())?;
|
||||
if one_based == 0 || one_based > count {
|
||||
return Err(invalid_page());
|
||||
}
|
||||
n as u32 - 1
|
||||
let zero_based = one_based.checked_sub(1).ok_or_else(invalid_page)?;
|
||||
u32::try_from(zero_based).map_err(|_| invalid_page())?
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
designcraft-idml = { workspace = true }
|
||||
# Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`.
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
||||
@@ -302,6 +302,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
"ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)),
|
||||
"ui.dialog.cancel" => {
|
||||
app.cancel_pdf_import();
|
||||
app.ui.dialog = None;
|
||||
ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -1072,7 +1072,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"preferences" => preferences(app, ui, &mut d),
|
||||
"print" => print_dialog(app, ui, &mut d),
|
||||
"pdfImport" => {
|
||||
ui.label(egui::RichText::new(d.s("path")).size(11.0));
|
||||
ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0));
|
||||
ui.horizontal(|ui| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page (1–{count}):").replace("{count}", &d.n("pages").unwrap_or(1.0).to_string()));
|
||||
text_field(ui, &mut d, "page", 60.0);
|
||||
@@ -1414,7 +1414,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
Some(true) => {
|
||||
let _ = confirm(app);
|
||||
}
|
||||
Some(false) => app.ui.dialog = None,
|
||||
Some(false) => {
|
||||
app.ui.dialog = None;
|
||||
app.cancel_pdf_import();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -1422,6 +1425,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
/// Apply the open dialog.
|
||||
pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) };
|
||||
if d.id != "pdfImport" || !d.b("async") {
|
||||
app.cancel_pdf_import();
|
||||
}
|
||||
match d.id.as_str() {
|
||||
"newDocument" => {
|
||||
let margins = json!({"top": d.m("marginTop").unwrap_or(36.0), "bottom": d.m("marginBottom").unwrap_or(36.0), "inside": d.m("marginInside").unwrap_or(36.0), "outside": d.m("marginOutside").unwrap_or(36.0)});
|
||||
@@ -1520,6 +1526,12 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
}
|
||||
"pdfImport" => {
|
||||
let crop = d.s("crop");
|
||||
if d.b("async") {
|
||||
return app.confirm_pdf_import(d.n("page").unwrap_or(1.0).max(1.0) as u64, if crop.is_empty() { "crop".into() } else { crop });
|
||||
}
|
||||
if let Some(target) = d.fields.get("target").and_then(Value::as_u64) {
|
||||
app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?;
|
||||
}
|
||||
app.open_file("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
|
||||
}
|
||||
"print" => {
|
||||
|
||||
+351
-20
@@ -38,10 +38,21 @@ pub use control::{ControlRequest, ControlResponse};
|
||||
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
|
||||
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
pub type PickFn = Box<dyn FnMut(&str) -> Option<String>>;
|
||||
pub type OpenAsyncFn = Box<dyn FnMut(&str)>;
|
||||
/// Context captured before any host file-picker or read await.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImportRequest {
|
||||
pub purpose: String,
|
||||
pub target: Option<u64>,
|
||||
}
|
||||
pub struct ImportedFile {
|
||||
pub request: ImportRequest,
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
pub type OpenAsyncFn = Box<dyn FnMut(ImportRequest)>;
|
||||
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
|
||||
/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files).
|
||||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
/// Files delivered asynchronously with their original operation and document identity.
|
||||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<ImportedFile>>>;
|
||||
|
||||
/// Platform services injected by the host (desktop or web).
|
||||
#[derive(Default)]
|
||||
@@ -58,8 +69,7 @@ pub struct Services {
|
||||
/// Hand bytes to the user as a named file (browser download). When set, Save uses it
|
||||
/// instead of writing to a path.
|
||||
pub download: Option<DownloadFn>,
|
||||
/// Files delivered asynchronously, drained every frame: documents ([`opens_as_document`]) →
|
||||
/// `file.openBytes`, `.ase` → `swatch.load`, anything else → `file.place`.
|
||||
/// Files delivered asynchronously, drained every frame with their captured request context.
|
||||
pub inbox: Option<Inbox>,
|
||||
}
|
||||
|
||||
@@ -386,6 +396,7 @@ pub struct Perf {
|
||||
}
|
||||
|
||||
pub struct DesignApp {
|
||||
pending_pdf: Option<ImportedFile>,
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub services: Services,
|
||||
@@ -434,6 +445,7 @@ impl DesignApp {
|
||||
// The font menus and the first file opened need the installed fonts: catalog them now.
|
||||
designcraft_fonts::FontDb::global().scan_in_background();
|
||||
DesignApp {
|
||||
pending_pdf: None,
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
services,
|
||||
@@ -537,6 +549,7 @@ impl DesignApp {
|
||||
/// Ask the host for a file to open (`open`) or place (`place`). Synchronous pickers run the
|
||||
/// command right away; asynchronous ones (web) deliver the file through the inbox.
|
||||
pub fn pick_and_open(&mut self, purpose: &str) -> Result<Value, String> {
|
||||
let target = self.session.active().map(|d| d.uid);
|
||||
let cmd = if purpose == "place" { "file.place" } else { "file.open" };
|
||||
if let Some(pick) = self.services.pick_open.as_mut() {
|
||||
return match pick(purpose) {
|
||||
@@ -545,7 +558,8 @@ impl DesignApp {
|
||||
let pages =
|
||||
self.services.read.as_mut().and_then(|r| r(&path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1);
|
||||
if pages > 1 {
|
||||
self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages})));
|
||||
self.ui.dialog =
|
||||
Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target})));
|
||||
Ok(Value::Null)
|
||||
} else {
|
||||
self.open_file(cmd, json!({"path": path}))
|
||||
@@ -555,33 +569,99 @@ impl DesignApp {
|
||||
None => Ok(Value::Null),
|
||||
};
|
||||
}
|
||||
let request = self.import_request(purpose);
|
||||
if let Some(open) = self.services.open_async.as_mut() {
|
||||
open(purpose);
|
||||
open(request);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Open or place files delivered through the inbox.
|
||||
pub fn import_request(&self, purpose: &str) -> ImportRequest {
|
||||
ImportRequest { purpose: purpose.into(), target: self.session.active().map(|d| d.uid) }
|
||||
}
|
||||
|
||||
pub(crate) fn activate_import_target(&mut self, request: &ImportRequest) -> Result<(), String> {
|
||||
let Some(index) = request.target.and_then(|uid| self.session.documents().iter().position(|d| d.uid == uid)) else {
|
||||
let message = "Import cancelled: the originating document has closed";
|
||||
self.status(message);
|
||||
return Err(message.into());
|
||||
};
|
||||
self.run("file.activate", json!({"index": index}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_pdf_import(&mut self) {
|
||||
self.pending_pdf = None;
|
||||
}
|
||||
|
||||
pub(crate) fn confirm_pdf_import(&mut self, page: u64, crop: String) -> Result<Value, String> {
|
||||
let file = self.pending_pdf.take().ok_or("PDF import cancelled: no pending file")?;
|
||||
self.activate_import_target(&file.request)?;
|
||||
self.open_file(
|
||||
"file.place",
|
||||
json!({"name": file.name, "base64": designcraft_engine::cmd::base64_encode(&file.bytes), "pdfPage": page, "pdfCrop": crop}),
|
||||
)
|
||||
}
|
||||
|
||||
/// A single unresolved PDF owns its bytes. Later deliveries cancel explicitly rather than
|
||||
/// replacing its options. Cancellation, replacement and target closure release its payload.
|
||||
fn drain_inbox(&mut self) {
|
||||
if let Some(file) = &self.pending_pdf {
|
||||
let target_exists = self.session.documents().iter().any(|d| Some(d.uid) == file.request.target);
|
||||
let owns_dialog =
|
||||
self.ui.dialog.as_ref().is_some_and(|d| d.id == "pdfImport" && d.fields.get("async").and_then(Value::as_bool) == Some(true));
|
||||
if !target_exists || !owns_dialog {
|
||||
self.pending_pdf = None;
|
||||
if !target_exists {
|
||||
if owns_dialog {
|
||||
self.ui.dialog = None;
|
||||
}
|
||||
self.status("PDF import cancelled: the originating document has closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(inbox) = self.services.inbox.clone() else { return };
|
||||
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
for (name, bytes) in files {
|
||||
let b64 = designcraft_engine::cmd::base64_encode(&bytes);
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let r = if lower.ends_with(".ase") {
|
||||
self.run("swatch.load", json!({"base64": b64}))
|
||||
} else if opens_as_document(&name) {
|
||||
let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem);
|
||||
self.open_file("file.openBytes", json!({"name": title, "base64": b64}))
|
||||
} else {
|
||||
self.open_file("file.place", json!({"name": name, "base64": b64}))
|
||||
};
|
||||
if let Err(e) = r {
|
||||
for file in files {
|
||||
if self.ui.dialog.is_some() {
|
||||
self.status(format!("{}: import cancelled while a dialog is open; choose the file again", file.name));
|
||||
continue;
|
||||
}
|
||||
let name = file.name.clone();
|
||||
if let Err(e) = self.import_file(file) {
|
||||
self.status(format!("{name}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn import_file(&mut self, file: ImportedFile) -> Result<Value, String> {
|
||||
let lower = file.name.to_ascii_lowercase();
|
||||
let layout = opens_as_document(&file.name);
|
||||
let place =
|
||||
file.request.purpose == "place" || (matches!(file.request.purpose.as_str(), "drop" | "open") && !layout && !lower.ends_with(".ase"));
|
||||
if place || lower.ends_with(".ase") || file.request.purpose == "swatches" {
|
||||
self.activate_import_target(&file.request)?;
|
||||
}
|
||||
if place && lower.ends_with(".pdf") {
|
||||
let pages = designcraft_render::pdf_page_count(&file.bytes).unwrap_or(1);
|
||||
if pages > 1 {
|
||||
self.ui.dialog =
|
||||
Some(dialogs::Dialog::new("pdfImport", json!({"async": true, "name": file.name.clone(), "page": "1", "pages": pages})));
|
||||
self.pending_pdf = Some(file);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
}
|
||||
let b64 = designcraft_engine::cmd::base64_encode(&file.bytes);
|
||||
if lower.ends_with(".ase") || file.request.purpose == "swatches" {
|
||||
self.run("swatch.load", json!({"base64": b64}))
|
||||
} else if place {
|
||||
self.open_file("file.place", json!({"name": file.name, "base64": b64}))
|
||||
} else {
|
||||
let title = file.name.rsplit_once('.').map_or(file.name.as_str(), |(stem, _)| stem);
|
||||
self.open_file("file.openBytes", json!({"name": title, "base64": b64}))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_tool(&mut self, id: &str) {
|
||||
let _ = self.run("tool.select", json!({"tool": id}));
|
||||
self.ui.flyout = None;
|
||||
@@ -1108,3 +1188,254 @@ pub(crate) mod test_window {
|
||||
h.run_steps(4);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod browser_import_tests {
|
||||
use super::*;
|
||||
|
||||
fn app() -> DesignApp {
|
||||
let mut app = DesignApp::new(Session::new(), Services { inbox: Some(Inbox::default()), ..Default::default() });
|
||||
app.run("file.new", json!({"title": "A"})).unwrap();
|
||||
app
|
||||
}
|
||||
fn deliver(app: &mut DesignApp, request: ImportRequest, name: &str, bytes: Vec<u8>) {
|
||||
app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: name.into(), bytes });
|
||||
app.drain_inbox();
|
||||
}
|
||||
fn image() -> Vec<u8> {
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="#ff0000"/></svg>"##.to_vec()
|
||||
}
|
||||
fn pdf_pages(count: usize) -> Vec<u8> {
|
||||
let kids = (0..count).map(|i| format!("{} 0 R", 3 + i * 2)).collect::<Vec<_>>().join(" ");
|
||||
let mut objects = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{kids}] /Count {count} >>")];
|
||||
for (i, color) in ["1 0 0", "0 1 0", "0 0 1"].iter().take(count).enumerate() {
|
||||
let content = format!("{color} rg 0 0 200 200 re f\n");
|
||||
objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents {} 0 R >>", 4 + i * 2));
|
||||
objects.push(format!("<< /Length {} >>\nstream\n{content}endstream", content.len()));
|
||||
}
|
||||
let mut result = "%PDF-1.4\n".to_string();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(result.len());
|
||||
result.push_str(&format!("{} 0 obj\n{object}\nendobj\n", i + 1));
|
||||
}
|
||||
let xref = result.len();
|
||||
result.push_str(&format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1));
|
||||
for offset in offsets {
|
||||
result.push_str(&format!("{offset:010} 00000 n \n"));
|
||||
}
|
||||
result.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1));
|
||||
result.into_bytes()
|
||||
}
|
||||
fn pdf() -> Vec<u8> {
|
||||
pdf_pages(3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_receives_context_before_returning_and_cancel_does_not_edit() {
|
||||
let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let copy = captured.clone();
|
||||
let mut app = app();
|
||||
app.services.open_async = Some(Box::new(move |request| *copy.lock().unwrap() = Some(request)));
|
||||
let uid = app.session.doc().unwrap().uid;
|
||||
app.pick_and_open("place").unwrap();
|
||||
app.run("file.new", json!({"title": "B"})).unwrap();
|
||||
let request = captured.lock().unwrap().take().unwrap();
|
||||
assert_eq!(request.purpose, "place");
|
||||
assert_eq!(request.target, Some(uid));
|
||||
assert!(app.session.documents().iter().all(|d| d.doc.assets.is_empty()));
|
||||
deliver(&mut app, request, "broken.pdf", b"not a PDF".to_vec());
|
||||
assert!(app.session.documents().iter().all(|d| d.doc.assets.is_empty()));
|
||||
assert!(app.pending_pdf.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_place_activates_original_document_and_undo_belongs_to_it() {
|
||||
let mut app = app();
|
||||
let request = app.import_request("place");
|
||||
app.run("file.new", json!({"title": "B"})).unwrap();
|
||||
deliver(&mut app, request, "red.svg", image());
|
||||
assert_eq!(app.session.active_index(), Some(0));
|
||||
assert_eq!(app.session.documents()[0].doc.assets.len(), 1);
|
||||
assert!(app.session.documents()[1].doc.assets.is_empty());
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty());
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc.assets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_target_is_not_replaced_by_a_reused_tab_index() {
|
||||
let mut app = app();
|
||||
let request = app.import_request("place");
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
app.run("file.new", json!({"title": "replacement"})).unwrap();
|
||||
deliver(&mut app, request, "red.svg", image());
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_earlier_tab_does_not_shift_import_target() {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({"title": "target"})).unwrap();
|
||||
let request = app.import_request("place");
|
||||
app.run("file.new", json!({"title": "other"})).unwrap();
|
||||
app.run("file.close", json!({"index": 0})).unwrap();
|
||||
deliver(&mut app, request, "red.svg", image());
|
||||
assert_eq!(app.session.doc().unwrap().doc.title, "target");
|
||||
assert_eq!(app.session.documents()[0].doc.assets.len(), 1);
|
||||
assert!(app.session.documents()[1].doc.assets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_place_and_open_preserve_explicit_intent() {
|
||||
let mut source = Session::new();
|
||||
source.execute("file.new", &json!({"title": "source"})).unwrap();
|
||||
source.execute("frame.create", &json!({"rect": [10, 10, 50, 50], "content": "text", "text": "placed"})).unwrap();
|
||||
let native = designcraft_engine::cmd::to_bytes(&source.doc().unwrap().doc);
|
||||
let idml = designcraft_idml::export_idml(&source.doc().unwrap().doc);
|
||||
for (name, bytes) in [("source.designcraft", native), ("source.idml", idml)] {
|
||||
let mut app = app();
|
||||
let request = app.import_request("place");
|
||||
deliver(&mut app, request, name, bytes.clone());
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
assert_eq!(app.session.doc().unwrap().doc.title, "A");
|
||||
assert!(app.session.doc().unwrap().doc.stories.values().any(|s| s.text == "placed"));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(app.session.doc().unwrap().doc.stories.is_empty());
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert!(!app.session.doc().unwrap().doc.stories.is_empty());
|
||||
let request = app.import_request("open");
|
||||
deliver(&mut app, request, name, bytes);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_options_keep_bytes_target_and_selected_page() {
|
||||
let mut app = app();
|
||||
let bytes = pdf();
|
||||
let request = app.import_request("place");
|
||||
deliver(&mut app, request, "three.pdf", bytes.clone());
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty());
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport");
|
||||
app.run("file.new", json!({"title": "B"})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2"));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.active_index(), Some(0));
|
||||
let asset = app.session.doc().unwrap().doc.assets.values().next().unwrap();
|
||||
assert_eq!(asset.page, 1);
|
||||
assert_eq!(*asset.data, bytes);
|
||||
assert!(app.pending_pdf.is_none());
|
||||
assert!(app.session.documents()[1].doc.assets.is_empty());
|
||||
let exported = app.run("file.exportPdf", json!({})).unwrap();
|
||||
let exported = designcraft_engine::cmd::base64_decode(exported["base64"].as_str().unwrap());
|
||||
let pixels = designcraft_render::render_pdf_page(&exported, 0, 400).unwrap();
|
||||
assert!(pixels.data().iter().filter(|p| p.g > 240 && p.r < 10 && p.b < 10).count() > 1000);
|
||||
assert_eq!(pixels.data().iter().filter(|p| p.r > 240 && p.g < 10 && p.b < 10).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_options_reject_oversized_page_without_editing() {
|
||||
let mut app = app();
|
||||
for page in [0_u64, 4, 4_294_967_296, 4_294_967_297, u64::MAX] {
|
||||
let request = app.import_request("place");
|
||||
deliver(&mut app, request, "three.pdf", pdf());
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!(page.to_string()));
|
||||
// The native dialog clamps zero to one; positive invalid pages must fail.
|
||||
if page == 0 {
|
||||
assert!(app.confirm_pdf_import(page, "crop".into()).is_err());
|
||||
} else {
|
||||
assert!(dialogs::confirm(&mut app).is_err());
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty());
|
||||
assert!(app.pending_pdf.is_none());
|
||||
}
|
||||
let request = app.import_request("place");
|
||||
deliver(&mut app, request, "three.pdf", pdf());
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2"));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc.assets.values().next().unwrap().page, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_routing_and_nonlayout_open_remain_compatible() {
|
||||
let mut app = app();
|
||||
let native = designcraft_engine::cmd::to_bytes(&app.session.doc().unwrap().doc);
|
||||
let request = app.import_request("drop");
|
||||
deliver(&mut app, request, "document.designcraft", native);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
let request = app.import_request("drop");
|
||||
app.run("file.activate", json!({"index": 0})).unwrap();
|
||||
deliver(&mut app, request, "red.svg", image());
|
||||
assert_eq!(app.session.active_index(), Some(1));
|
||||
assert_eq!(app.session.doc().unwrap().doc.assets.len(), 1);
|
||||
let request = app.import_request("open");
|
||||
deliver(&mut app, request, "single.pdf", pdf_pages(1));
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert!(app.pending_pdf.is_none());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ase_swatches_keep_purpose_and_target() {
|
||||
let mut source = Session::new();
|
||||
source.execute("file.new", &json!({})).unwrap();
|
||||
source.execute("swatch.create", &json!({"name": "Import Teal", "color": "#108080"})).unwrap();
|
||||
let data = source.execute("swatch.save", &json!({"names": ["Import Teal"]})).unwrap();
|
||||
let bytes = designcraft_engine::cmd::base64_decode(data["base64"].as_str().unwrap());
|
||||
for (purpose, name) in [("swatches", "picker-file"), ("drop", "palette.ase")] {
|
||||
let mut app = app();
|
||||
let request = app.import_request(purpose);
|
||||
app.run("file.new", json!({"title": "B"})).unwrap();
|
||||
deliver(&mut app, request, name, bytes.clone());
|
||||
assert_eq!(app.session.active_index(), Some(0));
|
||||
assert!(app.session.documents()[0].doc.swatches.iter().any(|s| s.name == "Import Teal"));
|
||||
assert!(!app.session.documents()[1].doc.swatches.iter().any(|s| s.name == "Import Teal"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_pdf_dialog_keeps_path_and_uid_at_confirmation() {
|
||||
let mut app = app();
|
||||
let path = std::env::temp_dir().join(format!("designcraft-native-import-{}-{}.pdf", std::process::id(), app.session.doc().unwrap().uid));
|
||||
std::fs::write(&path, pdf()).unwrap();
|
||||
let pick_path = path.to_string_lossy().to_string();
|
||||
app.services.pick_open = Some(Box::new(move |_| Some(pick_path.clone())));
|
||||
app.services.read = Some(Box::new(|path| std::fs::read(path).map_err(|e| e.to_string())));
|
||||
app.pick_and_open("place").unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport");
|
||||
app.run("file.new", json!({"title": "B"})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2"));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.active_index(), Some(0));
|
||||
assert_eq!(app.session.doc().unwrap().doc.assets.values().next().unwrap().page, 1);
|
||||
assert!(app.session.documents()[1].doc.assets.is_empty());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_pdf_cancel_close_supersession_and_second_delivery_release_bytes() {
|
||||
let mut app = app();
|
||||
let request = app.import_request("place");
|
||||
deliver(&mut app, request.clone(), "first.pdf", pdf());
|
||||
deliver(&mut app, request.clone(), "second.svg", image());
|
||||
assert_eq!(app.pending_pdf.as_ref().unwrap().name, "first.pdf");
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty());
|
||||
app.ui.dialog = None;
|
||||
app.drain_inbox();
|
||||
assert!(app.pending_pdf.is_none());
|
||||
deliver(&mut app, request.clone(), "first.pdf", pdf());
|
||||
app.ui.dialog = Some(dialogs::Dialog::new("newDocument", json!({})));
|
||||
app.drain_inbox();
|
||||
assert!(app.pending_pdf.is_none());
|
||||
app.ui.dialog = None;
|
||||
deliver(&mut app, request, "first.pdf", pdf());
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
assert!(dialogs::confirm(&mut app).is_err());
|
||||
assert!(app.pending_pdf.is_none());
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -858,8 +858,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
None => Ok(Value::Null),
|
||||
});
|
||||
}
|
||||
let request = app.import_request("swatches");
|
||||
if let Some(open) = app.services.open_async.as_mut() {
|
||||
open("swatches");
|
||||
open(request);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user