forked from 1b-agent/opencadstudio-upstream
io: enable the PDF pipeline on wasm32 and add plot quality presets
- pdf_export: drop the stale wasm32 gates (printpdf 0.9.1 compiles for wasm; the memchr/lopdf incompatibility is gone with memchr 2.8.3). Rendering is portable via render_pdf_bytes; only persistence differs: atomic temp-file write on desktop, browser download on the web. pick_pdf_path_owned on wasm returns a virtual path so the existing message flow works unchanged. - printpdf moves from the native-only dependency table to the shared one. - print_to_printer: preview_page_pdf / preview_pages_pdf render the same bytes and open them in a browser tab on the web (the browser's print dialog takes over); desktop keeps the temp-PDF + OS-viewer flow. - sys: open_bytes_in_new_tab (blob URL, popup-blocked falls back to a download). - New PlotQuality preset caps embedded raster resolution (High = native, Normal = 300 DPI, Low = 150 DPI effective); vector output is untouched. The image resource cache keys on the post-resample dims so one pixels Arc placed at different effective DPIs embeds per-placement XObjects.
This commit is contained in:
1 parent
0e42e3bf28
commit
4812bc775d
4 files changed
+317
-62
No files matched your search
+2
-1
@@ -39,6 +39,8 @@ dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
||||
flate2 = "1"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
|
||||
hayro = "0.7.1"
|
||||
# PDF writer shared by desktop export and the web export/preview/print path.
|
||||
printpdf = { version = "0.9.1", default-features = false, features = ["images"] }
|
||||
# Rasterises assets/logo.svg into the window icon at startup, so the taskbar
|
||||
# icon comes from the same file every other build target does. Already in the
|
||||
# tree behind iced's svg feature; named here only to use it directly.
|
||||
@@ -85,7 +87,6 @@ meshopt = "0.6.2"
|
||||
i18n-embed = { version = "0.16", features = ["desktop-requester"] }
|
||||
rayon = "1"
|
||||
open = "5"
|
||||
printpdf = { version = "0.9.1", default-features = false, features = ["images"] }
|
||||
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
|
||||
+248
-60
@@ -10,11 +10,9 @@
|
||||
|
||||
use crate::io::plot_style::PlotStyleTable;
|
||||
use crate::scene::model::hatch_model::HatchModel;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::scene::model::hatch_model::HatchPattern;
|
||||
use crate::scene::WireModel;
|
||||
use crate::scene::model::image_model::ImageModel;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use printpdf::{
|
||||
BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line,
|
||||
LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode, PdfDocument,
|
||||
@@ -24,7 +22,6 @@ use printpdf::{
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub struct PlotWire {
|
||||
pub wire: WireModel,
|
||||
pub draw_depth: f32,
|
||||
@@ -45,41 +42,49 @@ pub struct PlotImage {
|
||||
pub clips: Vec<Vec<[f64; 2]>>,
|
||||
}
|
||||
|
||||
// The web build has no `printpdf` (it pulls a wasm-incompatible `memchr` via
|
||||
// lopdf → nom_locate) and no filesystem, so PDF export is native-only; the web
|
||||
// build gets these stubs so the call sites still compile.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn export_pdf(_page: &PdfPageInput, _path: &Path) -> Result<(), String> {
|
||||
Err("PDF export is not available in the web version.".into())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn export_pdf_pages(
|
||||
_pages: &[PdfPageInput],
|
||||
_path: &Path,
|
||||
_plot_style: Option<&PlotStyleTable>,
|
||||
) -> Result<(), String> {
|
||||
Err("PDF export is not available in the web version.".into())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn pick_pdf_path_owned(_stem: String) -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
// Rendering is fully portable; only persistence differs — desktop writes the
|
||||
// bytes atomically through a temp file, web delivers them as a browser download.
|
||||
|
||||
/// mm to PDF points (1 mm = 2.834645 pt).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MM_TO_PT: f32 = 2.834645;
|
||||
/// `wire.line_weight_px` is the on-screen pixel weight. Convert the 96-dpi
|
||||
/// pixels to points while retaining the viewport's lineweight visibility
|
||||
/// boost, so "As displayed" output has the same visual hierarchy as the
|
||||
/// canvas instead of making 0.35 mm ByLayer outlines look half as thick.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const LW_PX_TO_PT: f32 = MM_TO_PT / (96.0 / 25.4);
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const SCREEN_DOT_MM: f32 = 25.4 / 96.0;
|
||||
|
||||
/// Image resolution preset from the plot dialog's Quality dropdown. Vector
|
||||
/// output is resolution-independent; this only caps raster content.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum PlotQuality {
|
||||
Low,
|
||||
#[default]
|
||||
Normal,
|
||||
High,
|
||||
}
|
||||
|
||||
impl PlotQuality {
|
||||
/// Accepts the dialog's raw string, including legacy names.
|
||||
pub fn from_dialog(value: &str) -> Self {
|
||||
match value {
|
||||
"Low" | "Draft" => Self::Low,
|
||||
"High" | "Presentation" | "Maximum" | "Custom" => Self::High,
|
||||
_ => Self::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum effective DPI for embedded raster images; `None` keeps native pixels.
|
||||
fn target_dpi(self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Low => Some(150),
|
||||
Self::Normal => Some(300),
|
||||
Self::High => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output controls shared by preview, PDF export, and printer rendering.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PdfPlotOptions {
|
||||
@@ -88,6 +93,7 @@ pub struct PdfPlotOptions {
|
||||
pub transparency: bool,
|
||||
pub stamp: bool,
|
||||
pub merge_lines: bool,
|
||||
pub quality: PlotQuality,
|
||||
}
|
||||
|
||||
/// End indexes of the first paper/model render group in each flat input list.
|
||||
@@ -109,7 +115,6 @@ pub struct PlotContent {
|
||||
}
|
||||
|
||||
/// Owned geometry and settings for one PDF page.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub struct PdfPageInput {
|
||||
pub content: PlotContent,
|
||||
/// Page dimensions in mm, after any 90/270-degree rotation.
|
||||
@@ -133,6 +138,7 @@ impl Default for PdfPlotOptions {
|
||||
transparency: false,
|
||||
stamp: false,
|
||||
merge_lines: false,
|
||||
quality: PlotQuality::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,13 +146,11 @@ impl Default for PdfPlotOptions {
|
||||
// ── Public entry point ────────────────────────────────────────────────────
|
||||
|
||||
/// Export one page to a PDF file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn export_pdf(page: &PdfPageInput, path: &Path) -> Result<(), String> {
|
||||
export_pdf_pages(std::slice::from_ref(page), path, None)
|
||||
}
|
||||
|
||||
/// Export several independently sized pages into one PDF file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn export_pdf_pages(
|
||||
pages: &[PdfPageInput],
|
||||
path: &Path,
|
||||
@@ -155,8 +159,23 @@ pub fn export_pdf_pages(
|
||||
if pages.is_empty() {
|
||||
return Err("No pages were selected.".into());
|
||||
}
|
||||
let bytes = build_pdf_pages(pages, plot_style)?;
|
||||
write_pdf_atomically(path, &bytes)
|
||||
let bytes = render_pdf_bytes(pages, plot_style)?;
|
||||
persist_pdf(path, &bytes)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn persist_pdf(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
write_pdf_atomically(path, bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn persist_pdf(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "drawing.pdf".into());
|
||||
crate::sys::download_bytes(&name, bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a complete PDF beside the destination, then replace it atomically.
|
||||
@@ -174,7 +193,6 @@ fn write_pdf_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn image_clip(ops: &mut Vec<Op>, rings: Vec<Vec<[f32; 2]>>) {
|
||||
ops.push(Op::DrawPolygon {
|
||||
polygon: Polygon {
|
||||
@@ -196,7 +214,18 @@ fn image_clip(ops: &mut Vec<Op>, rings: Vec<Vec<[f32; 2]>>) {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Pixel dimensions that keep the image at most `target_dpi` when drawn at
|
||||
/// `effective_dpi`. Returns the input unchanged when already within target
|
||||
/// (or when a dimension would collapse), so High/native never resamples.
|
||||
fn downsample_target(width: u32, height: u32, effective_dpi: f64, target_dpi: u32) -> (u32, u32) {
|
||||
let factor = target_dpi as f64 / effective_dpi;
|
||||
if width == 0 || height == 0 || !factor.is_finite() || factor >= 1.0 {
|
||||
return (width, height);
|
||||
}
|
||||
let scaled = |dim: u32| ((dim as f64 * factor).round() as u64).clamp(1, dim as u64) as u32;
|
||||
(scaled(width), scaled(height))
|
||||
}
|
||||
|
||||
fn emit_image(
|
||||
doc: &mut PdfDocument,
|
||||
resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
|
||||
@@ -204,6 +233,7 @@ fn emit_image(
|
||||
plot: &PlotImage,
|
||||
ox: f64,
|
||||
oy: f64,
|
||||
scale: f32,
|
||||
options: PdfPlotOptions,
|
||||
) -> Result<(), String> {
|
||||
let image = &plot.image;
|
||||
@@ -242,10 +272,61 @@ fn emit_image(
|
||||
{
|
||||
return Err("Cannot plot bitmap: invalid coordinates, clip boundary, or opacity.".into());
|
||||
}
|
||||
// Quality caps the raster resolution (High = native pixels, the previous
|
||||
// behaviour). Effective DPI is pixels per inch on paper: the page-space
|
||||
// corner edges are points, / MM_TO_PT and × the page CTM `scale` give
|
||||
// on-paper mm.
|
||||
let mut width = image.width;
|
||||
let mut height = image.height;
|
||||
let mut pixels: std::borrow::Cow<[u8]> = std::borrow::Cow::Borrowed(image.pixels.as_slice());
|
||||
if let Some(target) = options.quality.target_dpi() {
|
||||
let dist = |a: [f32; 2], b: [f32; 2]| {
|
||||
((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt()
|
||||
};
|
||||
let width_edge = {
|
||||
let d = dist(corners[0], corners[1]);
|
||||
if d > 0.0 { d } else { dist(corners[0], corners[3]) }
|
||||
};
|
||||
let height_edge = {
|
||||
let d = dist(corners[0], corners[3]);
|
||||
if d > 0.0 { d } else { dist(corners[0], corners[1]) }
|
||||
};
|
||||
// A degenerate (zero-area) placement keeps the native pixels.
|
||||
if width_edge > 0.0 && height_edge > 0.0 {
|
||||
let dpi_x = width as f64 * 25.4 / (width_edge / MM_TO_PT * scale) as f64;
|
||||
let dpi_y = height as f64 * 25.4 / (height_edge / MM_TO_PT * scale) as f64;
|
||||
// One shared factor from the denser axis preserves the pixel aspect ratio.
|
||||
let (w, h) = downsample_target(width, height, dpi_x.max(dpi_y), target);
|
||||
// Skip token reductions (< 5%): a Lanczos pass costs more than it saves.
|
||||
if (w != width || h != height) && w as f64 / (width as f64) < 0.95 {
|
||||
if let Some(resized) =
|
||||
image::RgbaImage::from_raw(width, height, image.pixels.as_ref().clone())
|
||||
.map(|buffer| {
|
||||
image::imageops::resize(
|
||||
&buffer,
|
||||
w,
|
||||
h,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
.into_raw()
|
||||
})
|
||||
{
|
||||
width = w;
|
||||
height = h;
|
||||
pixels = std::borrow::Cow::Owned(resized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keyed on the FINAL (post-resample) dims: the same pixels Arc can be
|
||||
// placed at different effective DPIs (several inserts at different
|
||||
// scales, layouts plotted with different quality settings), and each
|
||||
// XObject must match its own draw transform's scale factors. Identical
|
||||
// placements still dedupe on the shared key.
|
||||
let key = (
|
||||
std::sync::Arc::as_ptr(&image.pixels) as usize,
|
||||
image.width,
|
||||
image.height,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
let id = resources
|
||||
.entry(key)
|
||||
@@ -255,9 +336,9 @@ fn emit_image(
|
||||
doc.resources.xobjects.map.insert(
|
||||
id.clone(),
|
||||
printpdf::XObject::Image(printpdf::RawImage {
|
||||
pixels: printpdf::RawImageData::U8(image.pixels.as_ref().clone()),
|
||||
width: image.width as usize,
|
||||
height: image.height as usize,
|
||||
pixels: printpdf::RawImageData::U8(pixels.into_owned()),
|
||||
width: width as usize,
|
||||
height: height as usize,
|
||||
data_format: printpdf::RawImageFormat::RGBA8,
|
||||
tag: Vec::new(),
|
||||
}),
|
||||
@@ -299,10 +380,12 @@ fn emit_image(
|
||||
id: id.clone(),
|
||||
// Cancel printpdf's pixel-size transform: matrix maps the PDF
|
||||
// image's unit square directly onto the CAD quad in page points.
|
||||
// The scale factors divide the EMBEDDED dims, which differ from
|
||||
// the source when quality downsampling kicked in above.
|
||||
transform: printpdf::XObjectTransform {
|
||||
dpi: Some(72.0),
|
||||
scale_x: Some(1.0 / image.width as f32),
|
||||
scale_y: Some(1.0 / image.height as f32),
|
||||
scale_x: Some(1.0 / width as f32),
|
||||
scale_y: Some(1.0 / height as f32),
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
@@ -346,7 +429,6 @@ fn emit_image(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6]> {
|
||||
let u = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
|
||||
let v = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
|
||||
@@ -392,10 +474,19 @@ pub fn pick_pdf_path_owned(
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Web: no save dialog — the export continues with a virtual filename and
|
||||
/// the generated bytes are delivered as a browser download.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn pick_pdf_path_owned(stem: String) -> Option<std::path::PathBuf> {
|
||||
Some(std::path::PathBuf::from(format!("{stem}.pdf")))
|
||||
}
|
||||
|
||||
// ── PDF builder ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Result<Vec<u8>, String> {
|
||||
pub fn render_pdf_bytes(
|
||||
pages: &[PdfPageInput],
|
||||
plot_style: Option<&PlotStyleTable>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut doc = PdfDocument::new("Open CAD Studio Export");
|
||||
// Borrowing all pages keeps their pixel Arcs alive until this cache is dropped.
|
||||
// Allocation addresses cannot be reused by another source during this export.
|
||||
@@ -408,7 +499,6 @@ fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>)
|
||||
Ok(doc.save(&PdfSaveOptions::default(), &mut warnings))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn append_pdf_page(
|
||||
doc: &mut PdfDocument,
|
||||
image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
|
||||
@@ -630,7 +720,7 @@ fn append_pdf_page(
|
||||
continue;
|
||||
}
|
||||
DrawItem::Image(image) => {
|
||||
emit_image(doc, image_resources, &mut ops, image, ox, oy, options)?;
|
||||
emit_image(doc, image_resources, &mut ops, image, ox, oy, scale, options)?;
|
||||
continue;
|
||||
}
|
||||
DrawItem::Wire(wire) => wire,
|
||||
@@ -858,7 +948,6 @@ fn append_pdf_page(
|
||||
/// the wire shader uses). Returns an empty vec for a solid line. printpdf's
|
||||
/// `LineDashPattern` holds at most six entries, so longer patterns are
|
||||
/// truncated to three dash/gap pairs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn dash_array_from_pattern(pattern_length: f32, pattern: &[f32; 8], mm_to_pt: f32) -> Vec<i64> {
|
||||
if pattern_length <= 1e-6 {
|
||||
return Vec::new();
|
||||
@@ -875,7 +964,6 @@ fn dash_array_from_pattern(pattern_length: f32, pattern: &[f32; 8], mm_to_pt: f3
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn visible_station_ranges(
|
||||
start: f32,
|
||||
end: f32,
|
||||
@@ -945,7 +1033,6 @@ fn visible_station_ranges(
|
||||
ranges
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flush_line(ops: &mut Vec<Op>, pts: &[LinePoint], dot_radius: Option<Pt>) {
|
||||
if pts.len() < 2 {
|
||||
return;
|
||||
@@ -969,7 +1056,6 @@ fn flush_line(ops: &mut Vec<Op>, pts: &[LinePoint], dot_radius: Option<Pt>) {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn emit_round_dot(ops: &mut Vec<Op>, center: Point, radius: Pt) {
|
||||
const SIDES: usize = 12;
|
||||
let points = (0..SIDES)
|
||||
@@ -993,7 +1079,6 @@ fn emit_round_dot(ops: &mut Vec<Op>, center: Point, radius: Pt) {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn plotted_color(
|
||||
rgb: [f32; 3],
|
||||
alpha: f32,
|
||||
@@ -1013,7 +1098,6 @@ fn plotted_color(
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn emit_wire_fills(
|
||||
ops: &mut Vec<Op>,
|
||||
wires: &[WireModel],
|
||||
@@ -1137,7 +1221,6 @@ fn emit_wire_fills(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn emit_plot_stamp(ops: &mut Vec<Op>) {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -1178,7 +1261,6 @@ fn emit_plot_stamp(ops: &mut Vec<Op>) {
|
||||
/// rings so islands and holes render correctly under the even-odd rule.
|
||||
/// Mirrors `scene::paper_canvas::draw_hatch`: solid → fill, pattern → outline,
|
||||
/// gradient → solid fill of the averaged colour.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn emit_hatch(
|
||||
ops: &mut Vec<Op>,
|
||||
hatch: &HatchModel,
|
||||
@@ -1419,7 +1501,6 @@ fn emit_hatch(
|
||||
/// The fold must happen in f64: the pair exists because the absolute coordinate
|
||||
/// does not fit an f32, so `pos + pos_low` evaluated in f32 rounds straight back
|
||||
/// to `pos` and throws away the residual it was carrying.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn glyph_world_xy(v: &crate::scene::pipeline::text_gpu::TextVertex) -> [f64; 2] {
|
||||
[
|
||||
v.pos[0] as f64 + v.pos_low[0] as f64,
|
||||
@@ -1429,7 +1510,6 @@ fn glyph_world_xy(v: &crate::scene::pipeline::text_gpu::TextVertex) -> [f64; 2]
|
||||
|
||||
/// Adapt a text colour to the white sheet, mirroring the wire/hatch passes:
|
||||
/// near-white / near-yellow (colour-7-on-white) → black, near-cyan → dark blue.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||
let is_light = r > 0.80 && g > 0.80 && b > 0.80;
|
||||
let is_yellow = r > 0.80 && g > 0.70 && b < 0.30;
|
||||
@@ -1451,7 +1531,6 @@ fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||
/// quad's `uv_min` and map it into that quad by affine interpolation of the
|
||||
/// plane rect — so a stroke (LFF) font emits polylines and a filled TrueType
|
||||
/// glyph emits filled triangles, exactly where the SDF quad sits.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn emit_text(
|
||||
ops: &mut Vec<Op>,
|
||||
wires: &[WireModel],
|
||||
@@ -1690,12 +1769,121 @@ mod tests {
|
||||
let mut page = test_page(vec![w]);
|
||||
page.scale = 2.0;
|
||||
page.clip = Some((10.0, 10.0, 100.0, 100.0));
|
||||
let bytes = build_pdf_pages(&[page], None).unwrap();
|
||||
let bytes = render_pdf_bytes(&[page], None).unwrap();
|
||||
// A valid PDF is produced (starts with the PDF header) and is non-trivial.
|
||||
assert!(bytes.starts_with(b"%PDF"), "not a PDF");
|
||||
assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsample_target_returns_input_within_target() {
|
||||
// Exactly at the cap keeps the native pixels…
|
||||
assert_eq!(downsample_target(300, 150, 300.0, 300), (300, 150));
|
||||
// …and so does drawing larger than the pixel count (factor > 1).
|
||||
assert_eq!(downsample_target(300, 150, 150.0, 300), (300, 150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsample_target_halves_two_x_oversampling() {
|
||||
assert_eq!(downsample_target(600, 400, 600.0, 300), (300, 200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsample_target_clamps_tiny_images_to_one_pixel() {
|
||||
assert_eq!(downsample_target(2, 2, 1500.0, 150), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plot_quality_dpi_caps_match_the_dialog_presets() {
|
||||
assert_eq!(PlotQuality::High.target_dpi(), None);
|
||||
assert_eq!(PlotQuality::Normal.target_dpi(), Some(300));
|
||||
assert_eq!(PlotQuality::Low.target_dpi(), Some(150));
|
||||
assert_eq!(PlotQuality::default(), PlotQuality::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plot_quality_from_dialog_maps_legacy_and_unknown_names() {
|
||||
assert_eq!(PlotQuality::from_dialog("Low"), PlotQuality::Low);
|
||||
assert_eq!(PlotQuality::from_dialog("Draft"), PlotQuality::Low);
|
||||
assert_eq!(PlotQuality::from_dialog("Normal"), PlotQuality::Normal);
|
||||
assert_eq!(PlotQuality::from_dialog("High"), PlotQuality::High);
|
||||
assert_eq!(PlotQuality::from_dialog("Presentation"), PlotQuality::High);
|
||||
assert_eq!(PlotQuality::from_dialog("Maximum"), PlotQuality::High);
|
||||
assert_eq!(PlotQuality::from_dialog("Best"), PlotQuality::Normal);
|
||||
assert_eq!(PlotQuality::from_dialog(""), PlotQuality::Normal);
|
||||
}
|
||||
|
||||
// A noise-filled square image drawn as a `paper_mm`-sized world quad.
|
||||
// Incompressible pixels guarantee the High-quality PDF's embedded stream
|
||||
// dominates the Low-quality one's after downsampling.
|
||||
fn plot_image(px: u32, paper_mm: f32) -> PlotImage {
|
||||
let mut pixels = Vec::with_capacity((px * px * 4) as usize);
|
||||
for i in 0..px * px {
|
||||
let v = (i.wrapping_mul(2_654_435_761) >> 24) as u8;
|
||||
pixels.extend_from_slice(&[v, 255 - v, v ^ 0x5a, 0xff]);
|
||||
}
|
||||
let corner = |x: f32, y: f32| [x, y, 0.0];
|
||||
let corners = [
|
||||
corner(0.0, 0.0),
|
||||
corner(paper_mm, 0.0),
|
||||
corner(paper_mm, paper_mm),
|
||||
corner(0.0, paper_mm),
|
||||
];
|
||||
let verts = [
|
||||
(corners[0], [0.0, 1.0]),
|
||||
(corners[1], [1.0, 1.0]),
|
||||
(corners[2], [1.0, 0.0]),
|
||||
(corners[0], [0.0, 1.0]),
|
||||
(corners[2], [1.0, 0.0]),
|
||||
(corners[3], [0.0, 0.0]),
|
||||
]
|
||||
.iter()
|
||||
.map(|(pos, uv)| crate::scene::model::image_model::ImageQuadVertex {
|
||||
pos: *pos,
|
||||
uv: *uv,
|
||||
pos_low: [0.0; 3],
|
||||
})
|
||||
.collect();
|
||||
PlotImage {
|
||||
image: ImageModel {
|
||||
render_instance: None,
|
||||
file_path: String::new(),
|
||||
pixels: std::sync::Arc::new(pixels),
|
||||
width: px,
|
||||
height: px,
|
||||
opacity: 1.0,
|
||||
corners,
|
||||
corners_low: [[0.0; 3]; 4],
|
||||
draw_depth: 0.5,
|
||||
verts,
|
||||
},
|
||||
clips: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_quality_embeds_a_smaller_image_stream() {
|
||||
// 600 px on a 25.4 mm (1") square = 600 DPI effective; Low's 150 DPI
|
||||
// cap resamples to 150×150 while High embeds the native pixels.
|
||||
let image = plot_image(600, 25.4);
|
||||
let mut high = test_page(Vec::new());
|
||||
high.options.quality = PlotQuality::High;
|
||||
high.content.images = vec![image.clone()];
|
||||
let mut low = test_page(Vec::new());
|
||||
low.options.quality = PlotQuality::Low;
|
||||
low.content.images = vec![image];
|
||||
|
||||
let high_bytes = render_pdf_bytes(&[high], None).unwrap();
|
||||
let low_bytes = render_pdf_bytes(&[low], None).unwrap();
|
||||
assert!(high_bytes.starts_with(b"%PDF"));
|
||||
assert!(
|
||||
low_bytes.len() < high_bytes.len(),
|
||||
"low quality did not shrink the PDF: {} !< {}",
|
||||
low_bytes.len(),
|
||||
high_bytes.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Build a WireModel carrying the SDF glyph quads for `text` in the embedded
|
||||
// "txt" stroke font, laid out into the process-wide atlas emit_text reads.
|
||||
fn text_wire(text: &str, origin: [f64; 3]) -> PlotWire {
|
||||
@@ -1725,8 +1913,8 @@ mod tests {
|
||||
let mut blank = wire.clone();
|
||||
blank.wire.text_verts.clear();
|
||||
|
||||
let with_text = build_pdf_pages(&[test_page(vec![wire])], None).unwrap();
|
||||
let no_text = build_pdf_pages(&[test_page(vec![blank])], None).unwrap();
|
||||
let with_text = render_pdf_bytes(&[test_page(vec![wire])], None).unwrap();
|
||||
let no_text = render_pdf_bytes(&[test_page(vec![blank])], None).unwrap();
|
||||
assert!(with_text.starts_with(b"%PDF"));
|
||||
assert!(
|
||||
with_text.len() > no_text.len(),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// print_to_printer — send the current layout to the system printer.
|
||||
//
|
||||
// Strategy:
|
||||
// Desktop strategy:
|
||||
// 1. Render the drawing to a temporary PDF (reusing the PDF export pipeline).
|
||||
// 2. Send that PDF to the system printer with `lp` (Linux/macOS) or
|
||||
// `ShellExecute PRINT` (Windows).
|
||||
//
|
||||
// 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.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::io::pdf_export;
|
||||
@@ -332,6 +336,32 @@ pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> {
|
||||
.map_err(|e| format!("Could not open preview: {e}"))
|
||||
}
|
||||
|
||||
/// Render a page and show it the way each platform previews: the OS PDF
|
||||
/// viewer on desktop, a new browser tab on the web.
|
||||
pub fn preview_page_pdf(page: &crate::io::pdf_export::PdfPageInput) -> Result<(), String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let tmp = temp_pdf_path("preview");
|
||||
crate::io::pdf_export::export_pdf(page, &tmp)?;
|
||||
open_in_viewer(&tmp)
|
||||
}
|
||||
#[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-only multi-page variant used by print-all: render every layout into
|
||||
/// one PDF and open it in a browser tab.
|
||||
#[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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a PDF to a specific printer with [`PrintOptions`].
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn dispatch_to_printer_opts(
|
||||
|
||||
+36
@@ -373,6 +373,42 @@ 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.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_bytes_in_new_tab(name: &str, bytes: &[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 {
|
||||
return;
|
||||
};
|
||||
let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) 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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// Short platform string for bug reports: OS + architecture on the desktop,
|
||||
/// the browser user-agent on the web.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
Reference in new issue
Block a user