From cba64631b8e90e92c8d129078827914522cadf85 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 14 Sep 2026 12:53:06 +0300 Subject: [PATCH] fix: render PDF attachments on every platform Fixes #1243 --- Cargo.lock | 281 +++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/app/mod.rs | 2 +- src/app/update/mod.rs | 9 +- src/scene/model/pdf_raster.rs | 202 +++++++++++++----------- 5 files changed, 398 insertions(+), 97 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46b98eef3..111871824 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,7 @@ dependencies = [ "fontdb", "getrandom 0.3.4", "glam", + "hayro", "i18n-embed", "i18n-embed-fl", "iced", @@ -805,6 +806,17 @@ dependencies = [ "piper", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + [[package]] name = "brotli-decompressor" version = "5.0.3" @@ -1078,6 +1090,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1692,6 +1713,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "find-crate" version = "0.6.3" @@ -2080,6 +2107,23 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "skrifa 0.42.1", + "smallvec", + "vello_common 0.0.9", +] + [[package]] name = "glow" version = "0.17.0" @@ -2151,6 +2195,15 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "half" version = "2.7.1" @@ -2223,6 +2276,99 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hayro" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image", + "kurbo 0.13.1", + "pic-scale", + "vello_cpu", +] + +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-cmap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +dependencies = [ + "brotli", + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +dependencies = [ + "bitflags 2.13.1", + "hayro-cmap", + "hayro-syntax", + "kurbo 0.13.1", + "moxcms", + "phf", + "rustc-hash 2.1.3", + "siphasher", + "skrifa 0.42.1", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +dependencies = [ + "fearless_simd", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +dependencies = [ + "fearless_simd", +] + +[[package]] +name = "hayro-postscript" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash 2.1.3", + "smallvec", + "zune-jpeg", +] [[package]] name = "heck" @@ -2507,7 +2653,7 @@ dependencies = [ "bytemuck", "cryoglyph", "futures", - "guillotiere", + "guillotiere 0.6.2", "iced_debug", "iced_graphics", "log", @@ -2991,6 +3137,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -4140,6 +4298,19 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo 0.13.1", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4157,6 +4328,58 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pic-scale" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -4264,6 +4487,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -6167,6 +6399,53 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vello_common" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere 0.7.0", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere 0.7.0", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "vello_cpu" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "png 0.18.1", + "vello_common 0.0.8", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 8864ecbd6..894533e57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } +hayro = "0.7.1" # 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. diff --git a/src/app/mod.rs b/src/app/mod.rs index c7df63c77..6109b4ab9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3397,7 +3397,7 @@ pub enum Message { /// Open file-picker dialog for PDFATTACH command (async). PdfAttachPick, /// Result of the PDFATTACH file picker. - PdfAttachPickResult(Result), + PdfAttachPickResult(Result<(std::path::PathBuf, std::sync::Arc>), String>), // ── XREF ────────────────────────────────────────────────────────────── /// Open file-picker dialog for XATTACH command (async). diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 79046b306..2782556bf 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -953,20 +953,25 @@ impl OpenCADStudio { .await; match handle { - Some(h) => Ok(crate::sys::handle_path(&h)), + Some(h) => { + let path = crate::sys::handle_path(&h); + let bytes = std::sync::Arc::new(h.read().await); + Ok((path, bytes)) + } None => Err("Cancelled".to_string()), } }, Message::PdfAttachPickResult, ), - Message::PdfAttachPickResult(Ok(path)) => { + Message::PdfAttachPickResult(Ok((path, bytes))) => { use acadrust::objects::{ObjectType, UnderlayDefinition}; use crate::command::CadCommand; use crate::modules::insert::pdf_attach::PdfAttachCommand; let i = self.active_tab; let path_str = path.to_string_lossy().into_owned(); + crate::scene::model::pdf_raster::register_source(&path_str, bytes); let definition_handle = self.tabs[i].scene.document.allocate_handle(); diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index 029f565c9..d19c22113 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -1,19 +1,14 @@ -// PDF underlay rasterisation. -// -// No PDF crate: the page renders through the system `pdftoppm` (poppler-utils -// — present on virtually every Linux desktop, installable on Windows/macOS) -// into a temp PNG, which decodes through the existing `image` dependency. -// When the tool is missing the caller falls back to the underlay's outline -// placeholder, so the feature degrades instead of failing the load. -// -// Pages are cached per (resolved path, page name): underlays re-tessellate on -// every geometry bump, but the expensive external render runs once. +//! Cross-platform PDF underlay rasterisation. -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; -/// One rasterised PDF page. `dpi` ties the pixel size back to the page's -/// physical size: `inches = px / dpi`, which is what the underlay's world -/// quad is sized from (1 underlay unit = 1 PDF inch, AutoCAD's convention). +use hayro::hayro_interpret::InterpreterSettings; +use hayro::hayro_syntax::Pdf; +use hayro::vello_cpu::color::palette::css::WHITE; +use hayro::{RenderCache, RenderSettings}; + +/// One rasterised PDF page. `dpi` ties its pixel size to its physical size. pub struct PdfPage { pub pixels: Arc>, pub width: u32, @@ -21,98 +16,119 @@ pub struct PdfPage { pub dpi: f32, } -/// Raster resolution. 150 dpi puts an A4 page at ~1240×1754 px — crisp for -/// normal zooms without ballooning texture memory. -#[cfg(not(target_arch = "wasm32"))] -const RASTER_DPI: u32 = 150; +const RASTER_DPI: f32 = 150.0; -/// Rasterise `page` (a 1-based page name, e.g. "1") of the PDF at `path`. -/// `None` when the file/page can't be rendered (missing file, no pdftoppm, -/// bad page) — negative results are cached too, so a missing tool doesn't -/// re-spawn a process per tessellation. -#[cfg(not(target_arch = "wasm32"))] +type PageKey = (String, String); + +fn page_cache() -> &'static Mutex>>> { + static CACHE: OnceLock>>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn source_cache() -> &'static Mutex>>> { + static SOURCES: OnceLock>>>> = OnceLock::new(); + SOURCES.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Keep bytes selected through a file dialog. Browsers expose no reusable path, +/// while native builds also benefit by avoiding a second disk read. +pub fn register_source(path: &str, bytes: Arc>) { + source_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(path.to_string(), bytes); + page_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .retain(|(cached_path, _), _| cached_path != path); +} + +/// Rasterise a 1-based PDF page, memoised by source path and page name. pub fn rasterize_page(path: &str, page: &str) -> Option> { - use std::collections::HashMap; - use std::sync::{Mutex, OnceLock}; - - static CACHE: OnceLock>>>> = - OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - let key = (path.to_string(), page.to_string()); - if let Ok(c) = cache.lock() { - if let Some(hit) = c.get(&key) { - return hit.clone(); - } + if let Some(hit) = page_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(&key) + .cloned() + { + return hit; } + let built = rasterize_uncached(path, page); - if let Ok(mut c) = cache.lock() { - c.insert(key, built.clone()); - } + page_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(key, built.clone()); built } -#[cfg(not(target_arch = "wasm32"))] fn rasterize_uncached(path: &str, page: &str) -> Option> { - use std::process::Command; - - if !std::path::Path::new(path).is_file() { - return None; - } - let page_no: u32 = page.trim().parse().unwrap_or(1).max(1); - - // Unique output prefix in the system temp dir; pdftoppm appends - // `-` (with version-dependent zero padding) and `.png`. - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - use std::hash::{Hash, Hasher}; - (path, page_no).hash(&mut hasher); - let prefix = std::env::temp_dir().join(format!("ocs_pdf_{:016x}", hasher.finish())); - let prefix_str = prefix.to_string_lossy().into_owned(); - - let status = Command::new("pdftoppm") - .args([ - "-png", - "-r", - &RASTER_DPI.to_string(), - "-f", - &page_no.to_string(), - "-l", - &page_no.to_string(), - path, - &prefix_str, - ]) - .status() - .ok()?; - if !status.success() { - return None; - } - - // Find the produced file: `-1.png`, `-01.png`, … depending on the - // poppler version's padding. - let dir = prefix.parent()?; - let stem = prefix.file_name()?.to_string_lossy().into_owned(); - let mut produced: Option = None; - for entry in std::fs::read_dir(dir).ok()?.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - if name.starts_with(&stem) && name.ends_with(".png") { - produced = Some(entry.path()); - break; - } - } - let png = produced?; - let decoded = image::open(&png).ok()?.to_rgba8(); - let _ = std::fs::remove_file(&png); - let (width, height) = decoded.dimensions(); + let bytes = source_bytes(path)?; + let pdf = Pdf::new(bytes).ok()?; + let page_no = page.trim().parse::().unwrap_or(1).max(1); + let page = pdf.pages().get(page_no - 1)?; + let scale = RASTER_DPI / 72.0; + let pixmap = hayro::render( + page, + &RenderCache::new(), + &InterpreterSettings::default(), + &RenderSettings { + x_scale: scale, + y_scale: scale, + bg_color: WHITE, + ..Default::default() + }, + ); + let width = u32::from(pixmap.width()); + let height = u32::from(pixmap.height()); Some(Arc::new(PdfPage { - pixels: Arc::new(decoded.into_raw()), + pixels: Arc::new(pixmap.data_as_u8_slice().to_vec()), width, height, - dpi: RASTER_DPI as f32, + dpi: RASTER_DPI, })) } -/// wasm: no external process — underlays keep their outline placeholder. -#[cfg(target_arch = "wasm32")] -pub fn rasterize_page(_path: &str, _page: &str) -> Option> { - None +fn source_bytes(path: &str) -> Option>> { + if let Some(bytes) = source_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(path) + .cloned() + { + return Some(bytes); + } + + #[cfg(not(target_arch = "wasm32"))] + { + std::fs::read(path).ok().map(Arc::new) + } + #[cfg(target_arch = "wasm32")] + { + None + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use printpdf::{Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions}; + + use super::*; + + #[test] + fn registered_pdf_bytes_render_without_a_filesystem_path() { + let mut document = PdfDocument::new("PDF underlay test"); + document + .pages + .push(OutputPage::new(Mm(25.4), Mm(25.4), Vec::new())); + let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new()); + let path = "memory://pdf-underlay-test.pdf"; + + register_source(path, Arc::new(bytes)); + let page = rasterize_page(path, "1").expect("registered PDF should render"); + + assert_eq!((page.width, page.height), (150, 150)); + assert_eq!(page.pixels.len(), 150 * 150 * 4); + } }