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.
(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:
1 parent
7b68d274ad
commit
978000d37c
6 files changed
+209
-98
No files matched your search
+8
-6
@@ -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]
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"))]
|
||||
|
||||
Reference in new issue
Block a user