forked from 1b-agent/opencadstudio-upstream
web: render plot preview in-page instead of a new tab
A plot preview should show the plotted result on screen instead of
producing a file. The web build now does: 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.
(cherry picked from commit 8722d84910)
This commit is contained in:
1 parent
c8386c6d3a
commit
4d1f9d5960
3 files changed
+194
-34
No files matched your search
@@ -8,8 +8,9 @@
|
|||||||
// The function is async so the UI remains responsive while the job is queued.
|
// The function is async so the UI remains responsive while the job is queued.
|
||||||
//
|
//
|
||||||
// The web build has no system printers: `preview_page_pdf` /
|
// The web build has no system printers: `preview_page_pdf` /
|
||||||
// `preview_pages_pdf` render the same PDF bytes and open them in a browser
|
// `preview_pages_pdf` render the same PDF bytes, rasterise them, and show
|
||||||
// tab, where the browser's own print dialog takes over.
|
// 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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::io::pdf_export;
|
use crate::io::pdf_export;
|
||||||
@@ -1220,7 +1221,7 @@ fn shell_print_to(path: &std::path::Path, printer: &str, copies: u32) -> Result<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Render a page and show it the way each platform previews: the OS PDF
|
/// 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> {
|
pub fn preview_page_pdf(page: &crate::io::pdf_export::PdfPageInput) -> Result<(), String> {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
@@ -1231,17 +1232,45 @@ pub fn preview_page_pdf(page: &crate::io::pdf_export::PdfPageInput) -> Result<()
|
|||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
let bytes = crate::io::pdf_export::render_pdf_bytes(std::slice::from_ref(page), None)?;
|
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);
|
web_preview_overlay(&bytes)
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web-only multi-page variant used by print-all: render every layout into
|
/// 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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub fn preview_pages_pdf(pages: &[crate::io::pdf_export::PdfPageInput]) -> Result<(), String> {
|
pub fn preview_pages_pdf(pages: &[crate::io::pdf_export::PdfPageInput]) -> Result<(), String> {
|
||||||
let bytes = crate::io::pdf_export::render_pdf_bytes(pages, None)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,13 @@ fn rasterize_uncached(path: &str, page: &str, dpi: f32, transparent: bool) -> Op
|
|||||||
let bytes = source_bytes(path)?;
|
let bytes = source_bytes(path)?;
|
||||||
let pdf = Pdf::new(bytes).ok()?;
|
let pdf = Pdf::new(bytes).ok()?;
|
||||||
let page_no = page.trim().parse::<usize>().unwrap_or(1).max(1);
|
let page_no = page.trim().parse::<usize>().unwrap_or(1).max(1);
|
||||||
let page = pdf.pages().get(page_no - 1)?;
|
raster_pdf_page(&pdf, page_no - 1, dpi, transparent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rasterise one 0-based page index at `dpi`. Screen DPIs clamp the longest
|
||||||
|
/// side; higher (print) DPIs refuse an oversized page instead of rescaling.
|
||||||
|
fn raster_pdf_page(pdf: &Pdf, index: usize, dpi: f32, transparent: bool) -> Option<Arc<PdfPage>> {
|
||||||
|
let page = pdf.pages().get(index)?;
|
||||||
let mut scale = dpi / 72.0;
|
let mut scale = dpi / 72.0;
|
||||||
let mut effective_dpi = dpi;
|
let mut effective_dpi = dpi;
|
||||||
// Page size in points, straight from the file's MediaBox/CropBox.
|
// Page size in points, straight from the file's MediaBox/CropBox.
|
||||||
@@ -179,6 +185,19 @@ fn rasterize_uncached(path: &str, page: &str, dpi: f32, transparent: bool) -> Op
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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| raster_pdf_page(&pdf, index, dpi, false))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn unpremultiply(pixels: &mut [u8]) {
|
fn unpremultiply(pixels: &mut [u8]) {
|
||||||
for px in pixels.chunks_exact_mut(4) {
|
for px in pixels.chunks_exact_mut(4) {
|
||||||
let a = px[3] as u32;
|
let a = px[3] as u32;
|
||||||
@@ -441,4 +460,23 @@ mod tests {
|
|||||||
"a print raster over the pixel budget is refused, not rescaled"
|
"a print raster over the pixel budget is refused, not rescaled"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+119
-26
@@ -440,40 +440,133 @@ pub fn download_bytes(name: &str, bytes: &[u8]) {
|
|||||||
let _ = web_sys::Url::revoke_object_url(&url);
|
let _ = web_sys::Url::revoke_object_url(&url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open `bytes` (e.g. a generated PDF) in a new browser tab. Web only.
|
/// Show a plot preview as an in-page overlay. The plotted PDF arrives already
|
||||||
/// Falls back to a download when the popup is blocked, so the output is
|
/// rasterised to PNG pages: inline `<img>` elements render in every browser,
|
||||||
/// never lost. Used for plot preview and as the web stand-in for printing.
|
/// 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")]
|
#[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;
|
use wasm_bindgen::JsCast;
|
||||||
let Some(window) = web_sys::window() else { return };
|
let Some(window) = web_sys::window() else {
|
||||||
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 {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
|
let Some(document) = window.document() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let tab = window.open_with_url_and_target(&url, "_blank").ok().flatten();
|
if page_pngs.is_empty() {
|
||||||
if tab.is_none() {
|
// Rasterisation produced nothing: deliver the PDF itself rather than
|
||||||
// Popup blocked: free the URL right away and fall back to a plain
|
// show an empty frame.
|
||||||
// download so the rendered output is never lost.
|
download_bytes(name, pdf_bytes);
|
||||||
let _ = web_sys::Url::revoke_object_url(&url);
|
|
||||||
download_bytes(name, bytes);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The new tab loads the blob asynchronously, so the URL must outlive this
|
let Ok(el) = document.create_element("div") else {
|
||||||
// call; revoke it a minute later, once the viewer has the content. The
|
download_bytes(name, pdf_bytes);
|
||||||
// one-shot closure is owned by the timer, not stored Rust-side.
|
return;
|
||||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
};
|
||||||
let _ = web_sys::Url::revoke_object_url(&url);
|
let backdrop: web_sys::HtmlElement = el.unchecked_into();
|
||||||
});
|
backdrop
|
||||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
.set_attribute(
|
||||||
revoke.unchecked_ref(),
|
"style",
|
||||||
60_000,
|
"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,
|
/// Short platform string for bug reports: OS + architecture on the desktop,
|
||||||
|
|||||||
Reference in new issue
Block a user