From 28efbffd575eb0d87625e0e8656cd0ba015fe9ba Mon Sep 17 00:00:00 2001 From: huanld Date: Wed, 16 Sep 2026 16:42:21 +0700 Subject: [PATCH 1/3] feat: embed images (OLE2FRAME), print pipeline fixes, plot prefs, community fonts - IMAGEEMBED: pack raster images into OLE2FRAME entities so a .dwg is self-contained; 'Embed Image' entry in the XREF attach menu; control/MCP op embed_image for external tools; dxf_name for OLE2FRAME queries. - Scene: populate_images_from_document now rebuilds OLE2FRAME and PDF underlay models too (they used to vanish after any XREF op). - Printing: add PRINTER_ENUM_NETWORK pass when listing printers; GDI spooler fallback (hayro raster at 300 DPI + StretchDIBits) for hosts whose PDF handler registers no print verb; DEVMODE orientation with a 90-degree raster rotation fallback for v4 drivers; fix the fit-scale precedence bug that printed blank pages. - PDF export dialogs on Windows use the async file dialog so the native overwrite confirmation no longer deadlocks the UI thread. - Plot dialog: persist the chosen printer (saved on selection) and the picked plot window across sessions; page setups with no device no longer reset the output device. - Community fonts: detect unresolvable .shx references on open and offer a download from the repository's fonts/ folder (contribution guide in fonts/README.md); custom font source URL setting for intranet font servers; downloaded fonts resolve from the per-user fonts directory. --- Cargo.toml | 2 +- fonts/README.md | 32 ++ src/app/command_driver.rs | 5 + src/app/commands/draw.rs | 4 + src/app/commands/mod.rs | 1 + src/app/control/actions.rs | 47 +++ src/app/control/mod.rs | 38 ++ src/app/mod.rs | 29 ++ src/app/settings.rs | 15 + src/app/update/file.rs | 136 ++++++ src/app/update/mod.rs | 114 ++++- src/app/view/modal.rs | 12 + src/entities/names.rs | 1 + src/entities/text_support.rs | 6 + src/io/font_repo.rs | 273 ++++++++++++ src/io/mod.rs | 19 + src/io/ole_embed.rs | 261 ++++++++++++ src/io/pdf_export.rs | 21 +- src/io/print_to_printer.rs | 587 ++++++++++++++++++++++++-- src/mcp.rs | 13 +- src/modules/draw/draw/raster_image.rs | 83 +++- src/scene/entity.rs | 18 +- src/scene/model/pdf_raster.rs | 20 +- src/ui/window/missing_fonts.rs | 84 ++++ src/ui/window/mod.rs | 1 + src/ui/window/plot.rs | 6 + src/ui/window/xref_manager.rs | 5 + 27 files changed, 1767 insertions(+), 66 deletions(-) create mode 100644 fonts/README.md create mode 100644 src/io/font_repo.rs create mode 100644 src/io/ole_embed.rs create mode 100644 src/ui/window/missing_fonts.rs diff --git a/Cargo.toml b/Cargo.toml index 03f1476fa..c2ef74fb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,7 @@ iced_test = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa naga = { version = "27", features = ["wgsl-in"] } [target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] } +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi", "Win32_Storage_Xps"] } [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } diff --git a/fonts/README.md b/fonts/README.md new file mode 100644 index 000000000..4ed1ccf90 --- /dev/null +++ b/fonts/README.md @@ -0,0 +1,32 @@ +# OpenCADStudio Community Fonts + +Thư mục này là **kho font cộng đồng** của OpenCADStudio. Khi mở một bản vẽ +DWG/DXF tham chiếu font (`.shx`) không có trên máy, ứng dụng sẽ hỏi người dùng +và tự động tải font từ thư mục này, thay vì vẽ chữ bằng font thay thế bị lệch/vỡ. + +When a drawing references an `.shx` font that is missing on the machine, +OpenCADStudio offers to download it from this folder automatically instead of +rendering with a mismatched substitute font. + +## Cách đóng góp / How to contribute + +1. Fork repository, thêm font vào thư mục `fonts/` ở **thư mục gốc của repo** + (cùng cấp với `src/`, `crates/`, …), rồi mở Pull Request. +2. Giữ nguyên **tên file viết thường** như bản vẽ tham chiếu, ví dụ + `vnarial.shx`, `romans.shx`, `simplex.shx`. Ứng dụng so khớp tên file + không phân biệt hoa/thường. +3. Định dạng hỗ trợ: `.shx` (AutoCAD compiled shape font). +4. **Giấy phép / License**: chỉ đóng góp font bạn có quyền phân phối lại — + font do bạn tự vẽ, font nguồn mở, hoặc font thuộc public domain. + Ghi nguồn + giấy phép vào cuối file này theo mẫu bên dưới. + Only contribute fonts you have the right to redistribute, and record the + source and license below. + +Ứng dụng tải font về thư mục cài đặt người dùng +(`%APPDATA%/OpenCADStudio/fonts` trên Windows) và dùng lại cho các lần mở sau. + +## Danh mục font / Font catalog + +| File | Nguồn / Source | Giấy phép / License | +|------|----------------|---------------------| +| — | — | — | diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1465d86dc..b53b88664 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4635,6 +4635,7 @@ impl OpenCADStudio { let x1 = p1.x.max(p2.x); let y1 = p1.y.max(p2.y); self.plot_window = Some((x0, y0, x1, y1)); + self.plot_dialog.window = self.plot_window; self.command_line.push_output( crate::tf!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}").as_ref(), ); @@ -4642,6 +4643,8 @@ impl OpenCADStudio { // receive the two clicks — bring the dialog back with the // window now active. self.plot_dialog.area = "Window".to_string(); + // Remember the pick immediately, like the printer choice. + self.save_config(); self.active_modal = Some(super::ModalKind::Plot); } else { // PLOTWINDOW always describes the plotted layout. In MSPACE @@ -4654,10 +4657,12 @@ impl OpenCADStudio { let x2 = p1.x.max(p2.x); let y2 = p1.y.max(p2.y); self.plot_window = Some((x1, y1, x2, y2)); + self.plot_dialog.window = self.plot_window; self.command_line.push_output( crate::tf!("Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}").as_ref(), ); self.plot_dialog.area = "Window".to_string(); + self.save_config(); self.active_modal = Some(super::ModalKind::Plot); } self.tabs[i].active_cmd = None; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index d021b1d6b..cda6c5a90 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -57,6 +57,10 @@ impl OpenCADStudio { return Some(Task::done(Message::ImagePick)); } + cmd if cmd == "IMAGEEMBED" => { + return Some(Task::done(Message::ImageEmbedPick)); + } + "REVCLOUD" | "REVCLOUD_RECTANGULAR" | "REVCLOUD_POLYGONAL" | "REVCLOUD_FREEHAND" => { use crate::modules::draw::draw::revcloud::RevCloudCommand; let view_height = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0; diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 9c60e3660..29efabd84 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -622,6 +622,7 @@ inventory::submit!(crate::command::CommandRegistration { "IM", "IMAGE", "IMAGEATTACH", + "IMAGEEMBED", "IMPORTOBJ", "ISOLATEOBJECTS", "LA", diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 13ae83069..8c45f84d6 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -67,7 +67,54 @@ pub(super) const NAMES: &[&str] = &[ "undo", "redo", ]; + +/// Parse the `at` placement point for `embed_image`: `[x,y]` or `[x,y,z]`. +fn embed_point(req: &Value) -> Result { + let values = req["at"] + .as_array() + .filter(|v| (2..=3).contains(&v.len())) + .ok_or_else(|| failure("invalid_point", "Expected at:[x,y] or [x,y,z]"))?; + let mut p = [0.0_f64; 3]; + for (i, v) in values.iter().enumerate() { + p[i] = v + .as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(|| failure("invalid_point", "Expected finite coordinates"))?; + } + Ok(acadrust::types::Vector3::new(p[0], p[1], p[2])) +} impl OpenCADStudio { + /// `embed_image` — pack the picture at `path` into an OLE2FRAME placed + /// with its lower-left corner at `at` (default width ≈ pixel_width/100, + /// like the interactive command's Enter answer) and commit it as one + /// undo step. The drawing stays self-contained: no external file to lose. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn control_embed_image(&mut self, req: &Value) -> Result, Value> { + let i = self.active_tab; + let path = string(req, "path")?; + let image = crate::io::ole_embed::EmbeddedImage::from_file(std::path::Path::new(&path)) + .map_err(|e| failure("embed_failed", e))?; + let at = embed_point(req)?; + let default_width = (image.pixel_width as f64 / 100.0).max(1.0); + let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width); + self.push_undo_snapshot(i, "IMAGEEMBED"); + let handle = crate::io::ole_embed::add_embedded_image( + &mut self.tabs[i].scene.document, + &image, + at, + width, + ) + .map_err(|e| failure("embed_failed", e))?; + self.post_ref_op(i); + self.set_control_result(json!({ + "handle": format!("{:X}", handle.value()), + "kind": "Ole2Frame", + "path": path, + "width": width, + })); + Ok(Task::none()) + } + pub(super) fn control_properties(&mut self) -> Value { self.refresh_properties(); json!({"ok":true,"sections":self.tabs[self.active_tab].properties.sections.iter().map(|s|json!({"title":s.title,"properties":s.props.iter().map(|p|{ diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 797f40365..9c58bd212 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -781,6 +781,8 @@ impl OpenCADStudio { "set_properties" => self.control_set_record_properties(req)?, "action" => self.control_ui_action(req)?, #[cfg(not(target_arch = "wasm32"))] + "embed_image" => self.control_embed_image(req)?, + #[cfg(not(target_arch = "wasm32"))] "save" => { let path = req["path"] .as_str() @@ -1387,4 +1389,40 @@ mod tests { ); let _ = std::fs::remove_file(path); } + + #[test] + fn embed_image_op_places_an_ole2frame_and_undoes_it() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + let img = image::RgbaImage::from_pixel(40, 30, image::Rgba([9, 9, 9, 255])); + let path = std::env::temp_dir().join(format!("ocs-embed-{}.png", session_id())); + img.save(&path).unwrap(); + + let response = request( + &mut app, + json!({"op":"embed_image","path":path.to_string_lossy(),"at":[0,0,0],"width":20}), + ); + assert_eq!(response["status"], "completed", "{response}"); + let handle = response["result"]["handle"].as_str().unwrap().to_string(); + assert_eq!(response["result"]["kind"], "Ole2Frame"); + let ole_count = |app: &OpenCADStudio| { + app.tabs[app.active_tab] + .scene + .document + .entities() + .filter(|e| matches!(e, acadrust::EntityType::Ole2Frame(_))) + .count() + }; + assert_eq!(ole_count(&app), 1); + + // The entity must be queryable like any other and undoable as one step. + let queried = + app.automation_op(json!({"op":"entities","type":"OLE2FRAME"}).to_string().as_str()); + assert_eq!(queried["total"], 1, "{queried}"); + request(&mut app, json!({"op":"undo"})); + assert_eq!(ole_count(&app), 0); + let _ = std::fs::remove_file(path); + let _ = handle; + } } + diff --git a/src/app/mod.rs b/src/app/mod.rs index 157b4312f..69c6e42bf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -861,6 +861,12 @@ pub(super) struct OpenCADStudio { point_size_relative: bool, /// Whether the default-association prompt has been answered. default_assoc_prompted: bool, + /// Offer to download missing `.shx` fonts on open (see `io::font_repo`). + check_missing_fonts: bool, + /// Custom font source base URL; empty selects the community repository. + font_source_url: String, + /// Editable copy of `font_source_url` shown in the missing-fonts prompt. + font_source_input: String, donation_prompt_version: String, /// Read-only session (`--read-only`): editing is allowed but every save /// path is refused. Set once at boot from the CLI config. @@ -1137,6 +1143,9 @@ pub(super) struct OpenCADStudio { open_job_serial: u64, /// Last repair or failed-open report shown in the recovery modal. recovery_report: Option, + /// Missing SHX fonts of the last opened drawing, offered for download + /// from the community repository (see `crate::io::font_repo`). + missing_fonts: Option>, /// Drawings handed to us by other launches while `opening` was busy. /// `opening` is a single slot that a second `OpenPathPicked` would /// overwrite, and `on_file_opened` drops any result arriving once it is @@ -1757,6 +1766,8 @@ pub enum ModalKind { Unsaved, SaveDialog, Recovery, + /// Fonts referenced by the drawing are missing on this machine. + MissingFonts, RecoveryPrompt, Options, FindReplace, @@ -3517,6 +3528,20 @@ pub enum Message { ImagePick, /// Result of the image file picker + pixel dimension decode. ImagePickResult(Result<(std::path::PathBuf, u32, u32), String>), + /// Open file-picker dialog for IMAGEEMBED command (async). + ImageEmbedPick, + /// Result of the embedded-image picker: bytes prepared for an OLE2FRAME. + ImageEmbedPickResult(Result), + // ── Missing fonts ───────────────────────────────────────────────────── + /// Download the offered missing fonts from the community repository. + MissingFontsDownload, + /// Edit the custom font source URL in the missing-fonts prompt. + MissingFontsSourceChanged(String), + /// Close the missing-fonts prompt without downloading. + MissingFontsDismiss, + /// Fonts fetched (or failed); payload lists (name, saved-path) pairs. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + MissingFontsResult(Result, String>), // ── PDF Underlay ────────────────────────────────────────────────────── /// Open file-picker dialog for PDFATTACH command (async). PdfAttachPick, @@ -3843,6 +3868,9 @@ impl OpenCADStudio { point_size_buf: String::new(), point_size_relative: true, default_assoc_prompted: false, + check_missing_fonts: true, + font_source_url: String::new(), + font_source_input: String::new(), donation_prompt_version: String::new(), read_only: false, update_notice_version: None, @@ -3883,6 +3911,7 @@ impl OpenCADStudio { layout_settling: false, open_job_serial: 0, recovery_report: None, + missing_fonts: None, pending_opens: std::collections::VecDeque::new(), active_interaction_index: None, queued_interaction_indices: std::collections::VecDeque::new(), diff --git a/src/app/settings.rs b/src/app/settings.rs index 9c2757542..f4e8b9f36 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -338,6 +338,15 @@ pub struct UserSettings { /// prompt has already been shown. Set once the user answers (either way), /// so we never nag again on subsequent launches. pub default_assoc_prompted: bool, + /// Offer to download missing `.shx` fonts from the community repository + /// when a drawing opens (see `crate::io::font_repo`). + #[serde(default = "default_check_missing_fonts")] + pub check_missing_fonts: bool, + /// Custom font source base URL (empty = the OpenCADStudio community + /// repository). Each missing font is fetched as `{base}/{file_name}`, + /// so an intranet folder or a private GitHub raw folder both work. + #[serde(default)] + pub font_source_url: String, /// App version whose donation prompt has been displayed. pub donation_prompt_version: String, /// The graphics verdict (`GpuStatus::identity()`) whose warning popup the @@ -470,6 +479,10 @@ fn default_dimension_continue_mode() -> i16 { 1 } +fn default_check_missing_fonts() -> bool { + true +} + fn default_show_constraint_values() -> bool { true } @@ -521,6 +534,8 @@ impl Default for UserSettings { snap_angle_deg: 0.0, otrack: false, default_assoc_prompted: false, + check_missing_fonts: true, + font_source_url: String::new(), donation_prompt_version: String::new(), gpu_warning_silenced: String::new(), disabled_plugins: Vec::new(), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index e31f8c07f..dcbfa8baa 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -406,6 +406,8 @@ impl OpenCADStudio { snap_angle_deg: self.snap_angle_deg, otrack: self.snapper.otrack_enabled, default_assoc_prompted: self.default_assoc_prompted, + check_missing_fonts: self.check_missing_fonts, + font_source_url: self.font_source_url.clone(), donation_prompt_version: self.donation_prompt_version.clone(), gpu_warning_silenced: self.gpu_warning_silenced.clone(), disabled_plugins: { @@ -489,6 +491,9 @@ impl OpenCADStudio { // open / tab switch), not app-global, so they are not applied here. self.snapper.otrack_enabled = s.otrack; self.default_assoc_prompted = s.default_assoc_prompted; + self.check_missing_fonts = s.check_missing_fonts; + self.font_source_url = s.font_source_url.clone(); + self.font_source_input = s.font_source_url.clone(); self.donation_prompt_version = s.donation_prompt_version.clone(); self.gpu_warning_silenced = s.gpu_warning_silenced.clone(); self.disabled_plugins = s.disabled_plugins.iter().cloned().collect(); @@ -1636,6 +1641,21 @@ impl OpenCADStudio { self.active_modal = Some(crate::app::ModalKind::Recovery); Task::none() } else { + #[cfg(not(target_arch = "wasm32"))] + { + // A drawing referencing fonts this machine lacks offers to + // fetch them from the community repository before the user + // studies garbled substitute text (unless recovery already + // owns the modal slot). + let missing = crate::io::font_repo::missing_shx_fonts( + &self.tabs[i].scene.document, + ); + if !missing.is_empty() && self.check_missing_fonts { + self.font_source_input = self.font_source_url.clone(); + self.missing_fonts = Some(missing); + self.active_modal = Some(crate::app::ModalKind::MissingFonts); + } + } self.drain_pending_open() }; Task::batch([thumbs_task, pending_open_task, interaction_task]) @@ -3012,9 +3032,15 @@ impl OpenCADStudio { .into_iter() .map(|(name, _, factor)| (name, factor)) .collect(); + // This load only refreshes paper/scale context from the layout; + // the device the user just plotted with must survive it. + let kept_printer = self.plot_dialog.printer.clone(); + let kept_to_file = self.plot_dialog.to_file; if let Some(settings) = self.tabs[i].scene.effective_plot_settings() { self.load_plotsettings_into_dialog(&settings); } + self.plot_dialog.printer = kept_printer; + self.plot_dialog.to_file = kept_to_file; } let Some(page) = self.direct_plot_page() else { @@ -3178,6 +3204,7 @@ impl OpenCADStudio { } else { self.plot_dialog.paper_space = true; self.plot_window = None; + self.plot_dialog.window = None; self.load_plotsettings_into_dialog(&page_setup); } let dialog = self.plot_dialog.clone(); @@ -3703,6 +3730,13 @@ impl OpenCADStudio { self.plot_prev = Some(previous); let cur = self.tabs[self.active_tab].scene.current_layout.clone(); let layout_entry = format!("*{cur}*"); + // The auto-applied page setup must not reset the output device: a + // drawing rarely stores one, so honouring it would snap the dialog + // back to the system default on every open. The user's last choice + // (persisted across sessions with the rest of the dialog) survives + // unless the setup names an explicit device of its own. + let kept_printer = self.plot_dialog.printer.clone(); + let kept_to_file = self.plot_dialog.to_file; if self.tabs[self.active_tab] .scene .plot_settings_for(&cur) @@ -3712,6 +3746,19 @@ impl OpenCADStudio { } else { self.select_page_setup(crate::ui::window::plot::SETUP_PREV); } + let setup_named_a_device = + self.plot_dialog.printer.is_some() || self.plot_dialog.to_file; + if !setup_named_a_device { + self.plot_dialog.printer = kept_printer; + self.plot_dialog.to_file = kept_to_file; + } + // A persisted plot window (saved with the dialog settings) restores + // across sessions; a freshly loaded one wins so the two stay in sync. + if self.plot_window.is_none() { + self.plot_window = self.plot_dialog.window; + } else { + self.plot_dialog.window = self.plot_window; + } if self.plot_dialog.scale.eq_ignore_ascii_case("fit") { self.plot_dialog.fit_to_paper = true; self.plot_dialog.scale = one_to_one.clone(); @@ -3768,6 +3815,9 @@ impl OpenCADStudio { self.plot_dialog.to_file = false; self.plot_dialog.printer = Some(s); } + // Persist the device choice immediately, so quitting without + // plotting still remembers it for next session. + self.save_config(); Task::none() } M::PrinterProperties => { @@ -4242,6 +4292,7 @@ impl OpenCADStudio { ps.plot_window.upper_right_x, ps.plot_window.upper_right_y, )); + self.plot_dialog.window = self.plot_window; } if !ps.current_style_sheet.is_empty() && self @@ -4803,3 +4854,88 @@ impl OpenCADStudio { ) } } + +#[cfg(test)] +mod plot_device_persistence_tests { + use super::*; + + /// Reopening the Plot dialog must keep the user's printer. Layouts in + /// real drawings carry plot settings with no device, and the auto-applied + /// page setup used to reset the choice to the system default on every + /// open. + #[test] + fn reopening_the_plot_dialog_keeps_the_chosen_printer() { + let mut app = OpenCADStudio::new_for_test(); + // A real drawing's layout carries plot settings with no device; the + // auto-applied setup used to reset the choice to the default. + let mut ps = acadrust::objects::PlotSettings::default(); + ps.printer_name = String::new(); + let layout = app.tabs[app.active_tab].scene.current_layout.clone(); + assert!( + app.tabs[app.active_tab] + .scene + .set_layout_plot_settings(&layout, &ps), + "the test layout must accept plot settings" + ); + app.plot_dialog.printer = Some("Brother DCP-L2520D series".into()); + app.plot_dialog.to_file = false; + app.on_plot_dialog_open(); + assert_eq!( + app.plot_dialog.printer.as_deref(), + Some("Brother DCP-L2520D series"), + "the chosen printer must survive the auto-applied page setup" + ); + assert!(!app.plot_dialog.to_file); + } + + /// A layout whose plot settings name an explicit device wins over the + /// saved preference: selecting its setup is an explicit device choice. + #[test] + fn layout_device_overrides_the_saved_preference() { + let mut app = OpenCADStudio::new_for_test(); + app.plot_dialog.printer = Some("Brother DCP-L2520D series".into()); + let mut ps = acadrust::objects::PlotSettings::default(); + ps.printer_name = "Godex G500".into(); + app.load_plotsettings_into_dialog(&ps); + assert_eq!(app.plot_dialog.printer.as_deref(), Some("Godex G500")); + assert!(!app.plot_dialog.to_file); + } + + /// A persisted plot window (saved with the dialog settings) seeds the + /// runtime window when the dialog reopens, so `area == "Window"` keeps + /// plotting the same rectangle instead of reporting an empty area. + #[test] + fn persisted_window_seeds_the_dialog_on_reopen() { + let mut app = OpenCADStudio::new_for_test(); + assert!(app.plot_window.is_none()); + app.plot_dialog.area = "Window".into(); + app.plot_dialog.window = Some((-10.0, -5.0, 30.0, 15.0)); + app.on_plot_dialog_open(); + assert_eq!(app.plot_window, Some((-10.0, -5.0, 30.0, 15.0))); + } + + /// A window freshly loaded from a layout's plot settings is mirrored into + /// the dialog so it persists with the next config save. + #[test] + fn layout_window_is_mirrored_into_the_dialog() { + let mut app = OpenCADStudio::new_for_test(); + let mut ps = acadrust::objects::PlotSettings::default(); + ps.plot_type = acadrust::objects::PlotType::Window; + ps.set_plot_window(1.0, 2.0, 3.0, 4.0); + app.load_plotsettings_into_dialog(&ps); + assert_eq!(app.plot_window, Some((1.0, 2.0, 3.0, 4.0))); + assert_eq!(app.plot_dialog.window, Some((1.0, 2.0, 3.0, 4.0))); + } + + /// A layout pointing at a PDF device switches the dialog to file output. + #[test] + fn pdf_device_switches_to_file_output() { + let mut app = OpenCADStudio::new_for_test(); + app.plot_dialog.printer = Some("Brother DCP-L2520D series".into()); + let mut ps = acadrust::objects::PlotSettings::default(); + ps.printer_name = "Microsoft Print to PDF".into(); + app.load_plotsettings_into_dialog(&ps); + assert!(app.plot_dialog.to_file); + assert!(app.plot_dialog.printer.is_none()); + } +} diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 0a0607929..75a797f7e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -951,6 +951,93 @@ impl OpenCADStudio { } Task::none() } + + Message::ImageEmbedPick => { + Task::perform(crate::io::pick_embedded_image_file(), Message::ImageEmbedPickResult) + } + + Message::ImageEmbedPickResult(Ok(image)) => { + use crate::command::CadCommand; + use crate::modules::draw::draw::raster_image::ImageCommand; + self.command_line.push_output(crate::tf!( + "IMAGEEMBED \"{name}\": {w}×{h} px (embedded)", + name = image.name.as_str(), + w = image.pixel_width, + h = image.pixel_height, + ).as_ref()); + let cmd = ImageCommand::new_embedded(image); + let i = self.active_tab; + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + Task::none() + } + + Message::ImageEmbedPickResult(Err(e)) => { + if e != "Cancelled" { + self.command_line + .push_error(crate::tf!("IMAGEEMBED: {e}").as_ref()); + } + Task::none() + } + + Message::MissingFontsSourceChanged(url) => { + self.font_source_input = url; + Task::none() + } + Message::MissingFontsDownload => { + // Remember the source across sessions — and across drawings. + let source = self.font_source_input.trim().to_string(); + if self.font_source_url != source { + self.font_source_url = source.clone(); + self.save_config(); + } + let fonts = self.missing_fonts.take().unwrap_or_default(); + Task::perform( + async move { + let source = crate::io::font_repo::FontSource::from_url(&source); + crate::io::font_repo::download_fonts(&fonts, &source) + }, + Message::MissingFontsResult, + ) + } + Message::MissingFontsDismiss => { + self.missing_fonts = None; + self.close_active_modal(); + Task::none() + } + Message::MissingFontsResult(result) => { + self.missing_fonts = None; + self.close_active_modal(); + match result { + Ok(pairs) if pairs.is_empty() => { + self.command_line.push_error(crate::t!( + "None of the missing fonts are in the community repository yet. Contribute them at github.com/huaninstratech/OpenCADStudio/tree/main/fonts." + ).as_ref()); + } + Ok(pairs) => { + for (name, path) in &pairs { + self.command_line.push_output(crate::tf!( + "FONT Downloaded {name} → {path}", + path = path.display() + ).as_ref()); + } + // The downloaded files change glyph resolution for the + // whole drawing — reload it through the standard open + // pipeline so every wire is rebuilt with the real fonts. + let i = self.active_tab; + if let Some(path) = self.tabs[i].current_path.clone() { + return Task::done(Message::OpenExternal(path)); + } + self.command_line.push_info(crate::t!( + "Save and reopen the drawing to apply the new fonts." + ).as_ref()); + } + Err(e) => { + self.command_line.push_error(crate::tf!("Font download failed: {e}").as_ref()); + } + } + Task::none() + } Message::PdfAttachPick => Task::perform( async { let handle = crate::sys::file_dialog() @@ -8281,7 +8368,7 @@ impl OpenCADStudio { .and_then(|path| path.file_stem()) .map(|name| format!("{}_layouts", name.to_string_lossy())) .unwrap_or_else(|| "drawing_layouts".into()); - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { let Some(window_id) = self.main_window else { return Task::done(Message::PrintAllPdfPath(None)); @@ -8291,6 +8378,13 @@ impl OpenCADStudio { }) .map(Message::PrintAllPdfPath) } + #[cfg(target_os = "windows")] + { + Task::perform( + crate::io::pdf_export::pick_pdf_path_async(stem), + Message::PrintAllPdfPath, + ) + } #[cfg(target_arch = "wasm32")] { let _ = stem; @@ -8326,7 +8420,7 @@ impl OpenCADStudio { .and_then(|p: &std::path::Path| p.file_stem()) .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".into()); - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { let Some(window_id) = self.main_window else { return Task::done(Message::PlotExportPath(None)); @@ -8336,6 +8430,13 @@ impl OpenCADStudio { }) .map(Message::PlotExportPath) } + #[cfg(target_os = "windows")] + { + Task::perform( + crate::io::pdf_export::pick_pdf_path_async(stem), + Message::PlotExportPath, + ) + } #[cfg(target_arch = "wasm32")] { Task::perform( @@ -8363,7 +8464,7 @@ impl OpenCADStudio { .and_then(|p: &std::path::Path| p.file_stem()) .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".into()); - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { let Some(window_id) = self.main_window else { return Task::done(Message::PlotWindowExportPath(None)); @@ -8373,6 +8474,13 @@ impl OpenCADStudio { }) .map(Message::PlotWindowExportPath) } + #[cfg(target_os = "windows")] + { + Task::perform( + crate::io::pdf_export::pick_pdf_path_async(stem), + Message::PlotWindowExportPath, + ) + } #[cfg(target_arch = "wasm32")] { Task::perform( diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 8df1e0324..fd2f88aec 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -53,6 +53,7 @@ impl OpenCADStudio { Some(K::AttributeEditor) => crate::tr!("modal", "attribute-editor"), Some(K::SaveDialog) => crate::tr!("modal", "save-drawing-as"), Some(K::Recovery) => crate::tr!("modal", "recovery-report"), + Some(K::MissingFonts) => crate::t!("Missing fonts").into_owned(), Some(K::RecoveryPrompt) => crate::tr!("modal", "recovery-prompt"), Some(K::GpuWarning) => crate::tr!("gpu", "title"), None => String::new(), @@ -1568,6 +1569,17 @@ impl OpenCADStudio { ) }) } + super::super::ModalKind::MissingFonts => { + let fonts = self.missing_fonts.as_ref()?; + let font_source = &self.font_source_input; + automatic_flow(ex, |flow| { + crate::ui::window::missing_fonts::view_window( + fonts, + &font_source, + flow, + ) + }) + } super::super::ModalKind::Recovery => { let report = self.recovery_report.as_ref()?; sized_flow(ex, 680, 460, |flow| { diff --git a/src/entities/names.rs b/src/entities/names.rs index c26b252f2..1b4bfb8d1 100644 --- a/src/entities/names.rs +++ b/src/entities/names.rs @@ -143,6 +143,7 @@ pub fn dxf_name(e: &EntityType) -> &'static str { EntityType::MLine(_) => "MLINE", EntityType::RasterImage(_) => "RASTERIMAGE", EntityType::Wipeout(_) => "WIPEOUT", + EntityType::Ole2Frame(_) => "OLE2FRAME", EntityType::Underlay(_) => "UNDERLAY", EntityType::AttributeDefinition(_) => "ATTDEF", EntityType::AttributeEntity(_) => "ATTRIB", diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 8227ec73a..f26f7d52a 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -39,6 +39,12 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT .map(std::path::Path::new) .and_then(|p| p.parent()); crate::io::resolve_image_file(file, base) + // Fonts fetched from the community repository live in + // the per-user fonts folder — search it last. + .or_else(|| { + crate::io::font_repo::local_font_file(file) + .map(|path| path.to_string_lossy().into_owned()) + }) }) .flatten(); if let Some(p) = shx_path { diff --git a/src/io/font_repo.rs b/src/io/font_repo.rs new file mode 100644 index 000000000..5d5ab407c --- /dev/null +++ b/src/io/font_repo.rs @@ -0,0 +1,273 @@ +// Community font repository — detect missing SHX fonts and fetch them from +// the project's `fonts/` folder on GitHub. +// +// DWG files reference SHX fonts by bare name (`vnarial.shx`, `romans.shx`, …). +// When the file doesn't resolve on disk the renderer substitutes an embedded +// LFF stroke font, which mangles drawings authored with fonts it cannot stand +// in for. This module detects those references and downloads the real files +// from the community folder, where anyone can contribute (see +// `fonts/README.md` in the repository root). + +use std::io::Read as _; +use std::path::{Path, PathBuf}; + +use acadrust::CadDocument; + +/// The community folder's GitHub contents API (lists name + download URL). +const REPO_API_URL: &str = + "https://api.github.com/repos/huaninstratech/OpenCADStudio/contents/fonts"; + +/// Local store for fonts downloaded from the community repository. Text +/// resolution searches here after the drawing folder. +#[cfg(not(target_arch = "wasm32"))] +pub fn fonts_dir() -> Option { + crate::config::config_dir().map(|dir| dir.join("fonts")) +} + +/// Web builds have no filesystem to cache fonts into. +#[cfg(target_arch = "wasm32")] +pub fn fonts_dir() -> Option { + None +} + +/// Where missing fonts are fetched from. The community repository is the +/// default; a custom source is any HTTP folder - a company file server, a +/// private GitHub repo's raw folder - where each font lives as +/// `{base}/{file_name}`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FontSource { + Community, + Custom(String), +} + +impl FontSource { + /// Empty / whitespace input selects the community repository. + pub fn from_url(url: &str) -> Self { + match url.trim() { + "" => FontSource::Community, + trimmed => FontSource::Custom(trimmed.trim_end_matches('/').to_string()), + } + } +} + +/// One file listed in the community folder. +#[derive(Debug, Clone, PartialEq)] +pub struct RepoFont { + pub name: String, + pub download_url: String, +} + +/// Referenced-but-unresolvable `.shx` font files across the document's text +/// styles. Names are deduplicated case-insensitively and reduced to the bare +/// file name (`C:\Fonts\VNArial.shx` → `VNArial.shx`). +pub fn missing_shx_fonts(doc: &CadDocument) -> Vec { + let mut missing: Vec = Vec::new(); + let base = doc + .source_path + .as_deref() + .map(Path::new) + .and_then(|p| p.parent()); + for style in doc.text_styles.iter() { + let file = style.font_file.trim(); + if file.is_empty() || !file.to_ascii_lowercase().ends_with(".shx") { + continue; + } + let resolved = crate::io::resolve_image_file(file, base).is_some() + || local_font_file(file).is_some(); + if resolved { + continue; + } + let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string(); + if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) { + missing.push(name); + } + } + missing.sort_by_key(|name| name.to_ascii_lowercase()); + missing +} + +/// A font file previously downloaded into the local fonts directory, matched +/// case-insensitively by file name. +pub fn local_font_file(name: &str) -> Option { + let file = name.rsplit(['/', '\\']).next()?; + let dir = fonts_dir()?; + let direct = dir.join(file); + if direct.is_file() { + return Some(direct); + } + let lower = file.to_ascii_lowercase(); + std::fs::read_dir(&dir) + .ok()? + .filter_map(|entry| entry.ok()) + .find(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase() == lower) + .map(|entry| entry.path()) +} + +/// List the community folder through the GitHub contents API. +fn fetch_repo_fonts() -> Result, String> { + let agent = crate::network::agent(std::time::Duration::from_secs(15)); + let mut response = agent + .get(REPO_API_URL) + .header("User-Agent", concat!("OpenCADStudio/", env!("OCS_APP_VERSION"))) + .header("Accept", "application/vnd.github+json") + .call() + .map_err(|e| format!("Could not reach the font repository: {e}"))?; + let body = response + .body_mut() + .read_to_string() + .map_err(|e| format!("Could not read the font repository listing: {e}"))?; + parse_contents(&body) +} + +/// Parse the GitHub contents-API response: +/// `[{"name":"romans.shx","download_url":"https://…"}, …]`. +fn parse_contents(body: &str) -> Result, String> { + let value: serde_json::Value = + serde_json::from_str(body).map_err(|e| format!("Unexpected font listing: {e}"))?; + let items = value + .as_array() + .ok_or_else(|| "Unexpected font-repository response.".to_string())?; + Ok(items + .iter() + .filter_map(|item| { + let name = item.get("name")?.as_str()?.to_string(); + let url = item.get("download_url")?.as_str()?.to_string(); + (!name.is_empty() && !url.is_empty()).then_some(RepoFont { + name, + download_url: url, + }) + }) + .collect()) +} + +/// Download every `missing` font that exists in the community folder into the +/// local fonts directory. Returns the (requested, saved-path) pairs that were +/// actually fetched; names absent from the repository are skipped. +pub fn download_fonts( + missing: &[String], + source: &FontSource, +) -> Result, String> { + if missing.is_empty() { + return Ok(Vec::new()); + } + let repo = match source { + FontSource::Community => Some(fetch_repo_fonts()?), + FontSource::Custom(_) => None, + }; + let dir = fonts_dir().ok_or_else(|| "Could not resolve the fonts folder.".to_string())?; + std::fs::create_dir_all(&dir).map_err(|e| format!("Could not create {}: {e}", dir.display()))?; + let mut downloaded = Vec::new(); + for want in missing { + let file = want.rsplit(['/', '\\']).next().unwrap_or(want); + let url = match (source, repo.as_ref()) { + // Community: download URLs come from the listing, matched by name. + (FontSource::Community, Some(repo)) => { + let lower = file.to_ascii_lowercase(); + repo.iter() + .find(|f| f.name.to_ascii_lowercase() == lower) + .map(|f| f.download_url.clone()) + } + // Custom folder: the font is simply `{base}/{file_name}`; a miss + // (404) is skipped without failing the batch. + (FontSource::Custom(base), _) => { + Some(format!("{base}/{}", percent_encode_path(file))) + } + (FontSource::Community, None) => None, + }; + let Some(url) = url else { continue }; + let agent = crate::network::agent(std::time::Duration::from_secs(30)); + let mut response = agent + .get(&url) + .header("User-Agent", concat!("OpenCADStudio/", env!("OCS_APP_VERSION"))) + .call() + .map_err(|e| format!("Could not download {file}: {e}"))?; + if response.status() != 200 { + continue; + } + let mut bytes = Vec::new(); + response + .body_mut() + .as_reader() + .read_to_end(&mut bytes) + .map_err(|e| format!("Could not download {file}: {e}"))?; + if bytes.is_empty() { + continue; + } + let dest = dir.join(file); + std::fs::write(&dest, &bytes) + .map_err(|e| format!("Could not save {}: {e}", dest.display()))?; + downloaded.push((file.to_string(), dest)); + } + Ok(downloaded) +} + +/// Escape path characters that would break a URL (spaces and non-ASCII file +/// names are common in font sets). +fn percent_encode_path(file: &str) -> String { + let mut out = String::with_capacity(file.len()); + for byte in file.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char) + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_github_contents_listing() { + let body = r#"[ + {"name":"romans.shx","path":"fonts/romans.shx", + "download_url":"https://raw.githubusercontent.com/x/y/main/fonts/romans.shx","type":"file"}, + {"name":"subset","path":"fonts/subset","type":"dir"}, + {"name":"no-url.shx","path":"fonts/no-url.shx","type":"file"} + ]"#; + let fonts = parse_contents(body).unwrap(); + assert_eq!(fonts.len(), 1); + assert_eq!(fonts[0].name, "romans.shx"); + assert!(fonts[0].download_url.ends_with("/fonts/romans.shx")); + } + + #[test] + fn custom_source_urls_are_trimmed_and_encoded() { + assert_eq!(FontSource::from_url(""), FontSource::Community); + assert_eq!( + FontSource::from_url(" https://intra/fonts/ "), + FontSource::Custom("https://intra/fonts".into()) + ); + assert_eq!(percent_encode_path("vn 30f.shx"), "vn%2030f.shx"); + } + + #[test] + fn rejects_a_non_listing_response() { + assert!(parse_contents("{\"message\":\"Not Found\"}").is_err()); + } + + #[test] + fn missing_fonts_deduplicate_by_name_case_insensitively() { + // Two styles referencing the same missing font with different casing + // and one whose file resolves next to the drawing must yield one hit. + let dir = std::env::temp_dir().join("ocs_font_repo_tests"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("exists.shx"), b"stub").unwrap(); + let mut doc = CadDocument::new(); + let mk = |name: &str, file: &str| { + let mut style = acadrust::TextStyle::new(name); + style.font_file = file.into(); + style + }; + let _ = doc.text_styles.add(mk("A", "MISSING1.shx")); + let _ = doc.text_styles.add(mk("B", "missing1.SHX")); + let _ = doc.text_styles.add(mk("C", "exists.shx")); + doc.source_path = Some(dir.join("drawing.dwg").to_string_lossy().into_owned()); + let missing = missing_shx_fonts(&doc); + assert_eq!(missing, vec!["MISSING1.shx".to_string()]); + let _ = std::fs::remove_file(dir.join("exists.shx")); + } +} diff --git a/src/io/mod.rs b/src/io/mod.rs index 1b88c2bd6..4325c6682 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -4,9 +4,11 @@ // Default save format: DWG (AC1032 / R2018+). pub mod file_association; +pub mod font_repo; #[cfg(not(target_arch = "wasm32"))] pub mod edit_lock; pub mod obj; +pub mod ole_embed; #[cfg(not(target_arch = "wasm32"))] pub mod single_instance; pub mod pdf_export; @@ -1554,6 +1556,23 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> { Ok((path, w, h)) } +/// Pick an image file and prepare it for embedding (IMAGEEMBED). The bytes +/// are read before returning so the OLE2FRAME payload is ready to place. +pub async fn pick_embedded_image_file() -> Result { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Select Image File").as_ref()) + .add_filter(crate::t!("Images").as_ref(), &["png", "jpg", "jpeg", "bmp", "tiff", "tif"]) + .add_filter(crate::t!("PNG").as_ref(), &["png"]) + .add_filter(crate::t!("JPEG").as_ref(), &["jpg", "jpeg"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_file() + .await + .ok_or_else(|| "Cancelled".to_string())?; + let name = handle.file_name(); + let bytes = handle.read().await; + ole_embed::EmbeddedImage::from_bytes(name, bytes) +} + /// Save `doc` to `path` with the given DXF version, overriding `doc.version`. /// Format is auto-detected from the extension (dwg / dxf). pub fn save_as_version( diff --git a/src/io/ole_embed.rs b/src/io/ole_embed.rs new file mode 100644 index 000000000..29507f9e2 --- /dev/null +++ b/src/io/ole_embed.rs @@ -0,0 +1,261 @@ +// Embed a raster image inside the drawing itself as an OLE2FRAME entity. +// +// The IMAGE mechanism (RasterImage + ImageDefinition) stores only a file path, +// so a drawing copied to another machine loses its pictures. An OLE2FRAME +// instead carries the encoded raster inside an OLE compound file embedded in +// the entity data — the same mechanism AutoCAD uses for pasted pictures — so +// the .dwg is self-contained. cadcodec both builds the compound storage and +// reads it back, and the renderer already paints OLE2FRAMEs via +// ImageModel::from_ole2frame, so this module only packs and unpacks. + +use std::io::Write as _; +use std::path::Path; + +use acadrust::compound_file::{ + BinaryRecord, CompoundEntry, CompoundFile, CompoundStorage, CompoundStream, + CompoundStreamContent, StructuredStoragePayload, +}; +use acadrust::entities::{Ole2Frame, OleFrameEnvelope, OleObjectType}; +use acadrust::types::Vector3; + +/// OLE compound-file stream holding OLE 1.0 native data. cadcodec's +/// presentation extractor looks here first; the body is the encoded raster +/// behind a 4-byte little-endian length. +const OLE10NATIVE: &str = "\u{1}Ole10Native"; +/// Leading header marker carried verbatim by the geometry envelope; readers +/// use it to recognise the envelope around the compound file. +const GEOMETRY_MARKER: u16 = 1; +/// Encoded formats the extractor recognises by magic and every DWG consumer +/// can decode. Anything else is re-encoded as PNG before embedding. +const PORTABLE_FORMATS: [image::ImageFormat; 3] = [ + image::ImageFormat::Png, + image::ImageFormat::Jpeg, + image::ImageFormat::Bmp, +]; + +/// A raster image prepared for embedding: encoded bytes plus the metadata the +/// placement prompts need. +#[derive(Clone, Debug, PartialEq)] +pub struct EmbeddedImage { + /// Encoded raster bytes (PNG / JPEG / BMP as stored or re-encoded). + pub bytes: Vec, + pub pixel_width: u32, + pub pixel_height: u32, + /// File name the picture came from — surfaces in prompts and properties. + pub name: String, +} + +impl EmbeddedImage { + /// Prepare an image file for embedding. Recognised raster formats are + /// stored as-is; anything else is decoded and re-encoded as PNG so the + /// payload always stays renderable. + pub fn from_file(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("image") + .to_string(); + Self::from_bytes(name, bytes) + } + + /// Prepare already-read image bytes for embedding (web file handles). + pub fn from_bytes(name: String, bytes: Vec) -> Result { + let format = image::guess_format(&bytes).map_err(|e| e.to_string())?; + let encoded = if PORTABLE_FORMATS.contains(&format) { + bytes + } else { + encode_png(&bytes)? + }; + let img = image::load_from_memory(&encoded).map_err(|e| e.to_string())?; + let (pixel_width, pixel_height) = image::GenericImageView::dimensions(&img); + Ok(Self { + bytes: encoded, + pixel_width, + pixel_height, + name, + }) + } + + /// Width : height aspect ratio; a degenerate height falls back to square. + pub fn aspect(&self) -> f64 { + if self.pixel_height == 0 { + 1.0 + } else { + self.pixel_width as f64 / self.pixel_height as f64 + } + } +} + +fn encode_png(bytes: &[u8]) -> Result, String> { + let img = image::load_from_memory(bytes).map_err(|e| e.to_string())?; + let mut png = std::io::Cursor::new(Vec::new()); + img.write_to(&mut png, image::ImageFormat::Png) + .map_err(|e| e.to_string())?; + Ok(png.into_inner()) +} + +/// World-space corners for a picture placed with its bottom-left corner at +/// `origin`, extending `width` right and `width / aspect` up — the same +/// rectangle the IMAGE command previews. +pub fn corners_from_placement(origin: Vector3, width: f64, aspect: f64) -> (Vector3, Vector3) { + let width = width.max(1e-6); + let height = width / aspect.max(1e-6); + ( + Vector3::new(origin.x, origin.y + height, origin.z), + Vector3::new(origin.x + width, origin.y, origin.z), + ) +} + +/// Pack `image` into an OLE2FRAME spanning `upper_left` → `lower_right` in +/// world space. The picture rides in an `\x01Ole10Native` stream as OLE 1.0 +/// native data behind the geometry envelope cadcodec writes around it. +pub fn build_embedded_ole( + image: &EmbeddedImage, + upper_left: Vector3, + lower_right: Vector3, +) -> Ole2Frame { + // OLE 1.0 native: 4-byte little-endian payload length, then the raster. + let mut native = Vec::with_capacity(4 + image.bytes.len()); + let _ = native.write(&(image.bytes.len() as u32).to_le_bytes()); + native.extend_from_slice(&image.bytes); + + let file = CompoundFile { + minor_version: 0x003E, + major_version: 3, + transaction_signature: 0, + root: CompoundStorage { + name: "Root Entry".to_string(), + class_id: [0; 16], + state_bits: 0, + creation_time: 0, + modified_time: 0, + entries: vec![CompoundEntry::Stream(CompoundStream { + name: OLE10NATIVE.to_string(), + class_id: [0; 16], + state_bits: 0, + creation_time: 0, + modified_time: 0, + content: CompoundStreamContent::Binary(BinaryRecord::split(&native, 4096)), + })], + }, + }; + + let mut ole = Ole2Frame::new(); + ole.ole_object_type = OleObjectType::Embedded; + ole.source_application = "OpenCADStudio".to_string(); + ole.upper_left_corner = upper_left; + ole.lower_right_corner = lower_right; + ole.envelope = OleFrameEnvelope::Geometry { + marker: GEOMETRY_MARKER, + extension_records: Vec::new(), + }; + ole.storage = StructuredStoragePayload { + leading_records: Vec::new(), + compound_file: Some(file), + trailing_records: Vec::new(), + }; + ole +} + +/// Non-interactive embed used by the control API: add the entity to `doc` and +/// return its handle. The undo snapshot is the caller's responsibility. +pub fn add_embedded_image( + doc: &mut acadrust::CadDocument, + image: &EmbeddedImage, + origin: Vector3, + width: f64, +) -> Result { + let (upper_left, lower_right) = corners_from_placement(origin, width, image.aspect()); + let ole = build_embedded_ole(image, upper_left, lower_right); + doc.add_entity(acadrust::EntityType::Ole2Frame(ole)) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal valid PNG: a solid-colour 4×3 image through the `image` crate. + fn png_bytes(width: u32, height: u32) -> Vec { + let img = image::RgbaImage::from_pixel(width, height, image::Rgba([10, 20, 30, 255])); + let mut png = std::io::Cursor::new(Vec::new()); + img.write_to(&mut png, image::ImageFormat::Png).unwrap(); + png.into_inner() + } + + #[test] + fn from_bytes_keeps_png_and_measures_pixels() { + let embedded = EmbeddedImage::from_bytes("t.png".into(), png_bytes(4, 3)).unwrap(); + assert_eq!(embedded.pixel_width, 4); + assert_eq!(embedded.pixel_height, 3); + assert_eq!(embedded.bytes, png_bytes(4, 3)); + } + + #[test] + fn from_bytes_reencodes_foreign_formats_as_png() { + // TIFF starts with II*/MM* and is not in the portable set, so it must + // come back re-encoded as PNG. + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 3, 255])); + let mut tiff = std::io::Cursor::new(Vec::new()); + img.write_to(&mut tiff, image::ImageFormat::Tiff).unwrap(); + let embedded = EmbeddedImage::from_bytes("t.tif".into(), tiff.into_inner()).unwrap(); + assert!(embedded.bytes.starts_with(&[0x89, b'P', b'N', b'G'])); + } + + #[test] + fn packed_payload_roundtrips_through_the_extractor() { + let png = png_bytes(4, 3); + let image = EmbeddedImage { + bytes: png.clone(), + pixel_width: 4, + pixel_height: 3, + name: "t.png".into(), + }; + let ole = build_embedded_ole( + &image, + Vector3::new(0.0, 3.0, 0.0), + Vector3::new(4.0, 0.0, 0.0), + ); + let payload = ole.encoded_payload(); + match acadrust::entities::extract_presentation(&payload) { + Some(acadrust::entities::OlePresentation::Raster(bytes)) => assert_eq!(bytes, png), + other => panic!("expected the embedded raster back, got {other:?}"), + } + } + + #[test] + fn dwg_roundtrip_preserves_the_embedded_picture() { + let png = png_bytes(4, 3); + let image = EmbeddedImage { + bytes: png.clone(), + pixel_width: 4, + pixel_height: 3, + name: "t.png".into(), + }; + let mut doc = acadrust::CadDocument::new(); + let handle = + add_embedded_image(&mut doc, &image, Vector3::new(10.0, 10.0, 0.0), 20.0).unwrap(); + + let dir = std::env::temp_dir().join("ocs_ole_embed_tests"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("embedded_roundtrip.dwg"); + acadrust::DwgWriter::write_to_file(&path, &doc).unwrap(); + + let mut reader = acadrust::io::dwg::DwgReader::from_file(&path).unwrap(); + let reopened = reader.read().unwrap(); + std::fs::remove_file(&path).ok(); + + let ole = match reopened.get_entity(handle) { + Some(acadrust::EntityType::Ole2Frame(ole)) => ole, + other => panic!("expected the Ole2Frame back, got {other:?}"), + }; + // Corners survive: 20 wide at aspect 4:3 → 15 tall, base at (10,10). + assert_eq!(ole.lower_right_corner, Vector3::new(30.0, 10.0, 0.0)); + assert_eq!(ole.upper_left_corner, Vector3::new(10.0, 25.0, 0.0)); + match acadrust::entities::extract_presentation(&ole.encoded_payload()) { + Some(acadrust::entities::OlePresentation::Raster(bytes)) => assert_eq!(bytes, png), + other => panic!("expected the embedded raster back, got {other:?}"), + } + } +} diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 840555a8e..7d6702bf1 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -375,7 +375,7 @@ fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6] /// The parent comes from `iced::window::run`, keeping the portal request tied /// to the visible app window on Wayland instead of silently resolving to /// `None` on desktops that reject a parentless save dialog (#537). -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] pub fn pick_pdf_path_owned( stem: String, parent: &dyn iced::window::Window, @@ -392,6 +392,25 @@ pub fn pick_pdf_path_owned( Some(path) } +/// Windows: pick the PDF destination with the async dialog on a worker +/// thread. The parented blocking dialog ran `IFileDialog::Show` on the UI +/// thread inside the window callback, and when the target name already +/// existed the overwrite-confirmation popup is a second nested modal that +/// never gets pumped there — the app froze instead of asking. The async +/// backend runs the dialog off-thread; it is the same pattern the DWG +/// Save As flow uses, whose confirm popup works. +#[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))] +pub async fn pick_pdf_path_async(stem: String) -> Option { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Export as PDF").as_ref()) + .set_file_name(format!("{stem}.pdf")) + .add_filter(crate::t!("PDF Files").as_ref(), &["pdf"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .save_file() + .await?; + Some(crate::sys::handle_path(&handle)) +} + // ── PDF builder ─────────────────────────────────────────────────────────── #[cfg(not(target_arch = "wasm32"))] diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 35129dd61..630cbb75e 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -103,11 +103,30 @@ pub fn list_printers() -> Vec { #[cfg(target_os = "windows")] fn windows_printers() -> std::io::Result> { - use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER}; use windows_sys::Win32::Graphics::Printing::{ - EnumPrintersW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, + PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, PRINTER_ENUM_NETWORK, }; + let mut names = enumerate_printers(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS)?; + // LOCAL|CONNECTIONS misses network-discovered printers (spooler-shared and + // per-machine connections), which is what "the plot dialog lost printers" + // reports look like. A second NETWORK-augmented pass can only widen the + // list: when the spooler rejects the extra flag it just yields nothing. + if let Ok(extra) = + enumerate_printers(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS | PRINTER_ENUM_NETWORK) + { + names.extend(extra); + names.sort(); + names.dedup(); + } + Ok(names) +} + +#[cfg(target_os = "windows")] +fn enumerate_printers(flags: u32) -> std::io::Result> { + use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER}; + use windows_sys::Win32::Graphics::Printing::EnumPrintersW; + let mut bytes_needed = 0; let mut printer_count = 0; let mut buffer = Vec::::new(); @@ -124,7 +143,7 @@ fn windows_printers() -> std::io::Result> { // aligned, writable allocation of buffer_bytes bytes. let success = unsafe { EnumPrintersW( - PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, + flags, std::ptr::null(), 4, data, @@ -305,6 +324,297 @@ pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result dispatch_to_printer_opts(path, opts) } +/// Raster resolution for the GDI fallback print. 300 DPI balances spool +/// memory (a 297mm-wide page is ~3.5k pixels) against readable linework; the +/// on-screen rasteriser runs at 150 DPI, which looks soft on paper. +#[cfg(target_os = "windows")] +const GDI_PRINT_DPI: f32 = 300.0; + +/// Print by rasterising the PDF in-process (hayro) and drawing the bitmap to +/// the printer DC with GDI. Needs no PDF application — the ShellExecute verb +/// path only works when the registered PDF viewer exposes `print`/`printto`, +/// and many (Edge, store readers like ASC.Pdf) register only `open`. +/// Rotate a top-down RGBA raster 90° clockwise (width and height swap). +#[cfg(target_os = "windows")] +fn rotate_rgba90(pixels: &[u8], width: u32, height: u32) -> Vec { + let mut rotated = vec![0u8; pixels.len()]; + for y in 0..height as usize { + for x in 0..width as usize { + let src = (y * width as usize + x) * 4; + // Clockwise: destination column = height-1-y, row = x. + let dst = (x * height as usize + (height as usize - 1 - y)) * 4; + rotated[dst..dst + 4].copy_from_slice(&pixels[src..src + 4]); + } + } + rotated +} + +/// Fetch the printer driver's DEVMODE and flip its orientation to match the +/// plot's aspect (`landscape` = image wider than tall). The merged DEVMODE is +/// returned as raw bytes (drivers append private data after the struct). +/// `None` when the driver could not be queried — CreateDC then falls back to +/// the driver default. +#[cfg(target_os = "windows")] +fn oriented_printer_devmode(device_wide: &[u16], landscape: bool) -> Option> { + use windows_sys::Win32::Graphics::Gdi::{ + DEVMODEW, DM_IN_BUFFER, DM_ORIENTATION, DM_OUT_BUFFER, DMORIENT_LANDSCAPE, + DMORIENT_PORTRAIT, + }; + use windows_sys::Win32::Graphics::Printing::{ + ClosePrinter, DocumentPropertiesW, OpenPrinterW, PRINTER_HANDLE, + }; + unsafe { + let mut handle = PRINTER_HANDLE::default(); + if OpenPrinterW(device_wide.as_ptr(), &mut handle, std::ptr::null()) == 0 { + return None; + } + let result = (|| { + let needed = DocumentPropertiesW( + std::ptr::null_mut(), + handle, + device_wide.as_ptr(), + std::ptr::null_mut(), + std::ptr::null(), + DM_OUT_BUFFER, + ); + if needed <= 0 { + return None; + } + let mut buf = vec![0u8; needed as usize]; + let dm = buf.as_mut_ptr() as *mut DEVMODEW; + if DocumentPropertiesW( + std::ptr::null_mut(), + handle, + device_wide.as_ptr(), + dm, + std::ptr::null(), + DM_OUT_BUFFER, + ) <= 0 + { + return None; + } + (*dm).dmSize = std::mem::size_of::() as u16; + (*dm).dmFields |= DM_ORIENTATION; + (*dm).Anonymous1.Anonymous1.dmOrientation = if landscape { + DMORIENT_LANDSCAPE as i16 + } else { + DMORIENT_PORTRAIT as i16 + }; + // Let the driver reconcile fields that depend on orientation + // (paper dimensions, imageable area) before we print with it. + if DocumentPropertiesW( + std::ptr::null_mut(), + handle, + device_wide.as_ptr(), + dm, + dm, + DM_IN_BUFFER | DM_OUT_BUFFER, + ) <= 0 + { + return None; + } + Some(buf) + })(); + ClosePrinter(handle); + result + } +} + +#[cfg(target_os = "windows")] +fn gdi_raster_print( + path: &std::path::Path, + printer: Option<&str>, + copies: u32, + output_file: Option<&std::path::Path>, +) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Graphics::Gdi::{ + CreateDCW, DeleteDC, GetDeviceCaps, SetStretchBltMode, StretchDIBits, BITMAPINFO, + BITMAPINFOHEADER, BI_RGB, COLORONCOLOR, DIB_RGB_COLORS, DEVMODEW, HORZRES, SRCCOPY, + VERTRES, + }; + // windows-sys 0.61 groups the spooler-document calls (StartDoc/StartPage/ + // EndPage/EndDoc/DOCINFOW) under Storage::Xps, not Graphics::Gdi. + use windows_sys::Win32::Graphics::Printing::GetDefaultPrinterW; + use windows_sys::Win32::Storage::Xps::{EndDoc, EndPage, StartDocW, StartPage, DOCINFOW}; + + let wide = |s: &str| -> Vec { std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() }; + + // Resolve the target device: the named printer or the Windows default. + let device = match printer.map(str::trim).filter(|p| !p.is_empty()) { + Some(name) => name.to_string(), + None => { + let mut needed = 0u32; + // SAFETY: first call queries the size, second writes into a + // buffer of exactly that size. + unsafe { GetDefaultPrinterW(std::ptr::null_mut(), &mut needed) }; + if needed == 0 { + return Err("No default printer is configured on Windows.".into()); + } + let mut buf = vec![0u16; needed as usize]; + if unsafe { GetDefaultPrinterW(buf.as_mut_ptr(), &mut needed) } == 0 { + return Err(format!( + "Could not query the default printer (code {}).", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + String::from_utf16_lossy(&buf[..buf.iter().position(|&u| u == 0).unwrap_or(0)]) + } + }; + + let page = crate::scene::model::pdf_raster::rasterize_page_at_dpi( + &path.to_string_lossy(), + "1", + GDI_PRINT_DPI, + ) + .ok_or_else(|| "Could not rasterise the plot for direct printing.".to_string())?; + + let winspool = wide("WINSPOOL"); + let device_wide = wide(&device); + // The dialog's Landscape/Portrait choice rides in through the DEVMODE: + // the sheet in the raster decides, and the driver's default must not + // override it (a landscape plot into a portrait page used to shrink and + // misorient the output). + let dm_buf = oriented_printer_devmode(&device_wide, page.width > page.height); + let init = dm_buf + .as_ref() + .map(|b| b.as_ptr().cast::()) + .unwrap_or(std::ptr::null()); + // SAFETY: all pointers are NUL-terminated / valid for the call; the DC is + // released below and dm_buf outlives it. + let hdc = unsafe { + CreateDCW(winspool.as_ptr(), device_wide.as_ptr(), std::ptr::null(), init) + }; + if hdc.is_null() { + return Err(format!("Could not open printer \"{device}\" for printing.")); + } + + let result = (|| -> Result<(), String> { + // SAFETY: hdc is a valid printer DC; each capability is a documented index. + let (page_w, page_h) = + unsafe { (GetDeviceCaps(hdc, HORZRES as i32), GetDeviceCaps(hdc, VERTRES as i32)) }; + if page_w <= 0 || page_h <= 0 { + return Err("Printer reported no printable area.".into()); + } + + // BGRA, top-down — what StretchDIBits expects for BI_RGB 32bpp. + let mut bgra = page.pixels.as_ref().clone(); + for px in bgra.chunks_exact_mut(4) { + px.swap(0, 2); + } + + let mut src_w = page.width; + let mut src_h = page.height; + // Drivers whose DEVMODE could not be set (v4 class drivers may fail + // the DocumentProperties query) keep their default orientation. When + // that mismatches the plot sheet, rotating the raster 90° keeps the + // plot at full size on the page instead of shrinking it to fit. + if (page_w > page_h) != (src_w > src_h) { + bgra = rotate_rgba90(&bgra, src_w, src_h); + std::mem::swap(&mut src_w, &mut src_h); + } + + // Fit inside the printable area, preserving the page's aspect ratio. + let fit_w = f64::from(page_w) / f64::from(src_w); + let fit_h = f64::from(page_h) / f64::from(src_h); + let scale = fit_w.min(fit_h); + let draw_w = (f64::from(src_w) * scale).max(1.0) as i32; + let draw_h = (f64::from(src_h) * scale).max(1.0) as i32; + let x = (page_w - draw_w) / 2; + let y = (page_h - draw_h) / 2; + + let doc_name = wide("OpenCADStudio Plot"); + // An optional redirect writes the spooled output to a file instead of + // the physical tray — how "Microsoft Print to PDF" prints silently, + // and how the fallback proves itself in tests without wasting paper. + let redirect = output_file.map(|p| wide(&p.to_string_lossy())); + let info = DOCINFOW { + cbSize: std::mem::size_of::() as i32, + lpszDocName: doc_name.as_ptr(), + lpszOutput: redirect + .as_ref() + .map(|w| w.as_ptr()) + .unwrap_or(std::ptr::null()), + lpszDatatype: std::ptr::null(), + fwType: 0, + }; + // SAFETY: info and its strings outlive every StartDoc/EndDoc call here. + if unsafe { StartDocW(hdc, &info) } <= 0 { + return Err(format!( + "Print job could not start (code {}).", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + unsafe { SetStretchBltMode(hdc, COLORONCOLOR) }; + let mut bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: src_w as i32, + // Negative height: top-down rows, no vertical flip needed. + biHeight: -(src_h as i32), + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB, + biSizeImage: bgra.len() as u32, + ..Default::default() + }, + bmiColors: [Default::default()], + }; + for _ in 0..copies.max(1) { + if unsafe { StartPage(hdc) } <= 0 { + return Err(format!( + "Could not start a print page (code {}).", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + // StartPage resets DC attributes, so the stretch mode is set per page. + unsafe { SetStretchBltMode(hdc, COLORONCOLOR) }; + let drawn = unsafe { + StretchDIBits( + hdc, + x, + y, + draw_w, + draw_h, + 0, + 0, + src_w as i32, + src_h as i32, + bgra.as_ptr().cast(), + &mut bmi, + DIB_RGB_COLORS, + SRCCOPY, + ) + }; + // Failure returns GDI_ERROR (-1); a successful draw returns the + // source height it copied (>= 0, 0 only for zero-height pages). + if drawn < 0 { + return Err(format!( + "Drawing to the printer failed (code {}).", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + if unsafe { EndPage(hdc) } <= 0 { + return Err(format!( + "Could not finish a print page (code {}).", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + } + if unsafe { EndDoc(hdc) } <= 0 { + return Err(format!( + "Print job could not complete (code {}).", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + Ok(()) + })(); + + // SAFETY: the DC was created above and used only on this thread. + unsafe { DeleteDC(hdc) }; + result +} + /// Open a file with the OS default application (used for print preview). #[cfg(not(target_arch = "wasm32"))] pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> { @@ -356,32 +666,51 @@ fn dispatch_to_printer_opts( _ => (wide("print"), None, "default printer".to_string()), }; let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null()); - for _ in 0..opts.copies.max(1) { - let mut info = SHELLEXECUTEINFOW { - cbSize: std::mem::size_of::() as u32, - fMask: SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC, - lpVerb: verb.as_ptr(), - lpFile: path_wide.as_ptr(), - lpParameters: params_ptr, - nShow: SW_HIDE, - ..Default::default() - }; - if unsafe { ShellExecuteExW(&mut info) } == 0 { - let shell_code = info.hInstApp as usize; - let code = if (1..=32).contains(&shell_code) { - shell_code as u32 - } else { - unsafe { GetLastError() } + let shell = (|| -> Result { + for _ in 0..opts.copies.max(1) { + let mut info = SHELLEXECUTEINFOW { + cbSize: std::mem::size_of::() as u32, + fMask: SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC, + lpVerb: verb.as_ptr(), + lpFile: path_wide.as_ptr(), + lpParameters: params_ptr, + nShow: SW_HIDE, + ..Default::default() }; - if code == SE_ERR_NOASSOC || code == ERROR_NO_ASSOCIATION { - return Err( - "Windows has no PDF application registered with Print support.".into(), - ); + if unsafe { ShellExecuteExW(&mut info) } == 0 { + let shell_code = info.hInstApp as usize; + let code = if (1..=32).contains(&shell_code) { + shell_code as u32 + } else { + unsafe { GetLastError() } + }; + if code == SE_ERR_NOASSOC || code == ERROR_NO_ASSOCIATION { + return Err( + "Windows has no PDF application registered with Print support.".into(), + ); + } + return Err(format!("Windows print dispatch failed (code {code})")); } - return Err(format!("Windows print dispatch failed (code {code})")); } + Ok(label) + })(); + if let Ok(sent) = shell { + return Ok(sent); } - Ok(label) + // Fallback: hand the page straight to the spooler through GDI, with no + // PDF application involved. Modern PDF readers (Edge, store apps) + // register only an `open` verb, which used to leave every plot + // failing with "no PDF application registered". + gdi_raster_print(path, opts.printer.as_deref(), opts.copies, None).map_err(|gdi| { + format!( + "{}; direct print fallback failed: {gdi}", + shell.err().unwrap_or_default() + ) + })?; + Ok(opts + .printer + .clone() + .unwrap_or_else(|| "default printer".to_string())) } #[cfg(not(target_os = "windows"))] @@ -459,7 +788,215 @@ fn dispatch_to_printer_opts( mod printer_properties_tests { use super::printer_properties_command; + /// The GDI fallback rasterises the plot PDF in-process; prove the 300 DPI + /// path yields a page sized for print rather than the screen's 150 DPI. #[cfg(target_os = "windows")] + #[test] + fn gdi_fallback_rasterises_the_plot_pdf_at_print_dpi() { + use printpdf::{Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions}; + + let mut document = PdfDocument::new("gdi print test"); + document + .pages + .push(OutputPage::new(Mm(210.0), Mm(148.0), Vec::new())); + let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new()); + let path = std::env::temp_dir().join("ocs_gdi_print_probe.pdf"); + std::fs::write(&path, &bytes).unwrap(); + + let page = crate::scene::model::pdf_raster::rasterize_page_at_dpi( + &path.to_string_lossy(), + "1", + super::GDI_PRINT_DPI, + ) + .expect("plot PDF should rasterise"); + let _ = std::fs::remove_file(&path); + + assert_eq!(page.dpi, super::GDI_PRINT_DPI); + // 210mm at 300 DPI ≈ 2480 px, aspect 210:148. + assert!((page.width as f64 - 210.0 / 25.4 * 300.0).abs() < 2.0); + assert!((page.height as f64 - 148.0 / 25.4 * 300.0).abs() < 2.0); + assert_eq!(page.pixels.len(), page.width as usize * page.height as usize * 4); + } + + /// Opening the printer DC and reading its printable area must work with + /// the device-name resolution the GDI fallback uses. No job is spooled. + #[cfg(target_os = "windows")] + #[test] + fn gdi_fallback_opens_a_printer_dc() { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Graphics::Gdi::{ + CreateDCW, DeleteDC, GetDeviceCaps, HORZRES, VERTRES, + }; + + let wide = |s: &str| -> Vec { + std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() + }; + let winspool = wide("WINSPOOL"); + let device = wide("Microsoft Print to PDF"); + // SAFETY: NUL-terminated strings; the DC is released before returning. + let hdc = unsafe { + CreateDCW(winspool.as_ptr(), device.as_ptr(), std::ptr::null(), std::ptr::null()) + }; + assert!(!hdc.is_null(), "printer DC should open"); + // SAFETY: capability indexes on a valid DC. + let (w, h) = unsafe { (GetDeviceCaps(hdc, HORZRES as i32), GetDeviceCaps(hdc, VERTRES as i32)) }; + // SAFETY: releasing the DC opened above. + unsafe { DeleteDC(hdc) }; + assert!(w > 0 && h > 0, "printable area should be reported, got {w}x{h}"); + } + + /// End to end through the real spooler: plot a page with black ink, run + /// the GDI fallback against "Microsoft Print to PDF" redirected to a file + /// (no dialog, no paper), then rasterise the result and require that ink + /// actually landed on the page. Catches off-page draws (the blank-paper + /// regression) without a physical printer. + #[cfg(target_os = "windows")] + #[test] + fn gdi_fallback_lands_ink_on_the_printed_page() { + use printpdf::{Color, Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions, Rgb}; + + let mut document = PdfDocument::new("gdi ink test"); + let mut ops = Vec::new(); + // A big black block in the middle of an A4 page. + ops.push(printpdf::Op::SetFillColor { + col: Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 0.0, icc_profile: None }), + }); + // printpdf 0.9's DrawRectangle never paints (its serializer always + // ends the path with `n`), so the ink must be a filled polygon — the + // same op the plot pipeline draws hatches with. + ops.push(printpdf::Op::DrawPolygon { + polygon: printpdf::Polygon { + rings: vec![printpdf::PolygonRing { + points: vec![ + printpdf::LinePoint { p: printpdf::Point { x: Mm(60.0).into(), y: Mm(60.0).into() }, bezier: false }, + printpdf::LinePoint { p: printpdf::Point { x: Mm(150.0).into(), y: Mm(60.0).into() }, bezier: false }, + printpdf::LinePoint { p: printpdf::Point { x: Mm(150.0).into(), y: Mm(100.0).into() }, bezier: false }, + printpdf::LinePoint { p: printpdf::Point { x: Mm(60.0).into(), y: Mm(100.0).into() }, bezier: false }, + ], + }], + mode: printpdf::PaintMode::Fill, + winding_order: printpdf::WindingOrder::NonZero, + }, + }); + document + .pages + .push(OutputPage::new(Mm(210.0), Mm(148.0), ops)); + let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new()); + let plot = std::env::temp_dir().join("ocs_gdi_ink_source.pdf"); + std::fs::write(&plot, &bytes).unwrap(); + + let printed = std::env::temp_dir().join("ocs_gdi_ink_output.pdf"); + let _ = std::fs::remove_file(&printed); + + let result = super::gdi_raster_print( + &plot, + Some("Microsoft Print to PDF"), + 1, + Some(&printed), + ); + assert!(result.is_ok(), "GDI print failed: {result:?}"); + + // The spooled PDF must contain the black block: rasterise it and + // count clearly-dark pixels. + let page = crate::scene::model::pdf_raster::rasterize_page( + &printed.to_string_lossy(), + "1", + ) + .expect("spooled output should rasterise"); + let dark = page + .pixels + .chunks_exact(4) + .filter(|px| px[0] < 100 && px[1] < 100 && px[2] < 100) + .count(); + std::fs::remove_file(&plot).ok(); + std::fs::remove_file(&printed).ok(); + assert!( + dark > page.width as usize * page.height as usize / 50, + "printed page should carry ink; only {dark} dark pixels" + ); + } + + #[cfg(target_os = "windows")] + /// The plot sheet's orientation must drive the printer's DEVMODE: a + /// landscape sheet spooled through "Microsoft Print to PDF" (whose + /// default is portrait) has to come out as a landscape page. + #[cfg(target_os = "windows")] + #[test] + fn gdi_fallback_prints_landscape_sheets_landscape() { + use printpdf::{Color, Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions, Rgb}; + + let mut document = PdfDocument::new("gdi orientation test"); + let mut ops = Vec::new(); + ops.push(printpdf::Op::SetFillColor { + col: Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 0.0, icc_profile: None }), + }); + ops.push(printpdf::Op::DrawPolygon { + polygon: printpdf::Polygon { + rings: vec![printpdf::PolygonRing { + points: vec![ + printpdf::LinePoint { p: printpdf::Point { x: Mm(20.0).into(), y: Mm(20.0).into() }, bezier: false }, + printpdf::LinePoint { p: printpdf::Point { x: Mm(270.0).into(), y: Mm(20.0).into() }, bezier: false }, + printpdf::LinePoint { p: printpdf::Point { x: Mm(270.0).into(), y: Mm(45.0).into() }, bezier: false }, + printpdf::LinePoint { p: printpdf::Point { x: Mm(20.0).into(), y: Mm(45.0).into() }, bezier: false }, + ], + }], + mode: printpdf::PaintMode::Fill, + winding_order: printpdf::WindingOrder::NonZero, + }, + }); + // Landscape A4: 297 × 210 mm. + document.pages.push(OutputPage::new(Mm(297.0), Mm(210.0), ops)); + let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new()); + let plot = std::env::temp_dir().join("ocs_gdi_orientation_source.pdf"); + std::fs::write(&plot, &bytes).unwrap(); + let printed = std::env::temp_dir().join("ocs_gdi_orientation_output.pdf"); + let _ = std::fs::remove_file(&printed); + + let result = super::gdi_raster_print(&plot, Some("Microsoft Print to PDF"), 1, Some(&printed)); + assert!(result.is_ok(), "GDI print failed: {result:?}"); + + let page = crate::scene::model::pdf_raster::rasterize_page( + &printed.to_string_lossy(), + "1", + ) + .expect("spooled output should rasterise"); + std::fs::remove_file(&plot).ok(); + std::fs::remove_file(&printed).ok(); + + // Whichever mechanism wins — DEVMODE orientation or the raster + // rotation fallback — the ink must land on the page and the plot's + // long side must run along the page's long side. + let mut min_x = page.width; + let mut max_x = 0u32; + let mut min_y = page.height; + let mut max_y = 0u32; + let mut dark = 0usize; + for y in 0..page.height { + for x in 0..page.width { + let px = &page.pixels[((y * page.width + x) * 4) as usize..((y * page.width + x) * 4 + 3) as usize]; + if px.iter().all(|&c| c < 100) { + dark += 1; + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + } + } + } + assert!(dark > 1_000, "printed page must carry ink; {dark} dark pixels"); + let ink_w = (max_x - min_x + 1) as f64; + let ink_h = (max_y - min_y + 1) as f64; + assert_eq!( + ink_w > ink_h, + page.width > page.height, + "ink aspect must follow the page: ink {:.0}x{:.0} on page {}x{}", + ink_w, + ink_h, + page.width, + page.height + ); + } + #[test] fn windows_opens_selected_printer_or_printer_list() { assert_eq!( diff --git a/src/mcp.rs b/src/mcp.rs index 71866f815..0f7107c0d 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -54,6 +54,7 @@ const EXECUTE_OPS: &[&str] = &[ "property", "set_properties", "action", + "embed_image", "save", "stop", "batch", @@ -72,6 +73,7 @@ const BATCH_STEP_OPS: &[&str] = &[ "property", "set_properties", "action", + "embed_image", "save", "stop", ]; @@ -717,6 +719,12 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { )), None => missing("name", r#"{"op":"action","name":"zoom_extents"}"#), }, + "embed_image" if request["path"].as_str().is_none_or(str::is_empty) => { + missing( + "path", + r#"{"op":"embed_image","path":"/path/logo.png","at":[0,0,0],"width":100}"#, + ) + } _ => Ok(()), } } @@ -902,7 +910,9 @@ fn execute_request_schema() -> Value { "camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."}, "selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."}, "cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]}, - "path":{"type":"string","minLength":1,"description":"Absolute drawing path for open or save."}, + "path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image."}, + "at":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World [x,y] or [x,y,z] placement corner for embed_image (picture grows up-right)."}, + "width":{"type":"number","exclusiveMinimum":0,"description":"World width for embed_image; height follows the image aspect ratio. Defaults to pixel_width/100."}, "kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"],"description":"Input kind listed in state.command.accepts."}, "text":{"type":"string","description":"Free text or one option/value token."}, "point":point, @@ -943,6 +953,7 @@ fn execute_request_schema() -> Value { {"properties":{"op":{"const":"property"}},"required":["field","value"]}, {"properties":{"op":{"const":"set_properties"}},"required":["collection","updates"]}, {"properties":{"op":{"const":"action"}},"required":["name"]}, + {"properties":{"op":{"const":"embed_image"}},"required":["path"]}, {"properties":{"op":{"const":"save"}}}, {"properties":{"op":{"const":"stop"}}}, {"properties":{"op":{"const":"batch"}},"required":["steps"]} diff --git a/src/modules/draw/draw/raster_image.rs b/src/modules/draw/draw/raster_image.rs index 8f169fb9c..8c25915d7 100644 --- a/src/modules/draw/draw/raster_image.rs +++ b/src/modules/draw/draw/raster_image.rs @@ -1,6 +1,10 @@ -// IMAGE / IMAGEATTACH command — place a raster image in the drawing. +// IMAGE / IMAGEEMBED commands — place a raster image in the drawing. // -// Workflow: +// IMAGE references the picture by path (RasterImage + ImageDefinition, like +// AutoCAD's IMAGEATTACH); IMAGEEMBED packs the encoded raster inside the +// drawing as an OLE2FRAME so the .dwg travels alone. +// +// Workflow (both commands): // 1. File dialog opens (async, handled in update.rs). // 2. User picks insertion point (first click). // 3. User drags to pick width; height is computed from the image's aspect ratio. @@ -13,10 +17,22 @@ use glam::DVec3; use crate::t; use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::io::ole_embed::{build_embedded_ole, corners_from_placement, EmbeddedImage}; use crate::scene::model::wire_model::WireModel; +/// Where the picture's pixels come from. +#[derive(Clone)] +pub enum ImageSource { + /// Reference by path — the drawing stores the file location. + File(String), + /// Embedded — the encoded raster travels inside the drawing. + Embedded(EmbeddedImage), +} + pub struct ImageCommand { - file_path: String, + source: ImageSource, + /// CLI name: "IMAGE" for a path reference, "IMAGEEMBED" when embedded. + name: &'static str, pixel_width: u32, pixel_height: u32, origin: Option, @@ -26,7 +42,8 @@ pub struct ImageCommand { impl ImageCommand { pub fn new(file_path: String, pixel_width: u32, pixel_height: u32) -> Self { Self { - file_path, + source: ImageSource::File(file_path), + name: "IMAGE", pixel_width, pixel_height, origin: None, @@ -34,6 +51,25 @@ impl ImageCommand { } } + pub fn new_embedded(image: EmbeddedImage) -> Self { + let (pixel_width, pixel_height) = (image.pixel_width, image.pixel_height); + Self { + source: ImageSource::Embedded(image), + name: "IMAGEEMBED", + pixel_width, + pixel_height, + origin: None, + plane: WorkingPlane::default(), + } + } + + fn source_name(&self) -> std::borrow::Cow<'_, str> { + match &self.source { + ImageSource::File(path) => std::borrow::Cow::Borrowed(short_name(path)), + ImageSource::Embedded(image) => std::borrow::Cow::Owned(image.name.clone()), + } + } + fn aspect(&self) -> f64 { if self.pixel_height == 0 { 1.0 @@ -46,21 +82,30 @@ impl ImageCommand { let origin = self.plane.to_local(origin); let width_pt = self.plane.to_local(width_pt); let world_width = (width_pt.x - origin.x).abs().max(0.001); - let world_height = world_width / self.aspect(); - let ins = Vector3::new(origin.x, origin.y, origin.z); - let mut img = RasterImage::with_size( - &self.file_path, - ins, - self.pixel_width as f64, - self.pixel_height as f64, - world_width, - world_height, - ); - img.flags = acadrust::entities::ImageDisplayFlags::SHOW_IMAGE - | acadrust::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY; - self.plane.place_entity(EntityType::RasterImage(img)) + match &self.source { + ImageSource::File(path) => { + let world_height = world_width / self.aspect(); + let mut img = RasterImage::with_size( + path, + ins, + self.pixel_width as f64, + self.pixel_height as f64, + world_width, + world_height, + ); + img.flags = acadrust::entities::ImageDisplayFlags::SHOW_IMAGE + | acadrust::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY; + self.plane.place_entity(EntityType::RasterImage(img)) + } + ImageSource::Embedded(image) => { + let (upper_left, lower_right) = + corners_from_placement(ins, world_width, self.aspect()); + let ole = build_embedded_ole(image, upper_left, lower_right); + self.plane.place_entity(EntityType::Ole2Frame(ole)) + } + } } } @@ -70,14 +115,14 @@ impl CadCommand for ImageCommand { } fn name(&self) -> &'static str { - "IMAGE" + self.name } fn prompt(&self) -> String { if self.origin.is_none() { t!( "IMAGE Specify insertion point (%{name}): ", - name = short_name(&self.file_path) + name = self.source_name() ) .into_owned() } else { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 8a5ee23f4..394130af6 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -2153,19 +2153,19 @@ impl Scene { fn populate_images_from_document_unbumped(&mut self) { self.images.clear(); - let entries: Vec<(Handle, acadrust::entities::RasterImage)> = self + let entries: Vec<(Handle, acadrust::entities::EntityType)> = self .document .entities() - .filter_map(|e| { - if let EntityType::RasterImage(img) = e { - Some((img.common.handle, img.clone())) - } else { - None - } + .filter(|e| { + matches!( + e, + EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_) + ) }) + .map(|e| (e.common().handle, e.clone())) .collect(); - for (handle, img) in entries { - if let Some(model) = ImageModel::from_raster_image(&img) { + for (handle, entity) in entries { + if let Some(model) = self.image_seed_for(&entity) { self.images.insert(handle, model); } } diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index d19c22113..91b24d051 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -18,7 +18,7 @@ pub struct PdfPage { const RASTER_DPI: f32 = 150.0; -type PageKey = (String, String); +type PageKey = (String, String, u32); fn page_cache() -> &'static Mutex>>> { static CACHE: OnceLock>>>> = OnceLock::new(); @@ -40,12 +40,18 @@ pub fn register_source(path: &str, bytes: Arc>) { page_cache() .lock() .unwrap_or_else(|error| error.into_inner()) - .retain(|(cached_path, _), _| cached_path != path); + .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> { - let key = (path.to_string(), page.to_string()); + rasterize_page_at_dpi(path, page, RASTER_DPI) +} + +/// Rasterise a 1-based PDF page at a caller-chosen DPI, memoised per source +/// path, page and DPI (print quality differs from the on-screen 150 DPI). +pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option> { + let key = (path.to_string(), page.to_string(), dpi.to_bits()); if let Some(hit) = page_cache() .lock() .unwrap_or_else(|error| error.into_inner()) @@ -55,7 +61,7 @@ pub fn rasterize_page(path: &str, page: &str) -> Option> { return hit; } - let built = rasterize_uncached(path, page); + let built = rasterize_uncached(path, page, dpi); page_cache() .lock() .unwrap_or_else(|error| error.into_inner()) @@ -63,12 +69,12 @@ pub fn rasterize_page(path: &str, page: &str) -> Option> { built } -fn rasterize_uncached(path: &str, page: &str) -> Option> { +fn rasterize_uncached(path: &str, page: &str, dpi: f32) -> Option> { 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 scale = dpi / 72.0; let pixmap = hayro::render( page, &RenderCache::new(), @@ -86,7 +92,7 @@ fn rasterize_uncached(path: &str, page: &str) -> Option> { pixels: Arc::new(pixmap.data_as_u8_slice().to_vec()), width, height, - dpi: RASTER_DPI, + dpi, })) } diff --git a/src/ui/window/missing_fonts.rs b/src/ui/window/missing_fonts.rs new file mode 100644 index 000000000..cdc002d15 --- /dev/null +++ b/src/ui/window/missing_fonts.rs @@ -0,0 +1,84 @@ +// Missing-fonts prompt — shown after a drawing opens with `.shx` fonts this +// machine doesn't have. Offers to fetch them from the community repository +// (see `fonts/README.md` in the repo root) instead of rendering with a +// mismatched substitute. + +use crate::app::Message; +use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; +use iced::{Element, Fill, Length, Shrink, Theme}; + +fn muted_style(theme: &Theme) -> text::Style { + text::Style { + color: Some(theme.palette().background.weak.text), + } +} + +/// The prompt: the missing-font list, a download action, and a skip. +pub fn view_window<'a>( + fonts: &'a [String], + font_source: &'a str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let content_width = if matches!(sizing.width, Length::Fill) { + Fill + } else { + Shrink + }; + let content_height = if matches!(sizing.height, Length::Fill) { + Fill + } else { + Shrink + }; + let listed: Vec> = fonts + .iter() + .map(|name| text(format!(" {name}")).size(12).into()) + .collect(); + let actions = row![ + Space::new().width(content_width), + button(text(crate::t!("Skip")).size(12)) + .on_press(Message::MissingFontsDismiss) + .style(button::secondary) + .padding([6, 14]), + button(text(crate::t!("Download")).size(12)) + .on_press(Message::MissingFontsDownload) + .style(button::primary) + .padding([6, 14]), + ] + .spacing(8); + + container( + column![ + text(crate::t!("Missing fonts")).size(20), + text(crate::t!( + "This drawing uses fonts that are not installed on this machine. Download them from the OpenCADStudio community font repository?" + )) + .size(11) + .style(muted_style), + container(scrollable(column(listed).spacing(2)).height(content_height)) + .padding([10, 12]) + .width(content_width) + .height(content_height) + .style(container::bordered_box), + column![ + text(crate::t!("Font source — leave empty for the community repository, or point at any folder (e.g. your company's font server):")) + .size(10) + .style(muted_style), + text_input( + crate::t!("https://server/fonts or a GitHub raw folder").as_ref(), + font_source, + ) + .on_input(Message::MissingFontsSourceChanged) + .size(11) + .padding([4, 8]) + .width(content_width), + ] + .spacing(3), + actions, + ] + .spacing(10) + .width(sizing.width) + .height(sizing.height), + ) + .padding([12, 16]) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 7a29b89a3..00c05410e 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -14,6 +14,7 @@ pub mod shortcuts; pub mod layers; pub mod update_notice; pub mod open_progress; +pub mod missing_fonts; pub mod recovery; pub mod options; pub mod attribute_editor; diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index f4d744990..d6a9145cb 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -160,6 +160,10 @@ pub struct PlotDialogState { pub upside_down: bool, pub copies: String, pub area: String, + /// The picked world-space plot window (x0, y0, x1, y1) backing + /// `area == "Window"`. Persisted so reopening the dialog — or the app — + /// keeps the same window instead of reporting an empty plot area. + pub window: Option<(f64, f64, f64, f64)>, pub center: bool, pub offset_x: String, pub offset_y: String, @@ -219,6 +223,7 @@ impl Default for PlotDialogState { upside_down: false, copies: "1".into(), area: "Window".into(), + window: None, center: true, offset_x: "0.0".into(), offset_y: "0.0".into(), @@ -263,6 +268,7 @@ impl PlotDialogState { self.upside_down = o.upside_down; self.copies = o.copies.clone(); self.area = o.area.clone(); + self.window = o.window; self.center = o.center; self.offset_x = o.offset_x.clone(); self.offset_y = o.offset_y.clone(); diff --git a/src/ui/window/xref_manager.rs b/src/ui/window/xref_manager.rs index de4b97417..50626d5a5 100644 --- a/src/ui/window/xref_manager.rs +++ b/src/ui/window/xref_manager.rs @@ -619,6 +619,11 @@ impl XrefManagerPanel { Some(Message::ImagePick), None, ), + menu_item( + crate::t!("Embed Image (in drawing)").into_owned(), + Some(Message::ImageEmbedPick), + None, + ), menu_item( crate::t!("Attach PDF").into_owned(), Some(Message::PdfAttachPick), From 53cd8e01093f3e41fe2ac16525cb3c576a984b11 Mon Sep 17 00:00:00 2001 From: huanld Date: Wed, 16 Sep 2026 17:34:27 +0700 Subject: [PATCH 2/3] ci(release): signing waterfall so forks build without upstream secrets Forks do not inherit repository secrets, and the previous hardwired Azure Trusted Signing steps failed the Windows job outright on a fork. Each signing step is now gated on its credentials being present and the pipeline falls through: Azure Trusted Signing (upstream setup, unchanged when the secrets exist) -> PFX certificate from WINDOWS_PFX_BASE64 / WINDOWS_PFX_PASSWORD via signtool with RFC-3161 timestamping -> unsigned with a note in the job summary. The build and installer assembly always complete. --- .github/workflows/release.yml | 59 ++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e659b1b8..e956fec06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -237,7 +237,11 @@ jobs: cargo build --locked --release cargo build --locked --release -p dwg-thumbnailer-win + # Forks do not inherit repository secrets, so every signing step is + # gated on its credentials being present and the pipeline falls through: + # Azure Trusted Signing (upstream setup) -> PFX certificate -> unsigned. - name: Sign executable (Azure Trusted Signing) + if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME != '' }} uses: azure/artifact-signing-action@v2 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -251,6 +255,37 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 + - name: Import signing certificate (PFX fallback) + if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME == '' && secrets.WINDOWS_PFX_BASE64 != '' }} + shell: pwsh + env: + WINDOWS_PFX_BASE64: ${{ secrets.WINDOWS_PFX_BASE64 }} + run: | + $pfxPath = Join-Path $env:RUNNER_TEMP "signing.pfx" + [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_PFX_BASE64)) + "Imported signing certificate to $pfxPath" + + - name: Sign executable (certificate) + if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME == '' && secrets.WINDOWS_PFX_BASE64 != '' }} + shell: pwsh + env: + WINDOWS_PFX_PASSWORD: ${{ secrets.WINDOWS_PFX_PASSWORD }} + run: | + $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter signtool.exe | + Where-Object { $_.DirectoryName -match 'x64' } | + Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName + if (-not $signtool) { throw "signtool.exe not found in the Windows SDK" } + & $signtool sign /fd SHA256 /tr https://timestamp.digicert.com /td SHA256 ` + /f (Join-Path $env:RUNNER_TEMP "signing.pfx") /p $env:WINDOWS_PFX_PASSWORD ` + (Resolve-Path target\release\OpenCADStudio.exe).Path + if ($LASTEXITCODE -ne 0) { throw "signtool failed for the executable" } + + - name: Note unsigned executable + if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME == '' && secrets.WINDOWS_PFX_BASE64 == '' }} + shell: pwsh + run: | + "No signing credentials configured - add the Azure Trusted Signing secrets or WINDOWS_PFX_BASE64/WINDOWS_PFX_PASSWORD to sign. Building UNSIGNED." >> $env:GITHUB_STEP_SUMMARY + - name: Build MSI installer shell: pwsh run: | @@ -283,8 +318,9 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "light failed" } - name: Sign MSI installer (Azure Trusted Signing) - # The installer itself must be signed separately — light produces a + # The installer itself must be signed separately - light produces a # fresh, unsigned .msi even though the .exe inside is already signed. + if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME != '' }} uses: azure/artifact-signing-action@v2 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -298,6 +334,27 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 + - name: Sign MSI installer (certificate) + if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME == '' && secrets.WINDOWS_PFX_BASE64 != '' }} + shell: pwsh + env: + WINDOWS_PFX_PASSWORD: ${{ secrets.WINDOWS_PFX_PASSWORD }} + run: | + $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter signtool.exe | + Where-Object { $_.DirectoryName -match 'x64' } | + Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName + if (-not $signtool) { throw "signtool.exe not found in the Windows SDK" } + & $signtool sign /fd SHA256 /tr https://timestamp.digicert.com /td SHA256 ` + /f (Join-Path $env:RUNNER_TEMP "signing.pfx") /p $env:WINDOWS_PFX_PASSWORD ` + (Resolve-Path OpenCADStudio.msi).Path + if ($LASTEXITCODE -ne 0) { throw "signtool failed for the MSI" } + + - name: Note unsigned MSI + if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME == '' && secrets.WINDOWS_PFX_BASE64 == '' }} + shell: pwsh + run: | + "MSI left unsigned - no signing credentials configured." >> $env:GITHUB_STEP_SUMMARY + - name: Upload artifacts to release if: env.RELEASE_TAG != '' shell: pwsh From a45207f7c9d96723e768a678af11decf45538837 Mon Sep 17 00:00:00 2001 From: huanld Date: Wed, 16 Sep 2026 17:52:01 +0700 Subject: [PATCH 3/3] ci(release): gate signing steps on env mirrors of secrets Step-level if: conditions cannot read the secrets context, which made GitHub reject the workflow file outright (startup failure, zero jobs). The signing waterfall now keys off job-level env booleans mirrored from the credential secrets - a pattern actionlint accepts. --- .github/workflows/release.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e956fec06..f0577c585 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -194,6 +194,11 @@ jobs: runs-on: windows-latest permissions: contents: write + # Step-level `if:` cannot read the `secrets` context, so the signing + # waterfall is driven by these boolean mirrors of the credential secrets. + env: + AZURE_SIGNING_CONFIGURED: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME != '' }} + PFX_CONFIGURED: ${{ secrets.WINDOWS_PFX_BASE64 != '' }} steps: - name: Checkout @@ -241,7 +246,7 @@ jobs: # gated on its credentials being present and the pipeline falls through: # Azure Trusted Signing (upstream setup) -> PFX certificate -> unsigned. - name: Sign executable (Azure Trusted Signing) - if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME != '' }} + if: env.AZURE_SIGNING_CONFIGURED == 'true' uses: azure/artifact-signing-action@v2 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -256,7 +261,7 @@ jobs: timestamp-digest: SHA256 - name: Import signing certificate (PFX fallback) - if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME == '' && secrets.WINDOWS_PFX_BASE64 != '' }} + if: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED == 'true' shell: pwsh env: WINDOWS_PFX_BASE64: ${{ secrets.WINDOWS_PFX_BASE64 }} @@ -266,7 +271,7 @@ jobs: "Imported signing certificate to $pfxPath" - name: Sign executable (certificate) - if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME == '' && secrets.WINDOWS_PFX_BASE64 != '' }} + if: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED == 'true' shell: pwsh env: WINDOWS_PFX_PASSWORD: ${{ secrets.WINDOWS_PFX_PASSWORD }} @@ -281,7 +286,7 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "signtool failed for the executable" } - name: Note unsigned executable - if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME == '' && secrets.WINDOWS_PFX_BASE64 == '' }} + if: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED != 'true' shell: pwsh run: | "No signing credentials configured - add the Azure Trusted Signing secrets or WINDOWS_PFX_BASE64/WINDOWS_PFX_PASSWORD to sign. Building UNSIGNED." >> $env:GITHUB_STEP_SUMMARY @@ -320,7 +325,7 @@ jobs: - name: Sign MSI installer (Azure Trusted Signing) # The installer itself must be signed separately - light produces a # fresh, unsigned .msi even though the .exe inside is already signed. - if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME != '' }} + if: env.AZURE_SIGNING_CONFIGURED == 'true' uses: azure/artifact-signing-action@v2 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -335,7 +340,7 @@ jobs: timestamp-digest: SHA256 - name: Sign MSI installer (certificate) - if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME == '' && secrets.WINDOWS_PFX_BASE64 != '' }} + if: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED == 'true' shell: pwsh env: WINDOWS_PFX_PASSWORD: ${{ secrets.WINDOWS_PFX_PASSWORD }} @@ -350,7 +355,7 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "signtool failed for the MSI" } - name: Note unsigned MSI - if: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME == '' && secrets.WINDOWS_PFX_BASE64 == '' }} + if: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED != 'true' shell: pwsh run: | "MSI left unsigned - no signing credentials configured." >> $env:GITHUB_STEP_SUMMARY