web: render plot preview in-page instead of a new tab

AutoCAD's plot preview renders the plotted result on screen instead of
producing a file. The web build now does the same: the plotted PDF bytes
are rasterised with the hayro pipeline already used for PDF underlays
(150 DPI, first 12 pages) and shown in an in-page overlay with
Download-PDF and Close actions.

The previous strategy opened a new tab via window.open from the async
plot task; browsers withhold popups without a live user gesture, and
embedded hosts ship no PDF viewer plugin at all — so the fallback
download fired and preview behaved like an export. Plain PNG <img>
elements need neither permission.
This commit is contained in:
Afonsop committed 2026-09-15 19:51:11 +01:00
1 parent e3c86eca29
commit 8722d84910
3 files changed
+277 -102

No files matched your search

+52 -30
View File
@@ -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<String>) {
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<String, String> {
fn dispatch_to_printer_opts(path: &std::path::Path, opts: &PrintOptions) -> Result<String, String> {
#[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::<SHELLEXECUTEINFOW>() 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 ")),
+68 -9
View File
@@ -67,8 +67,30 @@ fn rasterize_uncached(path: &str, page: &str) -> Option<Arc<PdfPage>> {
let bytes = source_bytes(path)?;
let pdf = Pdf::new(bytes).ok()?;
let page_no = page.trim().parse::<usize>().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<Vec<u8>>,
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<RasterizedPage> {
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<Arc<PdfPage>> {
..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<Vec<u8>>, dpi: f32, max_pages: usize) -> Vec<Arc<PdfPage>> {
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<Arc<Vec<u8>>> {
@@ -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);
}
}
+157 -63
View File
@@ -7,8 +7,7 @@ pub(crate) fn web_hyperlink(value: &str) -> Option<String> {
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<Message: Send + 'static>(
fn linux_activation_token(window: &dyn iced::window::Window) -> Option<String> {
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<String> {
// 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<String>) {
}
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "linux")))]
pub fn open_url<Message>(
url: &str,
_parent: Option<iced::window::Id>,
) -> iced::Task<Message> {
pub fn open_url<Message>(url: &str, _parent: Option<iced::window::Id>) -> iced::Task<Message> {
let _ = open::that_detached(url);
iced::Task::none()
}
@@ -112,10 +108,7 @@ pub fn open_url<Message>(
/// 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<Message>(
url: &str,
_parent: Option<iced::window::Id>,
) -> iced::Task<Message> {
pub fn open_url<Message>(url: &str, _parent: Option<iced::window::Id>) -> iced::Task<Message> {
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<String> {
#[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 `<img>` 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<u8>]) {
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<web_sys::HtmlElement> {
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")),
] {