browser-correctness: fix print, startup flags, and document MIME (#117)

This commit is contained in:
skylar taylor-barrick authored and GitHub committed 2026-10-10 01:13:23 -04:00
1 parent 788e0eac66
commit 4c6aa4cd4b
8 files changed
+211 -36

No files matched your search

Generated
+73
View File
@@ -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"
+2
View File
@@ -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 }
@@ -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}");
}
}
}
+5 -1
View File
@@ -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;
+3 -13
View File
@@ -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",
}
}
+3
View File
@@ -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"
+27 -22
View File
@@ -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<CommandSpec> {
vec![
@@ -39,31 +43,32 @@ fn printers() -> Value {
fn print(s: &mut Session, p: &Value) -> Result<Value> {
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<String> = 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<String> = 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<Value> {
}
}
#[cfg(test)]
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use serde_json::json;
+31
View File
@@ -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);
}