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.
This commit is contained in:
huanld committed 2026-09-16 16:42:21 +07:00
1 parent 1450fdeedf
commit 28efbffd57
27 files changed
+1767 -66

No files matched your search

+136
View File
@@ -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());
}
}
+111 -3
View File
@@ -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(