From 4c6aa4cd4b1b1c516cf88226a89ee760d2780140 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 9 Oct 2026 23:13:23 -0600 Subject: [PATCH] browser-correctness: fix print, startup flags, and document MIME (#117) --- Cargo.lock | 73 ++++++++++++++++++++++ apps/designcraft-web/Cargo.toml | 2 + apps/designcraft-web/src/browser_policy.rs | 67 ++++++++++++++++++++ apps/designcraft-web/src/main.rs | 6 +- apps/designcraft-web/src/web.rs | 16 +---- crates/engine/Cargo.toml | 3 + crates/engine/src/cmd/print.rs | 49 ++++++++------- crates/engine/tests/browser_print.rs | 31 +++++++++ 8 files changed, 211 insertions(+), 36 deletions(-) create mode 100644 apps/designcraft-web/src/browser_policy.rs create mode 100644 crates/engine/tests/browser_print.rs diff --git a/Cargo.lock b/Cargo.lock index cfc7d351..6473b84f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -635,6 +635,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.5.1" @@ -952,6 +958,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "wasm-bindgen-test", "zip", ] @@ -1126,9 +1133,11 @@ name = "designcraft-web" version = "0.5.0" dependencies = [ "designcraft-engine", + "designcraft-format", "designcraft-ui-egui", "eframe", "egui", + "form_urlencoded", "js-sys", "log", "rfd", @@ -2550,6 +2559,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2673,6 +2692,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3022,6 +3050,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "open" version = "5.4.4" @@ -4746,6 +4780,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe" + [[package]] name = "wayland-backend" version = "0.3.17" diff --git a/apps/designcraft-web/Cargo.toml b/apps/designcraft-web/Cargo.toml index d27b3c41..75775c31 100644 --- a/apps/designcraft-web/Cargo.toml +++ b/apps/designcraft-web/Cargo.toml @@ -15,6 +15,8 @@ path = "src/main.rs" [dependencies] designcraft-engine = { workspace = true } +designcraft-format = { workspace = true } +form_urlencoded = "1.2" designcraft-ui-egui = { workspace = true } egui = { workspace = true } log = { workspace = true } diff --git a/apps/designcraft-web/src/browser_policy.rs b/apps/designcraft-web/src/browser_policy.rs new file mode 100644 index 00000000..524df6cc --- /dev/null +++ b/apps/designcraft-web/src/browser_policy.rs @@ -0,0 +1,67 @@ +//! Pure browser startup and download metadata policies. + +/// Query flags are case-sensitive decoded keys. Values are ignored: `?sample=false` +/// still enables sample startup, and repeated keys have the same presence semantics. +pub(crate) fn has_query_flag(query: &str, flag: &str) -> bool { + let query = query.strip_prefix('?').unwrap_or(query); + form_urlencoded::parse(query.as_bytes()).any(|(key, _)| key == flag) +} + +pub(crate) fn mime_for(name: &str) -> &'static str { + match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() { + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("pdf") => "application/pdf", + Some("designcraft") => designcraft_format::MIME, + Some("idml") => "application/vnd.adobe.indesign-idml-package", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::{has_query_flag, mime_for}; + + #[test] + fn startup_flags_match_complete_decoded_keys() { + for flag in ["sample", "webgl"] { + for query in [format!("?{flag}"), format!("?{flag}=false"), format!("?{flag}=1&{flag}=0"), format!("?other=value&{flag}")] { + assert!(has_query_flag(&query, flag), "{query}"); + } + for query in [ + String::new(), + format!("?note={flag}"), + format!("?{flag}d=1"), + format!("?note=not_a_{flag}"), + "?note=%73ample%77ebgl".to_string(), + format!("?{flag}+extra=1"), + format!("?{flag}%26extra=1"), + format!("?{flag}%3Dextra=1"), + ] { + assert!(!has_query_flag(&query, flag), "{query}"); + } + } + assert!(has_query_flag("?webgl&sample", "sample")); + assert!(has_query_flag("?webgl&sample", "webgl")); + assert!(has_query_flag("?%73ample", "sample")); + assert!(has_query_flag("?%77ebgl", "webgl")); + assert!(!has_query_flag("?Sample&WEBGL", "sample")); + assert!(!has_query_flag("?Sample&WEBGL", "webgl")); + } + + #[test] + fn download_mime_matches_native_package_and_existing_exports() { + for (name, expected) in [ + ("Quarterly.designcraft", designcraft_format::MIME), + ("日本語.multi.DEsignCraft", designcraft_format::MIME), + ("page.pdf", "application/pdf"), + ("page.png", "image/png"), + ("page.jpg", "image/jpeg"), + ("page.JPEG", "image/jpeg"), + ("document.idml", "application/vnd.adobe.indesign-idml-package"), + ("unknown.bin", "application/octet-stream"), + ] { + assert_eq!(mime_for(name), expected, "{name}"); + } + } +} diff --git a/apps/designcraft-web/src/main.rs b/apps/designcraft-web/src/main.rs index ed05d595..527baff4 100644 --- a/apps/designcraft-web/src/main.rs +++ b/apps/designcraft-web/src/main.rs @@ -12,9 +12,13 @@ //! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox. //! //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?sample` opens the -//! sample magazine on start. +//! sample magazine on start. Flags match case-sensitive, percent-decoded query keys; values +//! are ignored and repeated keys have the same presence semantics (`?sample=false` enables it). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(any(target_arch = "wasm32", test))] +mod browser_policy; + #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index c0466b69..3879f083 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -1,5 +1,6 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. +use super::browser_policy::{has_query_flag, mime_for}; use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, Inbox, Services}; use wasm_bindgen::JsCast as _; @@ -41,7 +42,7 @@ pub fn start() { return; }; let mut options = eframe::WebOptions::default(); - if query().contains("webgl") + if has_query_flag(&query(), "webgl") && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { create.instance_descriptor.backends = eframe::wgpu::Backends::GL; @@ -56,7 +57,7 @@ pub fn start() { } let inbox: Inbox = Inbox::default(); let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); - if query().contains("sample") { + if has_query_flag(&query(), "sample") { let _ = app.run("file.newSample", serde_json::json!({})); } Ok(Box::new(WebShell { app, inbox })) @@ -173,14 +174,3 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?; Ok(()) } - -fn mime_for(name: &str) -> &'static str { - match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() { - Some("png") => "image/png", - Some("jpg" | "jpeg") => "image/jpeg", - Some("pdf") => "application/pdf", - Some("designcraft") => "application/json", - Some("idml") => "application/vnd.adobe.indesign-idml-package", - _ => "application/octet-stream", - } -} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ff02d62b..241fd281 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -36,3 +36,6 @@ hayro-syntax = "0.7.2" hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } hayro-cmap = "0.1" kurbo = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index bbae1b38..b944b623 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -2,8 +2,12 @@ use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; -use crate::{EngineError, Result, Session}; +#[cfg(not(target_arch = "wasm32"))] +use super::str_param; +use super::{CommandSpec, bad, cmd, has_doc}; +#[cfg(not(target_arch = "wasm32"))] +use crate::EngineError; +use crate::{Result, Session}; pub fn specs() -> Vec { vec![ @@ -39,31 +43,32 @@ fn printers() -> Value { fn print(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; let opts = super::export::options(p, d.page_count())?; - let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999); - let printer = str_param(p, "printer").map(str::to_string); - let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); - let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - let file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_"))); - let mut cmd: Vec = vec!["lpr".into()]; - if let Some(pr) = &printer { - cmd.extend(["-P".into(), pr.clone()]); - } - if copies > 1 { - cmd.extend(["-#".into(), copies.to_string()]); - } - cmd.extend(["-T".into(), d.title.clone(), file.to_string_lossy().to_string()]); - let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len()); - let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()}); - if dry { - return Ok(out); - } #[cfg(target_arch = "wasm32")] { - let _ = pdf; + // Even a dry run cannot construct a native spool command in the browser. + let _ = opts; Err(bad("file.print", "printing isn't available on the web: export a PDF and print it")) } #[cfg(not(target_arch = "wasm32"))] { + let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999); + let printer = str_param(p, "printer").map(str::to_string); + let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_"))); + let mut cmd: Vec = vec!["lpr".into()]; + if let Some(pr) = &printer { + cmd.extend(["-P".into(), pr.clone()]); + } + if copies > 1 { + cmd.extend(["-#".into(), copies.to_string()]); + } + cmd.extend(["-T".into(), d.title.clone(), file.to_string_lossy().to_string()]); + let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len()); + let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()}); + if dry { + return Ok(out); + } std::fs::write(&file, &pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.display())))?; let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?; if !st.status.success() { @@ -73,7 +78,7 @@ fn print(s: &mut Session, p: &Value) -> Result { } } -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use serde_json::json; diff --git a/crates/engine/tests/browser_print.rs b/crates/engine/tests/browser_print.rs new file mode 100644 index 00000000..fda224b9 --- /dev/null +++ b/crates/engine/tests/browser_print.rs @@ -0,0 +1,31 @@ +//! Browser Print must reject native spooler operations without trapping the editor. +#![cfg(target_arch = "wasm32")] + +use designcraft_engine::Session; +use serde_json::json; +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + +wasm_bindgen_test_configure!(run_in_browser); + +fn print_is_safe(dry_run: bool) { + let mut session = Session::new(); + session.execute("file.new", &json!({"pages": 1})).unwrap(); + let error = session.execute("file.print", &json!({"dryRun": dry_run})).unwrap_err().to_string(); + assert!(error.contains("printing isn't available on the web"), "{error}"); + assert!(error.contains("export a PDF"), "{error}"); + session.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text"})).unwrap(); + let exported = session.execute("file.exportPdf", &json!({})).unwrap(); + assert!(exported["bytes"].as_u64().unwrap() > 500); + assert_eq!(exported["pages"], 1); + assert!(session.execute("file.print", &json!({"pages": "9", "dryRun": dry_run})).is_err()); +} + +#[wasm_bindgen_test] +fn print_confirmation_preserves_editing_and_pdf_export() { + print_is_safe(false); +} + +#[wasm_bindgen_test] +fn print_dry_run_is_also_unsupported_without_native_operations() { + print_is_safe(true); +}