diff --git a/Cargo.toml b/Cargo.toml index 9e74036d6..3a6ed8573 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,14 @@ kurbo = "0.13" zip = { version = "8", default-features = false, features = ["deflate"] } roxmltree = "0.20" tiny-skia = "0.11" +# PDF writer shared by desktop export and the web export/preview/print path. +printpdf = { version = "0.9.1", default-features = false, features = ["images"] } +# TrueType subsetting for searchable PDF text (same version printpdf itself +# uses, so no new binary enters the lockfile). Same feature set as printpdf's +# own allsorts use (pure-Rust flate backend) — the defaults would drag in the +# libz-sys C dependency. Pure Rust, so the web build embeds +# the same subsets. +allsorts = { version = "0.16", default-features = false, features = ["flate2_rust"] } # 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. @@ -102,12 +110,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"] } -# TrueType subsetting for searchable PDF text (same version printpdf itself -# uses, so no new binary enters the lockfile). Same feature set as printpdf's -# own allsorts use (pure-Rust flate backend) — the defaults would drag in the -# libz-sys C dependency. Native-only like printpdf. -allsorts = { version = "0.16", default-features = false, features = ["flate2_rust"] } ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 78e51ec7a..bfb5d8ead 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -5881,6 +5881,7 @@ impl OpenCADStudio { }, stamp: d.stamp, merge_lines: d.merge_lines, + quality: crate::io::pdf_export::PlotQuality::from_dialog(&d.quality), } } diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index eee3a235f..c03bedb31 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -10,12 +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; -#[cfg(not(target_arch = "wasm32"))] use crate::scene::model::wire_model::SearchableTextRun; use crate::scene::WireModel; -#[cfg(not(target_arch = "wasm32"))] use printpdf::{ BlendMode, BuiltinFont, Codepoint, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, FontId, Line, LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode, @@ -29,54 +26,37 @@ use std::path::Path; // the scene, the print pipeline and every other call site keep compiling. pub use super::plot::{ PlotContent, PlotGroupSplits, PlotImage, PlotOptions as PdfPlotOptions, - PlotPage as PdfPageInput, PlotWire, + PlotPage as PdfPageInput, PlotQuality, PlotWire, }; -// 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()) -} +// Rendering is portable; only persistence differs — desktop writes the bytes +// atomically through a temp file, the web delivers them as a browser download. +/// Web: no save dialog — the export continues with a virtual file name and the +/// generated bytes are delivered as a browser download. #[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_plot_path_owned(_stem: String) -> Option { - None +pub async fn pick_plot_path_owned(stem: String) -> Option { + Some(std::path::PathBuf::from(format!("{stem}.pdf"))) } /// 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; // ── 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, @@ -85,8 +65,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. @@ -104,7 +99,6 @@ fn write_pdf_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> { Ok(()) } -#[cfg(not(target_arch = "wasm32"))] fn image_clip(ops: &mut Vec, rings: Vec>) { ops.push(Op::DrawPolygon { polygon: Polygon { @@ -126,7 +120,18 @@ fn image_clip(ops: &mut Vec, rings: Vec>) { }); } -#[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 native quality 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>, @@ -134,6 +139,7 @@ fn emit_image( plot: &PlotImage, ox: f64, oy: f64, + scale: f32, options: PdfPlotOptions, ) -> Result<(), String> { let image = &plot.image; @@ -172,12 +178,44 @@ fn emit_image( { return Err("Cannot plot bitmap: invalid coordinates, clip boundary, or opacity.".into()); } + // Quality caps the raster resolution (None = native pixels). 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 = dist(corners[0], corners[1]); + let height_edge = dist(corners[0], corners[3]); + // 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 resampling pass costs more than it saves. + if (w != width || h != height) && (w as f64) / (width as f64) < 0.95 { + if let Some(buffer) = + image::RgbaImage::from_raw(width, height, image.pixels.as_ref().clone()) + { + let resized = + image::imageops::resize(&buffer, w, h, image::imageops::FilterType::Triangle); + width = w; + height = h; + pixels = std::borrow::Cow::Owned(resized.into_raw()); + } + } + } + } // The low bit (free: the pixel buffer is aligned) keeps the opaque copy - // of a picture apart from its transparent one. + // of a picture apart from its transparent one. Keyed on the final + // (post-resample) dims: the same pixels placed at different effective + // DPIs embed one image each, matching their own draw transforms. let key = ( std::sync::Arc::as_ptr(&image.pixels) as usize | usize::from(!image.use_alpha), - image.width, - image.height, + width, + height, ); let id = resources .entry(key) @@ -188,15 +226,15 @@ fn emit_image( id.clone(), printpdf::XObject::Image(printpdf::RawImage { pixels: printpdf::RawImageData::U8(if image.use_alpha { - image.pixels.as_ref().clone() + pixels.into_owned() } else { // Transparency off: every pixel opaque in its colour. - let mut opaque = image.pixels.as_ref().clone(); + let mut opaque = pixels.into_owned(); opaque.chunks_exact_mut(4).for_each(|px| px[3] = 255); opaque }), - width: image.width as usize, - height: image.height as usize, + width: width as usize, + height: height as usize, data_format: printpdf::RawImageFormat::RGBA8, tag: Vec::new(), }), @@ -240,8 +278,9 @@ fn emit_image( // image's unit square directly onto the CAD quad in page points. 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), + // The embedded dims differ from the source after downsampling. + scale_x: Some(1.0 / width as f32), + scale_y: Some(1.0 / height as f32), ..Default::default() }, }); @@ -285,7 +324,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]]; @@ -374,8 +412,8 @@ pub async fn pick_plot_path_async(stem: String, svg: bool) -> Option, ) -> Result, String> { @@ -414,7 +452,6 @@ fn build_pdf_pages( Ok(bytes) } -#[cfg(not(target_arch = "wasm32"))] fn append_pdf_page( doc: &mut PdfDocument, image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>, @@ -691,7 +728,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, @@ -916,12 +953,10 @@ fn append_pdf_page( Ok(()) } -#[cfg(not(target_arch = "wasm32"))] /// Slack around the export window, in sheet mm, so a stroke whose centreline /// sits just outside still contributes its width. const CULL_MARGIN_MM: f64 = 10.0; -#[cfg(not(target_arch = "wasm32"))] fn sheet_overlaps(b: [f64; 4], window: [f64; 4]) -> bool { b[0] <= window[2] + CULL_MARGIN_MM && b[2] >= window[0] - CULL_MARGIN_MM @@ -929,7 +964,6 @@ fn sheet_overlaps(b: [f64; 4], window: [f64; 4]) -> bool { && b[3] >= window[1] - CULL_MARGIN_MM } -#[cfg(not(target_arch = "wasm32"))] fn grow(bounds: &mut Option<[f64; 4]>, x: f64, y: f64) { if !x.is_finite() || !y.is_finite() { return; @@ -941,7 +975,6 @@ fn grow(bounds: &mut Option<[f64; 4]>, x: f64, y: f64) { b[3] = b[3].max(y); } -#[cfg(not(target_arch = "wasm32"))] /// Sheet-mm bounds of everything a wire inks (strokes, glyphs, fills). fn wire_sheet_bounds(wire: &WireModel, ox: f64, oy: f64, marker_h: f64) -> Option<[f64; 4]> { let mut bounds = None; @@ -966,7 +999,6 @@ fn wire_sheet_bounds(wire: &WireModel, ox: f64, oy: f64, marker_h: f64) -> Optio bounds } -#[cfg(not(target_arch = "wasm32"))] /// Sheet-mm bounds of a hatch, resolved the way `emit_hatch` places it. fn hatch_sheet_bounds(hatch: &HatchModel, ox: f64, oy: f64) -> Option<[f64; 4]> { let mut bounds = None; @@ -988,7 +1020,6 @@ fn hatch_sheet_bounds(hatch: &HatchModel, ox: f64, oy: f64) -> Option<[f64; 4]> /// 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 { if pattern_length <= 1e-6 { return Vec::new(); @@ -1005,7 +1036,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, @@ -1082,7 +1112,6 @@ fn visible_station_ranges( ranges } -#[cfg(not(target_arch = "wasm32"))] fn flush_line(ops: &mut Vec, pts: &[LinePoint], dot_radius: Option) { if pts.len() < 2 { return; @@ -1105,7 +1134,6 @@ fn flush_line(ops: &mut Vec, pts: &[LinePoint], dot_radius: Option) { }); } -#[cfg(not(target_arch = "wasm32"))] fn emit_round_dot(ops: &mut Vec, center: Point, radius: Pt) { const SIDES: usize = 12; let points = (0..SIDES) @@ -1129,7 +1157,6 @@ fn emit_round_dot(ops: &mut Vec, center: Point, radius: Pt) { }); } -#[cfg(not(target_arch = "wasm32"))] fn plotted_color(rgb: [f32; 3], alpha: f32, screening: f32, options: PdfPlotOptions) -> [f32; 3] { let amount = screening.clamp(0.0, 1.0) * if options.transparency { @@ -1144,7 +1171,6 @@ fn plotted_color(rgb: [f32; 3], alpha: f32, screening: f32, options: PdfPlotOpti ] } -#[cfg(not(target_arch = "wasm32"))] fn emit_wire_fills( ops: &mut Vec, wires: &[WireModel], @@ -1282,12 +1308,15 @@ fn emit_wire_fills( } } -#[cfg(not(target_arch = "wasm32"))] fn emit_plot_stamp(ops: &mut Vec) { + // `SystemTime::now` panics on wasm32-unknown-unknown. + #[cfg(not(target_arch = "wasm32"))] let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs()) .unwrap_or(0); + #[cfg(target_arch = "wasm32")] + let timestamp = (js_sys::Date::now() / 1000.0) as u64; let user = std::env::var("USER") .or_else(|_| std::env::var("USERNAME")) .unwrap_or_else(|_| "user".into()); @@ -1323,7 +1352,6 @@ fn emit_plot_stamp(ops: &mut Vec) { /// 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, hatch: &HatchModel, @@ -1565,7 +1593,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, @@ -1575,7 +1602,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] { // An authored white (not colour 7) plots as drawn, like on screen. let is_light = r > 0.80 @@ -1634,7 +1660,6 @@ fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] { /// the run rect — same space as the clip polygon). /// /// Returns the number of runs emitted. -#[cfg(not(target_arch = "wasm32"))] fn emit_searchable_text( ops: &mut Vec, wires: &[WireModel], @@ -1775,7 +1800,6 @@ fn emit_searchable_text( /// emits a wrong `Tj` — those runs keep their outlines and stay unsearchable. /// (Runs set in an embedded subset bypass this gate: Identity-H covers any /// glyph the subset carries.) -#[cfg(not(target_arch = "wasm32"))] fn is_winansi_encodable(text: &str) -> bool { text.chars().all(|c| { let cp = c as u32; @@ -1787,7 +1811,6 @@ fn is_winansi_encodable(text: &str) -> bool { /// One embedded subset TrueType font for the search layer: a single system /// family at one weight, cut by `allsorts` to the glyphs the plot uses. -#[cfg(not(target_arch = "wasm32"))] struct EmbeddedSubset { family: String, bold: bool, @@ -1802,7 +1825,6 @@ struct EmbeddedSubset { cap_ratio: f32, } -#[cfg(not(target_arch = "wasm32"))] impl EmbeddedSubset { /// Width-match factor for a run: laid advance ÷ natural advance at the /// run's cap height. 1.0 when the widths are unknown (never explodes a @@ -1830,13 +1852,11 @@ impl EmbeddedSubset { } /// Embedded subsets for one export (Helvetica fallback needs no entry). -#[cfg(not(target_arch = "wasm32"))] #[derive(Default)] struct SearchableFontSet { subsets: Vec, } -#[cfg(not(target_arch = "wasm32"))] impl SearchableFontSet { /// Collect every TTF-resolvable run's chars per (family, bold), subset /// each bucket with `allsorts`, and register the subsets on the document. @@ -1882,7 +1902,6 @@ impl SearchableFontSet { /// Returns `None` whenever embedding is impossible or pointless (no file, CFF /// outlines the stub can't describe, subset failure, nothing mappable) — the /// caller falls back to Helvetica, never to an invalid font dict. -#[cfg(not(target_arch = "wasm32"))] fn subset_family( doc: &mut PdfDocument, family: &str, @@ -1988,7 +2007,6 @@ fn subset_family( /// Always drawn: the visible glyph rendering (strokes for LFF/SHX pen fonts, /// fills for TrueType outlines, solid bars for decorations). The invisible /// searchable layer above adds extractability; it never replaces this. -#[cfg(not(target_arch = "wasm32"))] fn emit_text( ops: &mut Vec, fonts: &mut TextFonts, @@ -2220,7 +2238,6 @@ fn emit_text( // ── Embedded TrueType text ───────────────────────────────────────────────── /// The atlas export table plus its solid-tile key. -#[cfg(not(target_arch = "wasm32"))] type GlyphTable = std::sync::Arc<( std::collections::HashMap, u64, @@ -2233,7 +2250,6 @@ type GlyphTable = std::sync::Arc<( /// the glyphs actually used, renumbers them in the page streams, and adds the /// subset fonts to the document. printpdf 0.9 would otherwise embed whole /// fonts (its own subsetting is disabled), and a CJK face is megabytes. -#[cfg(not(target_arch = "wasm32"))] #[derive(Default)] struct TextFonts { /// The atlas export table, snapshotted once: every page's text quads are @@ -2247,7 +2263,6 @@ struct TextFonts { by_blob: std::collections::HashMap<(usize, u32, u32), Option>, } -#[cfg(not(target_arch = "wasm32"))] struct EmbeddedFont { blob: std::sync::Arc, id: FontId, @@ -2255,7 +2270,6 @@ struct EmbeddedFont { used: std::collections::BTreeMap, } -#[cfg(not(target_arch = "wasm32"))] impl TextFonts { /// The atlas' glyph export table (and solid-tile key), built on first use. fn glyph_table(&mut self) -> Option { @@ -2380,7 +2394,6 @@ impl TextFonts { /// `blob`'s face reduced to `gids` (glyph 0 first), keeping the hinting tables /// a tricky CJK face (DFKai-SB, MingLiU) needs to draw its strokes in place. -#[cfg(not(target_arch = "wasm32"))] fn subset_font(blob: &crate::scene::text::ttf_glyph::FontBlob, gids: &[u16]) -> Option> { use allsorts::binary::read::ReadScope; use allsorts::font_data::FontData; @@ -2398,7 +2411,6 @@ fn subset_font(blob: &crate::scene::text::ttf_glyph::FontBlob, gids: &[u16]) -> } /// Rewrite the glyph ids shown in font `id` through `remap` (original → subset). -#[cfg(not(target_arch = "wasm32"))] fn renumber_glyphs( pages: &mut [PdfPage], id: &FontId, @@ -2435,7 +2447,6 @@ fn renumber_glyphs( /// so one em (`units_per_em` font units) spans `k · units_per_em` 9-units. /// With `Tf` size 1 a text-space unit is one em, which this matrix carries to /// sheet points under the page's CTM — the same space the wire pass draws in. -#[cfg(not(target_arch = "wasm32"))] #[allow(clippy::too_many_arguments)] fn glyph_text_matrix( source: &crate::scene::text::ttf_glyph::GlyphSource, @@ -2471,7 +2482,6 @@ fn glyph_text_matrix( /// One glyph as a text object: font `font` at size 1, positioned by `matrix`. /// The character rides along as the glyph's Unicode so the text is searchable. -#[cfg(not(target_arch = "wasm32"))] fn push_glyph_text(ops: &mut Vec, font: FontId, gid: u16, ch: char, matrix: [f32; 6]) { ops.push(Op::StartTextSection); ops.push(Op::SetFont { @@ -2576,7 +2586,7 @@ 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()); @@ -2601,10 +2611,10 @@ mod tests { test_page(vec![wire]) }; - let unstyled = pdf_stream_text(&build_pdf_pages(&[make_page()], None).unwrap()); + let unstyled = pdf_stream_text(&render_pdf_bytes(&[make_page()], None).unwrap()); let monochrome = PlotStyleTable::builtin("monochrome.ctb").expect("shipped monochrome.ctb parses"); - let styled = pdf_stream_text(&build_pdf_pages(&[make_page()], Some(&monochrome)).unwrap()); + let styled = pdf_stream_text(&render_pdf_bytes(&[make_page()], Some(&monochrome)).unwrap()); assert!( unstyled.contains("1 0 0 rg") || unstyled.contains("1 0 0 RG"), @@ -2666,7 +2676,7 @@ mod tests { ); let unstyled = stroke_widths(&pdf_stream_text( - &build_pdf_pages(&[make_page(1.0), make_page(3.0)], None).unwrap(), + &render_pdf_bytes(&[make_page(1.0), make_page(3.0)], None).unwrap(), )); let unstyled = distinct_widths(unstyled); assert_eq!( @@ -2680,7 +2690,7 @@ mod tests { ); let styled = stroke_widths(&pdf_stream_text( - &build_pdf_pages(&[make_page(1.0), make_page(3.0)], Some(&monochrome)).unwrap(), + &render_pdf_bytes(&[make_page(1.0), make_page(3.0)], Some(&monochrome)).unwrap(), )); let styled = distinct_widths(styled); assert_eq!( @@ -2723,8 +2733,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(), @@ -2760,7 +2770,7 @@ mod tests { #[test] fn searchable_text_emits_bt_tj_with_string() { let wire = searchable_wire("CMa-03", [20.0, 30.0, 0.0]); - let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap(); + let bytes = render_pdf_bytes(&[test_page(vec![wire])], None).unwrap(); assert!(bytes.starts_with(b"%PDF"), "not a PDF"); let stream = pdf_stream_text(&bytes); assert!( @@ -2809,8 +2819,8 @@ mod tests { "searchable wire must keep its outline quads" ); - let outline_bytes = build_pdf_pages(&[test_page(vec![outline_only])], None).unwrap(); - let searchable_bytes = build_pdf_pages(&[test_page(vec![searchable])], None).unwrap(); + let outline_bytes = render_pdf_bytes(&[test_page(vec![outline_only])], None).unwrap(); + let searchable_bytes = render_pdf_bytes(&[test_page(vec![searchable])], None).unwrap(); if sdf_atlas::generation() == generation { break (outline_bytes, searchable_bytes); } @@ -2843,7 +2853,7 @@ mod tests { #[test] fn searchable_text_skips_non_winansi_runs() { let wire = searchable_wire("ΩΩ", [20.0, 20.0, 0.0]); - let stream = pdf_stream_text(&build_pdf_pages(&[test_page(vec![wire])], None).unwrap()); + let stream = pdf_stream_text(&render_pdf_bytes(&[test_page(vec![wire])], None).unwrap()); assert!( !stream.contains("BT"), "non-encodable run must not emit a text object" @@ -2856,7 +2866,7 @@ mod tests { fn searchable_text_rotation_uses_text_matrix() { let mut wire = searchable_wire("AB", [20.0, 20.0, 0.0]); wire.wire.searchable_text[0].rotation = std::f32::consts::FRAC_PI_2; - let stream = pdf_stream_text(&build_pdf_pages(&[test_page(vec![wire])], None).unwrap()); + let stream = pdf_stream_text(&render_pdf_bytes(&[test_page(vec![wire])], None).unwrap()); assert!(stream.contains("AB"), "rotated run lost its string"); assert!( stream.contains("Tm"), @@ -2951,7 +2961,7 @@ mod tests { "accents must survive subsetting" ); // PDF level: ToUnicode + embedded bytes, and no Helvetica fallback. - let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap(); + let bytes = render_pdf_bytes(&[test_page(vec![wire])], None).unwrap(); let stream = pdf_stream_text(&bytes); assert!( stream.contains("ToUnicode"), @@ -2981,7 +2991,7 @@ mod tests { // Same page, windowed clip around the origin run only. let mut page = test_page(vec![inside, outside]); page.clip = Some((0.0, 0.0, 100.0, 100.0)); - let stream = pdf_stream_text(&build_pdf_pages(&[page], None).unwrap()); + let stream = pdf_stream_text(&render_pdf_bytes(&[page], None).unwrap()); assert!(stream.contains("IN"), "inside run must survive the clip"); assert!( !stream.contains("OUT"), @@ -3040,7 +3050,7 @@ mod tests { }, draw_depth: 0.0, }; - let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap(); + let bytes = render_pdf_bytes(&[test_page(vec![wire])], None).unwrap(); let stable = sdf_atlas::generation() == generation && sdf_atlas::textfill(); stable.then(|| { let text = pdf_stream_text(&bytes); diff --git a/src/io/plot.rs b/src/io/plot.rs index b4273550a..26b6ee19a 100644 --- a/src/io/plot.rs +++ b/src/io/plot.rs @@ -33,6 +33,36 @@ pub struct PlotImage { pub clips: Vec>, } +/// Image resolution preset from the plot dialog's Quality dropdown. Vector +/// output is resolution-independent; this only caps raster content. Normal +/// and High keep the native pixels; Low trades raster detail for size. +#[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. + pub(crate) fn target_dpi(self) -> Option { + match self { + Self::Low => Some(150), + Self::Normal | Self::High => None, + } + } +} + /// Output controls shared by preview, plot exports, and printer rendering. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PlotOptions { @@ -41,6 +71,7 @@ pub struct PlotOptions { 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. @@ -86,6 +117,7 @@ impl Default for PlotOptions { transparency: false, stamp: false, merge_lines: false, + quality: PlotQuality::Normal, } } } diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 201af959d..75431d1be 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -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; @@ -1215,6 +1219,32 @@ fn shell_print_to(path: &std::path::Path, printer: &str, copies: u32) -> Result< Ok(()) } +/// 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(path: &std::path::Path, opts: &PrintOptions) -> Result { diff --git a/src/sys.rs b/src/sys.rs index eafe4f657..14436000b 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -440,6 +440,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"))]