diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 9b6483852..92e7d7dec 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -8,8 +8,9 @@ // The function is async so the UI remains responsive while the job is queued. // // The web build has no system printers: `preview_page_pdf` / -// `preview_pages_pdf` render the same PDF bytes and open them in a browser -// tab, where the browser's own print dialog takes over. +// `preview_pages_pdf` render the same PDF bytes, rasterise them, and show +// the pages in an in-page overlay — the way AutoCAD previews plot output on +// screen rather than handing a file to an external viewer. #[cfg(not(target_arch = "wasm32"))] use crate::io::pdf_export; @@ -238,9 +239,7 @@ mod printer_buffer_tests { /// Build the platform printer-properties command. #[cfg(not(target_arch = "wasm32"))] fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec) { - let named = printer - .map(str::trim) - .filter(|name| !name.is_empty()); + let named = printer.map(str::trim).filter(|name| !name.is_empty()); #[cfg(target_os = "windows")] let command = match named { @@ -337,7 +336,7 @@ pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> { } /// Render a page and show it the way each platform previews: the OS PDF -/// viewer on desktop, a new browser tab on the web. +/// viewer on desktop, an in-page rasterised overlay on the web. pub fn preview_page_pdf(page: &crate::io::pdf_export::PdfPageInput) -> Result<(), String> { #[cfg(not(target_arch = "wasm32"))] { @@ -348,34 +347,59 @@ pub fn preview_page_pdf(page: &crate::io::pdf_export::PdfPageInput) -> Result<() #[cfg(target_arch = "wasm32")] { let bytes = crate::io::pdf_export::render_pdf_bytes(std::slice::from_ref(page), None)?; - crate::sys::open_bytes_in_new_tab("plot_preview.pdf", &bytes); - Ok(()) + web_preview_overlay(&bytes) } } /// Web-only multi-page variant used by print-all: render every layout into -/// one PDF and open it in a browser tab. +/// one PDF and preview the first pages in the overlay. #[cfg(target_arch = "wasm32")] pub fn preview_pages_pdf(pages: &[crate::io::pdf_export::PdfPageInput]) -> Result<(), String> { let bytes = crate::io::pdf_export::render_pdf_bytes(pages, None)?; - crate::sys::open_bytes_in_new_tab("plot_preview.pdf", &bytes); + web_preview_overlay(&bytes) +} + +/// Web preview: rasterise the plotted PDF (the same hayro pipeline that draws +/// PDF underlays) and show the pages in an in-page overlay. The browser gets +/// plain PNGs, so neither popup permission nor a PDF viewer plugin is needed. +#[cfg(target_arch = "wasm32")] +fn web_preview_overlay(bytes: &[u8]) -> Result<(), String> { + const PREVIEW_DPI: f32 = 150.0; + const MAX_PREVIEW_PAGES: usize = 12; + let pages = crate::scene::model::pdf_raster::rasterize_bytes_pages( + std::sync::Arc::new(bytes.to_vec()), + PREVIEW_DPI, + MAX_PREVIEW_PAGES, + ); + let mut pngs = Vec::with_capacity(pages.len()); + for page in &pages { + let encoded = + image::RgbaImage::from_raw(page.width, page.height, page.pixels.as_ref().clone()) + .and_then(|img| { + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .ok()?; + Some(out) + }); + if let Some(png) = encoded { + pngs.push(png); + } + } + crate::sys::show_pdf_preview("plot_preview.pdf", bytes, &pngs); Ok(()) } /// Dispatch a PDF to a specific printer with [`PrintOptions`]. #[cfg(not(target_arch = "wasm32"))] -fn dispatch_to_printer_opts( - path: &std::path::Path, - opts: &PrintOptions, -) -> Result { +fn dispatch_to_printer_opts(path: &std::path::Path, opts: &PrintOptions) -> Result { #[cfg(target_os = "windows")] { use std::ffi::OsStr; use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Foundation::{GetLastError, ERROR_NO_ASSOCIATION}; use windows_sys::Win32::UI::Shell::{ - ShellExecuteExW, SHELLEXECUTEINFOW, SEE_MASK_FLAG_NO_UI, SEE_MASK_NOASYNC, - SE_ERR_NOASSOC, + ShellExecuteExW, SEE_MASK_FLAG_NO_UI, SEE_MASK_NOASYNC, SE_ERR_NOASSOC, + SHELLEXECUTEINFOW, }; use windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE; @@ -385,7 +409,10 @@ fn dispatch_to_printer_opts( Some(p) if !p.is_empty() => (wide("printto"), Some(wide(p)), p.to_string()), _ => (wide("print"), None, "default printer".to_string()), }; - let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null()); + let params_ptr = params + .as_ref() + .map(|v| v.as_ptr()) + .unwrap_or(std::ptr::null()); for _ in 0..opts.copies.max(1) { let mut info = SHELLEXECUTEINFOW { cbSize: std::mem::size_of::() as u32, @@ -436,20 +463,17 @@ fn dispatch_to_printer_opts( }; cmd.arg("-o").arg(format!("print-quality={pq}")); } - let lp_result = cmd - .arg("--") - .arg(path_str.as_ref()) - .output(); + let lp_result = cmd.arg("--").arg(path_str.as_ref()).output(); if let Ok(out) = &lp_result { if !out.status.success() { // Continue to the lpr fallback below. } else { - let msg = String::from_utf8_lossy(&out.stdout); - let printer = msg - .split_whitespace() - .find(|w| w.contains('-')) - .unwrap_or("printer") - .to_string(); + let msg = String::from_utf8_lossy(&out.stdout); + let printer = msg + .split_whitespace() + .find(|w| w.contains('-')) + .unwrap_or("printer") + .to_string(); return Ok(printer); } } @@ -515,9 +539,7 @@ mod printer_properties_tests { fn macos_opens_print_settings() { let expected = ( "open", - vec![ - "x-apple.systempreferences:com.apple.Print-Scan-Settings.extension".to_string(), - ], + vec!["x-apple.systempreferences:com.apple.Print-Scan-Settings.extension".to_string()], ); assert_eq!( printer_properties_command(Some(" Office LaserJet ")), diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index d19c22113..85ce75801 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -67,8 +67,30 @@ fn rasterize_uncached(path: &str, page: &str) -> Option> { let bytes = source_bytes(path)?; let pdf = Pdf::new(bytes).ok()?; let page_no = page.trim().parse::().unwrap_or(1).max(1); - let page = pdf.pages().get(page_no - 1)?; - let scale = RASTER_DPI / 72.0; + let pixmap = raster_page(&pdf, page_no - 1, RASTER_DPI)?; + let RasterizedPage { + pixels, + width, + height, + } = pixmap; + Some(Arc::new(PdfPage { + pixels, + width, + height, + dpi: RASTER_DPI, + })) +} + +struct RasterizedPage { + pixels: Arc>, + width: u32, + height: u32, +} + +/// Rasterise one 0-based page index at `dpi` onto a white background. +fn raster_page(pdf: &Pdf, index: usize, dpi: f32) -> Option { + let page = pdf.pages().get(index)?; + let scale = dpi / 72.0; let pixmap = hayro::render( page, &RenderCache::new(), @@ -80,14 +102,32 @@ fn rasterize_uncached(path: &str, page: &str) -> Option> { ..Default::default() }, ); - let width = u32::from(pixmap.width()); - let height = u32::from(pixmap.height()); - Some(Arc::new(PdfPage { + Some(RasterizedPage { pixels: Arc::new(pixmap.data_as_u8_slice().to_vec()), - width, - height, - dpi: RASTER_DPI, - })) + width: u32::from(pixmap.width()), + height: u32::from(pixmap.height()), + }) +} + +/// One-shot rasterisation of freshly generated PDF bytes (plot preview). +/// Uncached: the bytes differ on every plot, so a cache would only grow. +/// Returns at most `max_pages` pages; larger plots preview their first pages. +pub fn rasterize_bytes_pages(bytes: Arc>, dpi: f32, max_pages: usize) -> Vec> { + let Ok(pdf) = Pdf::new(bytes) else { + return Vec::new(); + }; + let count = pdf.pages().len().min(max_pages); + (0..count) + .filter_map(|index| { + let page = raster_page(&pdf, index, dpi)?; + Some(Arc::new(PdfPage { + pixels: page.pixels, + width: page.width, + height: page.height, + dpi, + })) + }) + .collect() } fn source_bytes(path: &str) -> Option>> { @@ -131,4 +171,23 @@ mod tests { assert_eq!((page.width, page.height), (150, 150)); assert_eq!(page.pixels.len(), 150 * 150 * 4); } + + #[test] + fn bytes_pages_render_every_page_up_to_the_cap() { + let mut document = PdfDocument::new("multi-page"); + for _ in 0..3 { + document + .pages + .push(OutputPage::new(Mm(50.8), Mm(25.4), Vec::new())); + } + let bytes = Arc::new(document.save(&PdfSaveOptions::default(), &mut Vec::new())); + + let all = rasterize_bytes_pages(bytes.clone(), 72.0, 10); + assert_eq!(all.len(), 3); + assert_eq!((all[0].width, all[0].height), (144, 72)); + assert_eq!(all[0].dpi, 72.0); + + let capped = rasterize_bytes_pages(bytes, 72.0, 2); + assert_eq!(capped.len(), 2); + } } diff --git a/src/sys.rs b/src/sys.rs index a166944e7..68838fc81 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -7,8 +7,7 @@ pub(crate) fn web_hyperlink(value: &str) -> Option { return None; } let url = url::Url::parse(value.trim()).ok()?; - (matches!(url.scheme(), "http" | "https") && url.host_str().is_some()) - .then(|| url.into()) + (matches!(url.scheme(), "http" | "https") && url.host_str().is_some()).then(|| url.into()) } #[test] @@ -17,9 +16,15 @@ fn drawing_hyperlinks_only_open_web_pages() { assert!(web_hyperlink(value).is_some(), "{value}"); } for value in [ - "", "https://", "javascript:alert(1)", "data:text/html,example", - "file:///tmp/program.desktop", "/tmp/program.desktop", "custom:run", - "mailto:user@example.com", "https://example.com/\npath", + "", + "https://", + "javascript:alert(1)", + "data:text/html,example", + "file:///tmp/program.desktop", + "/tmp/program.desktop", + "custom:run", + "mailto:user@example.com", + "https://example.com/\npath", ] { assert!(web_hyperlink(value).is_none(), "{value}"); } @@ -52,14 +57,10 @@ pub fn open_url( fn linux_activation_token(window: &dyn iced::window::Window) -> Option { use iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle}; - let RawWindowHandle::Wayland(window_handle) = - window.window_handle().ok()?.as_raw() - else { + let RawWindowHandle::Wayland(window_handle) = window.window_handle().ok()?.as_raw() else { return None; }; - let RawDisplayHandle::Wayland(display_handle) = - window.display_handle().ok()?.as_raw() - else { + let RawDisplayHandle::Wayland(display_handle) = window.display_handle().ok()?.as_raw() else { return None; }; @@ -67,13 +68,11 @@ fn linux_activation_token(window: &dyn iced::window::Window) -> Option { // The returned token is an owned string and is immediately handed to the // child process. unsafe { - iced::futures::executor::block_on( - ashpd::ActivationToken::from_wayland_raw( - None, - window_handle.surface.as_ptr(), - display_handle.display.as_ptr(), - ), - ) + iced::futures::executor::block_on(ashpd::ActivationToken::from_wayland_raw( + None, + window_handle.surface.as_ptr(), + display_handle.display.as_ptr(), + )) } .map(String::from) } @@ -101,10 +100,7 @@ fn open_url_linux(url: &str, activation_token: Option) { } #[cfg(all(not(target_arch = "wasm32"), not(target_os = "linux")))] -pub fn open_url( - url: &str, - _parent: Option, -) -> iced::Task { +pub fn open_url(url: &str, _parent: Option) -> iced::Task { let _ = open::that_detached(url); iced::Task::none() } @@ -112,10 +108,7 @@ pub fn open_url( /// Web opens the tab synchronously so the browser still sees the click as a /// user gesture and does not block it as a pop-up. #[cfg(target_arch = "wasm32")] -pub fn open_url( - url: &str, - _parent: Option, -) -> iced::Task { +pub fn open_url(url: &str, _parent: Option) -> iced::Task { if let Some(window) = web_sys::window() { let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer"); } @@ -148,12 +141,11 @@ pub fn set_unsaved_changes_warning(active: bool) { return; } if active { - let callback = wasm_bindgen::closure::Closure::new( - move |event: web_sys::BeforeUnloadEvent| { + let callback = + wasm_bindgen::closure::Closure::new(move |event: web_sys::BeforeUnloadEvent| { event.prevent_default(); event.set_return_value(""); - }, - ); + }); window.set_onbeforeunload(Some(callback.as_ref().unchecked_ref())); *warning = Some(callback); } else { @@ -254,7 +246,11 @@ pub async fn read_clipboard_text() -> Option { #[wasm_bindgen::prelude::wasm_bindgen(module = "/web/clipboard.js")] extern "C" { #[wasm_bindgen::prelude::wasm_bindgen(js_name = copyHistory)] - pub fn copy_history_text(text: &str, fallback_label: &str, close_label: &str) -> js_sys::Promise; + pub fn copy_history_text( + text: &str, + fallback_label: &str, + close_label: &str, + ) -> js_sys::Promise; } /// Web: write text to the system clipboard (fire-and-forget). Backs Ctrl+C in @@ -286,9 +282,7 @@ pub fn synthesize_typing(text: &str) { init.set_bubbles(true); init.set_cancelable(true); for kind in ["keydown", "keyup"] { - if let Ok(ev) = - web_sys::KeyboardEvent::new_with_keyboard_event_init_dict(kind, &init) - { + if let Ok(ev) = web_sys::KeyboardEvent::new_with_keyboard_event_init_dict(kind, &init) { let _ = canvas.dispatch_event(&ev); } } @@ -353,8 +347,12 @@ pub fn file_dialog() -> rfd::AsyncFileDialog { #[cfg(target_arch = "wasm32")] pub fn download_bytes(name: &str, bytes: &[u8]) { use wasm_bindgen::JsCast; - let Some(window) = web_sys::window() else { return }; - let Some(document) = window.document() else { return }; + let Some(window) = web_sys::window() else { + return; + }; + let Some(document) = window.document() else { + return; + }; let array = js_sys::Uint8Array::from(bytes); let parts = js_sys::Array::new(); parts.push(&array.buffer()); @@ -373,40 +371,133 @@ pub fn download_bytes(name: &str, bytes: &[u8]) { let _ = web_sys::Url::revoke_object_url(&url); } -/// Open `bytes` (e.g. a generated PDF) in a new browser tab. Web only. -/// Falls back to a download when the popup is blocked, so the output is -/// never lost. Used for plot preview and as the web stand-in for printing. +/// Show a plot preview as an in-page overlay. The plotted PDF arrives already +/// rasterised to PNG pages: inline `` elements render in every browser, +/// with no popup permission and no PDF viewer plugin — both of which embedded +/// browser hosts routinely withhold. A Download button still hands out the +/// vector PDF, Close drops the overlay. Web only. #[cfg(target_arch = "wasm32")] -pub fn open_bytes_in_new_tab(name: &str, bytes: &[u8]) { +pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec]) { use wasm_bindgen::JsCast; - let Some(window) = web_sys::window() else { return }; - let array = js_sys::Uint8Array::from(bytes); - let parts = js_sys::Array::new(); - parts.push(&array.buffer()); - let Ok(blob) = web_sys::Blob::new_with_u8_array_sequence(&parts) else { + let Some(window) = web_sys::window() else { return; }; - let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else { + let Some(document) = window.document() else { return; }; - let tab = window.open_with_url_and_target(&url, "_blank").ok().flatten(); - if tab.is_none() { - // Popup blocked: free the URL right away and fall back to a plain - // download so the rendered output is never lost. - let _ = web_sys::Url::revoke_object_url(&url); - download_bytes(name, bytes); + if page_pngs.is_empty() { + // Rasterisation produced nothing: deliver the PDF itself rather than + // show an empty frame. + download_bytes(name, pdf_bytes); return; } - // The new tab loads the blob asynchronously, so the URL must outlive this - // call; revoke it a minute later, once the viewer has the content. The - // one-shot closure is owned by the timer, not stored Rust-side. - let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { - let _ = web_sys::Url::revoke_object_url(&url); - }); - let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( - revoke.unchecked_ref(), - 60_000, - ); + let Ok(el) = document.create_element("div") else { + download_bytes(name, pdf_bytes); + return; + }; + let backdrop: web_sys::HtmlElement = el.unchecked_into(); + backdrop + .set_attribute( + "style", + "position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:30000;overflow:auto;\ + display:flex;flex-direction:column;align-items:center;gap:14px;padding:24px;\ + font-family:system-ui,sans-serif;color:#ddd", + ) + .ok(); + + let mut urls = Vec::new(); + for png in page_pngs { + let array = js_sys::Uint8Array::from(png.as_slice()); + let parts = js_sys::Array::new(); + parts.push(&array.buffer()); + let Ok(blob) = web_sys::Blob::new_with_u8_array_sequence(&parts) else { + continue; + }; + let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else { + continue; + }; + if let Ok(el) = document.create_element("img") { + let img: web_sys::HtmlImageElement = el.unchecked_into(); + img.set_src(&url); + img.set_attribute( + "style", + "max-width:min(92vw,1000px);height:auto;background:#fff;\ + box-shadow:0 2px 14px rgba(0,0,0,.6)", + ) + .ok(); + backdrop.append_child(&img).ok(); + urls.push(url); + } + } + if urls.is_empty() { + download_bytes(name, pdf_bytes); + return; + } + + // Sticky action bar so Download/Close stay reachable while pages scroll. + let bar: web_sys::HtmlElement = document + .create_element("div") + .ok() + .and_then(|el| { + let bar: web_sys::HtmlElement = el.unchecked_into(); + bar.set_attribute( + "style", + "position:sticky;top:0;display:flex;gap:12px;align-items:center;\ + background:rgba(0,0,0,.88);padding:4px 8px", + ) + .ok()?; + Some(bar) + }) + .unwrap_or_else(|| backdrop.clone()); + if let Ok(el) = document.create_element("span") { + let label: web_sys::HtmlElement = el.unchecked_into(); + label.set_inner_text(&format!( + "Plot preview — {} page{}", + urls.len(), + if urls.len() == 1 { "" } else { "s" } + )); + bar.append_child(&label).ok(); + } + let button = |text: &str| -> Option { + let el = document.create_element("button").ok()?; + let btn: web_sys::HtmlElement = el.unchecked_into(); + btn.set_inner_text(text); + btn.set_attribute( + "style", + "padding:8px 18px;border:0;border-radius:4px;cursor:pointer;\ + background:#3399e6;color:#fff;font-size:14px", + ) + .ok()?; + Some(btn) + }; + if let Some(dl) = button("Download PDF") { + let name = name.to_string(); + let bytes = pdf_bytes.to_vec(); + let click = wasm_bindgen::closure::Closure::once_into_js(move || { + download_bytes(&name, &bytes); + }); + let click: js_sys::Function = click.unchecked_into(); + dl.set_onclick(Some(&click)); + bar.append_child(&dl).ok(); + } + if let Some(close) = button("Close") { + let backdrop = backdrop.clone(); + let click = wasm_bindgen::closure::Closure::once_into_js(move || { + for url in &urls { + let _ = web_sys::Url::revoke_object_url(url); + } + backdrop.remove(); + }); + let click: js_sys::Function = click.unchecked_into(); + close.set_onclick(Some(&click)); + bar.append_child(&close).ok(); + } + if let Some(first) = backdrop.first_child() { + let _ = backdrop.insert_before(&bar, Some(&first)); + } + if let Some(body) = document.body() { + body.append_child(&backdrop).ok(); + } } /// Short platform string for bug reports: OS + architecture on the desktop, @@ -519,7 +610,10 @@ pub mod web_diag { ); let _ = body.append_child(&overlay); for (id, label) in [ - ("ocs-err-title", crate::t!("OpenCADStudio renderer error — copy this into a bug report:")), + ( + "ocs-err-title", + crate::t!("OpenCADStudio renderer error — copy this into a bug report:"), + ), ("ocs-err-copy", crate::t!("Copy")), ("ocs-err-dismiss", crate::t!("Dismiss")), ] {