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.

(cherry picked from commit 4812bc775d)

Ported onto main (#1717 review): the plot model lives in io::plot; Normal
keeps native pixels like High (only Low caps images at 150 DPI, so desktop
output is unchanged); the searchable TrueType text layer and its allsorts
subsetting now build on the web too; the plot stamp reads the time through
js_sys on wasm.
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:04 +03:00
1 parent 7b68d274ad
commit 978000d37c
6 files changed
+209 -98

No files matched your search

+8 -6
View File
@@ -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]
+1
View File
@@ -5881,6 +5881,7 @@ impl OpenCADStudio {
},
stamp: d.stamp,
merge_lines: d.merge_lines,
quality: crate::io::pdf_export::PlotQuality::from_dialog(&d.quality),
}
}
+101 -91
View File
@@ -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<std::path::PathBuf> {
None
pub async fn pick_plot_path_owned(stem: String) -> Option<std::path::PathBuf> {
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<Op>, rings: Vec<Vec<[f32; 2]>>) {
ops.push(Op::DrawPolygon {
polygon: Polygon {
@@ -126,7 +120,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 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<std::path::
// ── PDF builder ───────────────────────────────────────────────────────────
#[cfg(not(target_arch = "wasm32"))]
fn build_pdf_pages(
/// Render pages to complete PDF bytes (export, preview and printing share it).
pub(crate) fn render_pdf_bytes(
pages: &[PdfPageInput],
plot_style: Option<&PlotStyleTable>,
) -> Result<Vec<u8>, 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<i64> {
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<Op>, pts: &[LinePoint], dot_radius: Option<Pt>) {
if pts.len() < 2 {
return;
@@ -1105,7 +1134,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)
@@ -1129,7 +1157,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, 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<Op>,
wires: &[WireModel],
@@ -1282,12 +1308,15 @@ fn emit_wire_fills(
}
}
#[cfg(not(target_arch = "wasm32"))]
fn emit_plot_stamp(ops: &mut Vec<Op>) {
// `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<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,
@@ -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<Op>,
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<EmbeddedSubset>,
}
#[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<Op>,
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, crate::scene::text::sdf_atlas::GlyphExport>,
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<usize>>,
}
#[cfg(not(target_arch = "wasm32"))]
struct EmbeddedFont {
blob: std::sync::Arc<crate::scene::text::ttf_glyph::FontBlob>,
id: FontId,
@@ -2255,7 +2270,6 @@ struct EmbeddedFont {
used: std::collections::BTreeMap<u16, char>,
}
#[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<GlyphTable> {
@@ -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<Vec<u8>> {
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<Op>, 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);
+32
View File
@@ -33,6 +33,36 @@ pub struct PlotImage {
pub clips: Vec<Vec<[f64; 2]>>,
}
/// 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<u32> {
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,
}
}
}
+31 -1
View File
@@ -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<String, String> {
+36
View File
@@ -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"))]