fix: render PDF attachments on every platform

Fixes #1243
This commit is contained in:
Hakan Seven committed 2026-09-14 12:53:06 +03:00
1 parent 010d23fa95
commit cba64631b8
5 files changed
+398 -97

No files matched your search

Generated
+280 -1
View File
@@ -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"
+1
View File
@@ -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.
+1 -1
View File
@@ -3397,7 +3397,7 @@ pub enum Message {
/// Open file-picker dialog for PDFATTACH command (async).
PdfAttachPick,
/// Result of the PDFATTACH file picker.
PdfAttachPickResult(Result<std::path::PathBuf, String>),
PdfAttachPickResult(Result<(std::path::PathBuf, std::sync::Arc<Vec<u8>>), String>),
// ── XREF ──────────────────────────────────────────────────────────────
/// Open file-picker dialog for XATTACH command (async).
+7 -2
View File
@@ -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();
+109 -93
View File
@@ -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<Vec<u8>>,
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<HashMap<PageKey, Option<Arc<PdfPage>>>> {
static CACHE: OnceLock<Mutex<HashMap<PageKey, Option<Arc<PdfPage>>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn source_cache() -> &'static Mutex<HashMap<String, Arc<Vec<u8>>>> {
static SOURCES: OnceLock<Mutex<HashMap<String, Arc<Vec<u8>>>>> = 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<Vec<u8>>) {
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<Arc<PdfPage>> {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<(String, String), Option<Arc<PdfPage>>>>> =
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<Arc<PdfPage>> {
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
// `-<page>` (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: `<prefix>-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<std::path::PathBuf> = 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::<usize>().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<Arc<PdfPage>> {
None
fn source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
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);
}
}