Merge pull request #1309 from ghost/embed-print-fonts

This commit is contained in:
Hakan Seven committed 2026-09-17 06:57:42 +03:00
commit 03f4e7cb86
29 files changed
+1833 -68

No files matched your search

+63 -1
View File
@@ -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
@@ -237,7 +242,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: env.AZURE_SIGNING_CONFIGURED == 'true'
uses: azure/artifact-signing-action@v2
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -251,6 +260,37 @@ jobs:
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Import signing certificate (PFX fallback)
if: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED == 'true'
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: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED == 'true'
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: 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
- name: Build MSI installer
shell: pwsh
run: |
@@ -283,8 +323,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: env.AZURE_SIGNING_CONFIGURED == 'true'
uses: azure/artifact-signing-action@v2
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -298,6 +339,27 @@ jobs:
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Sign MSI installer (certificate)
if: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED == 'true'
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: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED != 'true'
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
+1 -1
View File
@@ -63,7 +63,7 @@ naga = { version = "27", features = ["wgsl-in"] }
[target.'cfg(target_os = "windows")'.dependencies]
libloading = "0.8"
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi"] }
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "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"] }
+32
View File
@@ -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` (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 |
|------|----------------|---------------------|
| — | — | — |
+5
View File
@@ -4693,6 +4693,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(),
);
@@ -4700,6 +4701,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
@@ -4712,10 +4715,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;
+4
View File
@@ -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;
+1
View File
@@ -641,6 +641,7 @@ inventory::submit!(crate::command::CommandRegistration {
"IM",
"IMAGE",
"IMAGEATTACH",
"IMAGEEMBED",
"IMPORTOBJ",
"ISOLATEOBJECTS",
"LA",
+47
View File
@@ -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<acadrust::types::Vector3, Value> {
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<Task<Message>, 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|{
+37
View File
@@ -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,39 @@ 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;
}
}
+29
View File
@@ -883,6 +883,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.
@@ -1171,6 +1177,9 @@ pub(super) struct OpenCADStudio {
open_job_serial: u64,
/// Last repair or failed-open report shown in the recovery modal.
recovery_report: Option<crate::io::recovery::RecoveryReport>,
/// Missing SHX fonts of the last opened drawing, offered for download
/// from the community repository (see `crate::io::font_repo`).
missing_fonts: Option<Vec<String>>,
/// 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
@@ -1791,6 +1800,8 @@ pub enum ModalKind {
Unsaved,
SaveDialog,
Recovery,
/// Fonts referenced by the drawing are missing on this machine.
MissingFonts,
RecoveryPrompt,
Options,
FindReplace,
@@ -3580,6 +3591,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<crate::io::ole_embed::EmbeddedImage, String>),
// ── 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<Vec<(String, std::path::PathBuf)>, String>),
// ── PDF Underlay ──────────────────────────────────────────────────────
/// Open file-picker dialog for PDFATTACH command (async).
PdfAttachPick,
@@ -3921,6 +3946,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,
@@ -3961,6 +3989,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(),
+15
View File
@@ -348,6 +348,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
@@ -501,6 +510,10 @@ fn default_dimension_continue_mode() -> i16 {
1
}
fn default_check_missing_fonts() -> bool {
true
}
fn default_show_constraint_values() -> bool {
true
}
@@ -553,6 +566,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(),
+134
View File
@@ -491,6 +491,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: {
@@ -577,6 +579,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();
@@ -1728,6 +1733,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])
@@ -3106,9 +3126,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 {
@@ -3272,6 +3298,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();
@@ -3799,6 +3826,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)
@@ -3808,6 +3842,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();
@@ -4048,6 +4095,7 @@ impl OpenCADStudio {
self.plot_dialog.to_file = false;
self.plot_dialog.printer = Some(s);
}
self.save_config();
self.request_printer_media()
}
M::PrinterMedia(printer, caps) => {
@@ -4591,6 +4639,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
@@ -5447,3 +5496,88 @@ cupsPrintQuality/Print Quality: *Normal High\n";
assert_eq!(layout_paper(&app).0, "Roll_24_(609.60_x_1500.00_MM)");
}
}
#[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;
let _ = 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));
let _ = 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
@@ -1007,6 +1007,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()
@@ -8446,7 +8533,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));
@@ -8456,6 +8543,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;
@@ -8491,7 +8585,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));
@@ -8501,6 +8595,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(
@@ -8520,7 +8621,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));
@@ -8530,6 +8631,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(
+12
View File
@@ -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(),
@@ -1637,6 +1638,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| {
+1
View File
@@ -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",
+6
View File
@@ -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 {
+273
View File
@@ -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::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/HakanSeven12/OpenCADStudio/contents/fonts";
const MAX_FONT_BYTES: u64 = 16 * 1024 * 1024;
/// 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<PathBuf> {
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<PathBuf> {
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<String> {
let mut missing: Vec<String> = 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<PathBuf> {
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<Vec<RepoFont>, 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<Vec<RepoFont>, 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<Vec<(String, PathBuf)>, 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 bytes = response
.body_mut()
.with_config()
.limit(MAX_FONT_BYTES)
.read_to_vec()
.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"));
}
}
+19
View File
@@ -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;
@@ -1555,6 +1557,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<ole_embed::EmbeddedImage, String> {
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(
+261
View File
@@ -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, 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<u8>,
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<Self, String> {
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<u8>) -> Result<Self, String> {
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<Vec<u8>, 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<acadrust::Handle, String> {
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<u8> {
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:?}"),
}
}
}
+20 -1
View File
@@ -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<std::path::PathBuf> {
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"))]
+6 -1
View File
@@ -30,6 +30,7 @@ const NO_DEVICE_STORED_NAME: &str = "none_device";
/// Recognised on read so those drawings keep plotting to PDF, and always
/// rewritten with [`PDF_DEVICE_NAME`] on save.
const LEGACY_PDF_LABEL: &str = "Save to PDF file…";
const WINDOWS_PDF_PRINTER: &str = "Microsoft Print to PDF";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlotDevice {
@@ -67,7 +68,7 @@ impl PlotDevice {
// the source application's own spelling: keep it rather than rewriting it.
return (PlotDevice::None, true);
}
if name == LEGACY_PDF_LABEL {
if name == LEGACY_PDF_LABEL || name.eq_ignore_ascii_case(WINDOWS_PDF_PRINTER) {
return (PlotDevice::Pdf, false);
}
let lower = name.to_ascii_lowercase();
@@ -420,6 +421,10 @@ mod tests {
PlotDevice::from_stored_name("Save to PDF file…"),
(PlotDevice::Pdf, false)
);
assert_eq!(
PlotDevice::from_stored_name("Microsoft Print to PDF"),
(PlotDevice::Pdf, false)
);
assert_eq!(
PlotDevice::from_stored_name("DWG To PDF.pc3"),
(PlotDevice::Pdf, true)
+562 -25
View File
@@ -109,11 +109,30 @@ pub fn list_printers() -> Vec<String> {
#[cfg(target_os = "windows")]
fn windows_printers() -> std::io::Result<Vec<String>> {
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<Vec<String>> {
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::<usize>::new();
@@ -130,7 +149,7 @@ fn windows_printers() -> std::io::Result<Vec<String>> {
// aligned, writable allocation of buffer_bytes bytes.
let success = unsafe {
EnumPrintersW(
PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS,
flags,
std::ptr::null(),
4,
data,
@@ -320,6 +339,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<u8> {
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<Vec<u8>> {
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::<DEVMODEW>() 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<u16> { 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::<DEVMODEW>())
.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::<DOCINFOW>() 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::<BITMAPINFOHEADER>() 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> {
@@ -371,32 +681,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::<SHELLEXECUTEINFOW>() 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<String, String> {
for _ in 0..opts.copies.max(1) {
let mut info = SHELLEXECUTEINFOW {
cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() 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"))]
@@ -478,7 +807,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<u16> {
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!(
+12 -1
View File
@@ -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"]}
+64 -19
View File
@@ -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);
// 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<DVec3>,
@@ -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 {
+9 -9
View File
@@ -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);
}
}
+13 -7
View File
@@ -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<HashMap<PageKey, Option<Arc<PdfPage>>>> {
static CACHE: OnceLock<Mutex<HashMap<PageKey, Option<Arc<PdfPage>>>>> = OnceLock::new();
@@ -40,12 +40,18 @@ pub fn register_source(path: &str, bytes: Arc<Vec<u8>>) {
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<Arc<PdfPage>> {
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<Arc<PdfPage>> {
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<Arc<PdfPage>> {
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<Arc<PdfPage>> {
built
}
fn rasterize_uncached(path: &str, page: &str) -> Option<Arc<PdfPage>> {
fn rasterize_uncached(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
let bytes = source_bytes(path)?;
let pdf = Pdf::new(bytes).ok()?;
let page_no = page.trim().parse::<usize>().unwrap_or(1).max(1);
let page = pdf.pages().get(page_no - 1)?;
let scale = RASTER_DPI / 72.0;
let scale = dpi / 72.0;
let pixmap = hayro::render(
page,
&RenderCache::new(),
@@ -86,7 +92,7 @@ fn rasterize_uncached(path: &str, page: &str) -> Option<Arc<PdfPage>> {
pixels: Arc::new(pixmap.data_as_u8_slice().to_vec()),
width,
height,
dpi: RASTER_DPI,
dpi,
}))
}
+84
View File
@@ -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<Element<'a, Message>> = 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()
}
+1
View File
@@ -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;
+6
View File
@@ -398,6 +398,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,
@@ -463,6 +467,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(),
@@ -507,6 +512,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();
+5
View File
@@ -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),