forked from 1b-agent/opencadstudio-upstream
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:
1 parent
1450fdeedf
commit
28efbffd57
27 files changed
+1767
-66
No files matched your search
+1
-1
@@ -61,7 +61,7 @@ iced_test = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa
|
|||||||
naga = { version = "27", features = ["wgsl-in"] }
|
naga = { version = "27", features = ["wgsl-in"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] }
|
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi", "Win32_Storage_Xps"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] }
|
ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] }
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# OpenCADStudio Community Fonts
|
||||||
|
|
||||||
|
Thư mục này là **kho font cộng đồng** của OpenCADStudio. Khi mở một bản vẽ
|
||||||
|
DWG/DXF tham chiếu font (`.shx`) không có trên máy, ứng dụng sẽ hỏi người dùng
|
||||||
|
và tự động tải font từ thư mục này, thay vì vẽ chữ bằng font thay thế bị lệch/vỡ.
|
||||||
|
|
||||||
|
When a drawing references an `.shx` font that is missing on the machine,
|
||||||
|
OpenCADStudio offers to download it from this folder automatically instead of
|
||||||
|
rendering with a mismatched substitute font.
|
||||||
|
|
||||||
|
## Cách đóng góp / How to contribute
|
||||||
|
|
||||||
|
1. Fork repository, thêm font vào thư mục `fonts/` ở **thư mục gốc của repo**
|
||||||
|
(cùng cấp với `src/`, `crates/`, …), rồi mở Pull Request.
|
||||||
|
2. Giữ nguyên **tên file viết thường** như bản vẽ tham chiếu, ví dụ
|
||||||
|
`vnarial.shx`, `romans.shx`, `simplex.shx`. Ứng dụng so khớp tên file
|
||||||
|
không phân biệt hoa/thường.
|
||||||
|
3. Định dạng hỗ trợ: `.shx` (AutoCAD compiled shape font).
|
||||||
|
4. **Giấy phép / License**: chỉ đóng góp font bạn có quyền phân phối lại —
|
||||||
|
font do bạn tự vẽ, font nguồn mở, hoặc font thuộc public domain.
|
||||||
|
Ghi nguồn + giấy phép vào cuối file này theo mẫu bên dưới.
|
||||||
|
Only contribute fonts you have the right to redistribute, and record the
|
||||||
|
source and license below.
|
||||||
|
|
||||||
|
Ứng dụng tải font về thư mục cài đặt người dùng
|
||||||
|
(`%APPDATA%/OpenCADStudio/fonts` trên Windows) và dùng lại cho các lần mở sau.
|
||||||
|
|
||||||
|
## Danh mục font / Font catalog
|
||||||
|
|
||||||
|
| File | Nguồn / Source | Giấy phép / License |
|
||||||
|
|------|----------------|---------------------|
|
||||||
|
| — | — | — |
|
||||||
@@ -4635,6 +4635,7 @@ impl OpenCADStudio {
|
|||||||
let x1 = p1.x.max(p2.x);
|
let x1 = p1.x.max(p2.x);
|
||||||
let y1 = p1.y.max(p2.y);
|
let y1 = p1.y.max(p2.y);
|
||||||
self.plot_window = Some((x0, y0, x1, y1));
|
self.plot_window = Some((x0, y0, x1, y1));
|
||||||
|
self.plot_dialog.window = self.plot_window;
|
||||||
self.command_line.push_output(
|
self.command_line.push_output(
|
||||||
crate::tf!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}").as_ref(),
|
crate::tf!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}").as_ref(),
|
||||||
);
|
);
|
||||||
@@ -4642,6 +4643,8 @@ impl OpenCADStudio {
|
|||||||
// receive the two clicks — bring the dialog back with the
|
// receive the two clicks — bring the dialog back with the
|
||||||
// window now active.
|
// window now active.
|
||||||
self.plot_dialog.area = "Window".to_string();
|
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);
|
self.active_modal = Some(super::ModalKind::Plot);
|
||||||
} else {
|
} else {
|
||||||
// PLOTWINDOW always describes the plotted layout. In MSPACE
|
// PLOTWINDOW always describes the plotted layout. In MSPACE
|
||||||
@@ -4654,10 +4657,12 @@ impl OpenCADStudio {
|
|||||||
let x2 = p1.x.max(p2.x);
|
let x2 = p1.x.max(p2.x);
|
||||||
let y2 = p1.y.max(p2.y);
|
let y2 = p1.y.max(p2.y);
|
||||||
self.plot_window = Some((x1, y1, x2, y2));
|
self.plot_window = Some((x1, y1, x2, y2));
|
||||||
|
self.plot_dialog.window = self.plot_window;
|
||||||
self.command_line.push_output(
|
self.command_line.push_output(
|
||||||
crate::tf!("Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}").as_ref(),
|
crate::tf!("Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}").as_ref(),
|
||||||
);
|
);
|
||||||
self.plot_dialog.area = "Window".to_string();
|
self.plot_dialog.area = "Window".to_string();
|
||||||
|
self.save_config();
|
||||||
self.active_modal = Some(super::ModalKind::Plot);
|
self.active_modal = Some(super::ModalKind::Plot);
|
||||||
}
|
}
|
||||||
self.tabs[i].active_cmd = None;
|
self.tabs[i].active_cmd = None;
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ impl OpenCADStudio {
|
|||||||
return Some(Task::done(Message::ImagePick));
|
return Some(Task::done(Message::ImagePick));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd if cmd == "IMAGEEMBED" => {
|
||||||
|
return Some(Task::done(Message::ImageEmbedPick));
|
||||||
|
}
|
||||||
|
|
||||||
"REVCLOUD" | "REVCLOUD_RECTANGULAR" | "REVCLOUD_POLYGONAL" | "REVCLOUD_FREEHAND" => {
|
"REVCLOUD" | "REVCLOUD_RECTANGULAR" | "REVCLOUD_POLYGONAL" | "REVCLOUD_FREEHAND" => {
|
||||||
use crate::modules::draw::draw::revcloud::RevCloudCommand;
|
use crate::modules::draw::draw::revcloud::RevCloudCommand;
|
||||||
let view_height = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0;
|
let view_height = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0;
|
||||||
|
|||||||
@@ -622,6 +622,7 @@ inventory::submit!(crate::command::CommandRegistration {
|
|||||||
"IM",
|
"IM",
|
||||||
"IMAGE",
|
"IMAGE",
|
||||||
"IMAGEATTACH",
|
"IMAGEATTACH",
|
||||||
|
"IMAGEEMBED",
|
||||||
"IMPORTOBJ",
|
"IMPORTOBJ",
|
||||||
"ISOLATEOBJECTS",
|
"ISOLATEOBJECTS",
|
||||||
"LA",
|
"LA",
|
||||||
|
|||||||
@@ -67,7 +67,54 @@ pub(super) const NAMES: &[&str] = &[
|
|||||||
"undo",
|
"undo",
|
||||||
"redo",
|
"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 {
|
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 {
|
pub(super) fn control_properties(&mut self) -> Value {
|
||||||
self.refresh_properties();
|
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|{
|
json!({"ok":true,"sections":self.tabs[self.active_tab].properties.sections.iter().map(|s|json!({"title":s.title,"properties":s.props.iter().map(|p|{
|
||||||
|
|||||||
@@ -781,6 +781,8 @@ impl OpenCADStudio {
|
|||||||
"set_properties" => self.control_set_record_properties(req)?,
|
"set_properties" => self.control_set_record_properties(req)?,
|
||||||
"action" => self.control_ui_action(req)?,
|
"action" => self.control_ui_action(req)?,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
"embed_image" => self.control_embed_image(req)?,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
"save" => {
|
"save" => {
|
||||||
let path = req["path"]
|
let path = req["path"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -1387,4 +1389,40 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = std::fs::remove_file(path);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,6 +861,12 @@ pub(super) struct OpenCADStudio {
|
|||||||
point_size_relative: bool,
|
point_size_relative: bool,
|
||||||
/// Whether the default-association prompt has been answered.
|
/// Whether the default-association prompt has been answered.
|
||||||
default_assoc_prompted: bool,
|
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,
|
donation_prompt_version: String,
|
||||||
/// Read-only session (`--read-only`): editing is allowed but every save
|
/// Read-only session (`--read-only`): editing is allowed but every save
|
||||||
/// path is refused. Set once at boot from the CLI config.
|
/// path is refused. Set once at boot from the CLI config.
|
||||||
@@ -1137,6 +1143,9 @@ pub(super) struct OpenCADStudio {
|
|||||||
open_job_serial: u64,
|
open_job_serial: u64,
|
||||||
/// Last repair or failed-open report shown in the recovery modal.
|
/// Last repair or failed-open report shown in the recovery modal.
|
||||||
recovery_report: Option<crate::io::recovery::RecoveryReport>,
|
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.
|
/// Drawings handed to us by other launches while `opening` was busy.
|
||||||
/// `opening` is a single slot that a second `OpenPathPicked` would
|
/// `opening` is a single slot that a second `OpenPathPicked` would
|
||||||
/// overwrite, and `on_file_opened` drops any result arriving once it is
|
/// overwrite, and `on_file_opened` drops any result arriving once it is
|
||||||
@@ -1757,6 +1766,8 @@ pub enum ModalKind {
|
|||||||
Unsaved,
|
Unsaved,
|
||||||
SaveDialog,
|
SaveDialog,
|
||||||
Recovery,
|
Recovery,
|
||||||
|
/// Fonts referenced by the drawing are missing on this machine.
|
||||||
|
MissingFonts,
|
||||||
RecoveryPrompt,
|
RecoveryPrompt,
|
||||||
Options,
|
Options,
|
||||||
FindReplace,
|
FindReplace,
|
||||||
@@ -3517,6 +3528,20 @@ pub enum Message {
|
|||||||
ImagePick,
|
ImagePick,
|
||||||
/// Result of the image file picker + pixel dimension decode.
|
/// Result of the image file picker + pixel dimension decode.
|
||||||
ImagePickResult(Result<(std::path::PathBuf, u32, u32), String>),
|
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 ──────────────────────────────────────────────────────
|
// ── PDF Underlay ──────────────────────────────────────────────────────
|
||||||
/// Open file-picker dialog for PDFATTACH command (async).
|
/// Open file-picker dialog for PDFATTACH command (async).
|
||||||
PdfAttachPick,
|
PdfAttachPick,
|
||||||
@@ -3843,6 +3868,9 @@ impl OpenCADStudio {
|
|||||||
point_size_buf: String::new(),
|
point_size_buf: String::new(),
|
||||||
point_size_relative: true,
|
point_size_relative: true,
|
||||||
default_assoc_prompted: false,
|
default_assoc_prompted: false,
|
||||||
|
check_missing_fonts: true,
|
||||||
|
font_source_url: String::new(),
|
||||||
|
font_source_input: String::new(),
|
||||||
donation_prompt_version: String::new(),
|
donation_prompt_version: String::new(),
|
||||||
read_only: false,
|
read_only: false,
|
||||||
update_notice_version: None,
|
update_notice_version: None,
|
||||||
@@ -3883,6 +3911,7 @@ impl OpenCADStudio {
|
|||||||
layout_settling: false,
|
layout_settling: false,
|
||||||
open_job_serial: 0,
|
open_job_serial: 0,
|
||||||
recovery_report: None,
|
recovery_report: None,
|
||||||
|
missing_fonts: None,
|
||||||
pending_opens: std::collections::VecDeque::new(),
|
pending_opens: std::collections::VecDeque::new(),
|
||||||
active_interaction_index: None,
|
active_interaction_index: None,
|
||||||
queued_interaction_indices: std::collections::VecDeque::new(),
|
queued_interaction_indices: std::collections::VecDeque::new(),
|
||||||
|
|||||||
@@ -338,6 +338,15 @@ pub struct UserSettings {
|
|||||||
/// prompt has already been shown. Set once the user answers (either way),
|
/// prompt has already been shown. Set once the user answers (either way),
|
||||||
/// so we never nag again on subsequent launches.
|
/// so we never nag again on subsequent launches.
|
||||||
pub default_assoc_prompted: bool,
|
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.
|
/// App version whose donation prompt has been displayed.
|
||||||
pub donation_prompt_version: String,
|
pub donation_prompt_version: String,
|
||||||
/// The graphics verdict (`GpuStatus::identity()`) whose warning popup the
|
/// The graphics verdict (`GpuStatus::identity()`) whose warning popup the
|
||||||
@@ -470,6 +479,10 @@ fn default_dimension_continue_mode() -> i16 {
|
|||||||
1
|
1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_check_missing_fonts() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn default_show_constraint_values() -> bool {
|
fn default_show_constraint_values() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -521,6 +534,8 @@ impl Default for UserSettings {
|
|||||||
snap_angle_deg: 0.0,
|
snap_angle_deg: 0.0,
|
||||||
otrack: false,
|
otrack: false,
|
||||||
default_assoc_prompted: false,
|
default_assoc_prompted: false,
|
||||||
|
check_missing_fonts: true,
|
||||||
|
font_source_url: String::new(),
|
||||||
donation_prompt_version: String::new(),
|
donation_prompt_version: String::new(),
|
||||||
gpu_warning_silenced: String::new(),
|
gpu_warning_silenced: String::new(),
|
||||||
disabled_plugins: Vec::new(),
|
disabled_plugins: Vec::new(),
|
||||||
|
|||||||
@@ -406,6 +406,8 @@ impl OpenCADStudio {
|
|||||||
snap_angle_deg: self.snap_angle_deg,
|
snap_angle_deg: self.snap_angle_deg,
|
||||||
otrack: self.snapper.otrack_enabled,
|
otrack: self.snapper.otrack_enabled,
|
||||||
default_assoc_prompted: self.default_assoc_prompted,
|
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(),
|
donation_prompt_version: self.donation_prompt_version.clone(),
|
||||||
gpu_warning_silenced: self.gpu_warning_silenced.clone(),
|
gpu_warning_silenced: self.gpu_warning_silenced.clone(),
|
||||||
disabled_plugins: {
|
disabled_plugins: {
|
||||||
@@ -489,6 +491,9 @@ impl OpenCADStudio {
|
|||||||
// open / tab switch), not app-global, so they are not applied here.
|
// open / tab switch), not app-global, so they are not applied here.
|
||||||
self.snapper.otrack_enabled = s.otrack;
|
self.snapper.otrack_enabled = s.otrack;
|
||||||
self.default_assoc_prompted = s.default_assoc_prompted;
|
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.donation_prompt_version = s.donation_prompt_version.clone();
|
||||||
self.gpu_warning_silenced = s.gpu_warning_silenced.clone();
|
self.gpu_warning_silenced = s.gpu_warning_silenced.clone();
|
||||||
self.disabled_plugins = s.disabled_plugins.iter().cloned().collect();
|
self.disabled_plugins = s.disabled_plugins.iter().cloned().collect();
|
||||||
@@ -1636,6 +1641,21 @@ impl OpenCADStudio {
|
|||||||
self.active_modal = Some(crate::app::ModalKind::Recovery);
|
self.active_modal = Some(crate::app::ModalKind::Recovery);
|
||||||
Task::none()
|
Task::none()
|
||||||
} else {
|
} 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()
|
self.drain_pending_open()
|
||||||
};
|
};
|
||||||
Task::batch([thumbs_task, pending_open_task, interaction_task])
|
Task::batch([thumbs_task, pending_open_task, interaction_task])
|
||||||
@@ -3012,9 +3032,15 @@ impl OpenCADStudio {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(name, _, factor)| (name, factor))
|
.map(|(name, _, factor)| (name, factor))
|
||||||
.collect();
|
.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() {
|
if let Some(settings) = self.tabs[i].scene.effective_plot_settings() {
|
||||||
self.load_plotsettings_into_dialog(&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()
|
let Some(page) = self.direct_plot_page()
|
||||||
else {
|
else {
|
||||||
@@ -3178,6 +3204,7 @@ impl OpenCADStudio {
|
|||||||
} else {
|
} else {
|
||||||
self.plot_dialog.paper_space = true;
|
self.plot_dialog.paper_space = true;
|
||||||
self.plot_window = None;
|
self.plot_window = None;
|
||||||
|
self.plot_dialog.window = None;
|
||||||
self.load_plotsettings_into_dialog(&page_setup);
|
self.load_plotsettings_into_dialog(&page_setup);
|
||||||
}
|
}
|
||||||
let dialog = self.plot_dialog.clone();
|
let dialog = self.plot_dialog.clone();
|
||||||
@@ -3703,6 +3730,13 @@ impl OpenCADStudio {
|
|||||||
self.plot_prev = Some(previous);
|
self.plot_prev = Some(previous);
|
||||||
let cur = self.tabs[self.active_tab].scene.current_layout.clone();
|
let cur = self.tabs[self.active_tab].scene.current_layout.clone();
|
||||||
let layout_entry = format!("*{cur}*");
|
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]
|
if self.tabs[self.active_tab]
|
||||||
.scene
|
.scene
|
||||||
.plot_settings_for(&cur)
|
.plot_settings_for(&cur)
|
||||||
@@ -3712,6 +3746,19 @@ impl OpenCADStudio {
|
|||||||
} else {
|
} else {
|
||||||
self.select_page_setup(crate::ui::window::plot::SETUP_PREV);
|
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") {
|
if self.plot_dialog.scale.eq_ignore_ascii_case("fit") {
|
||||||
self.plot_dialog.fit_to_paper = true;
|
self.plot_dialog.fit_to_paper = true;
|
||||||
self.plot_dialog.scale = one_to_one.clone();
|
self.plot_dialog.scale = one_to_one.clone();
|
||||||
@@ -3768,6 +3815,9 @@ impl OpenCADStudio {
|
|||||||
self.plot_dialog.to_file = false;
|
self.plot_dialog.to_file = false;
|
||||||
self.plot_dialog.printer = Some(s);
|
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()
|
Task::none()
|
||||||
}
|
}
|
||||||
M::PrinterProperties => {
|
M::PrinterProperties => {
|
||||||
@@ -4242,6 +4292,7 @@ impl OpenCADStudio {
|
|||||||
ps.plot_window.upper_right_x,
|
ps.plot_window.upper_right_x,
|
||||||
ps.plot_window.upper_right_y,
|
ps.plot_window.upper_right_y,
|
||||||
));
|
));
|
||||||
|
self.plot_dialog.window = self.plot_window;
|
||||||
}
|
}
|
||||||
if !ps.current_style_sheet.is_empty()
|
if !ps.current_style_sheet.is_empty()
|
||||||
&& self
|
&& 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
@@ -951,6 +951,93 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
Task::none()
|
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(
|
Message::PdfAttachPick => Task::perform(
|
||||||
async {
|
async {
|
||||||
let handle = crate::sys::file_dialog()
|
let handle = crate::sys::file_dialog()
|
||||||
@@ -8281,7 +8368,7 @@ impl OpenCADStudio {
|
|||||||
.and_then(|path| path.file_stem())
|
.and_then(|path| path.file_stem())
|
||||||
.map(|name| format!("{}_layouts", name.to_string_lossy()))
|
.map(|name| format!("{}_layouts", name.to_string_lossy()))
|
||||||
.unwrap_or_else(|| "drawing_layouts".into());
|
.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 {
|
let Some(window_id) = self.main_window else {
|
||||||
return Task::done(Message::PrintAllPdfPath(None));
|
return Task::done(Message::PrintAllPdfPath(None));
|
||||||
@@ -8291,6 +8378,13 @@ impl OpenCADStudio {
|
|||||||
})
|
})
|
||||||
.map(Message::PrintAllPdfPath)
|
.map(Message::PrintAllPdfPath)
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
Task::perform(
|
||||||
|
crate::io::pdf_export::pick_pdf_path_async(stem),
|
||||||
|
Message::PrintAllPdfPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
let _ = stem;
|
let _ = stem;
|
||||||
@@ -8326,7 +8420,7 @@ impl OpenCADStudio {
|
|||||||
.and_then(|p: &std::path::Path| p.file_stem())
|
.and_then(|p: &std::path::Path| p.file_stem())
|
||||||
.map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned())
|
.map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned())
|
||||||
.unwrap_or_else(|| "drawing".into());
|
.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 {
|
let Some(window_id) = self.main_window else {
|
||||||
return Task::done(Message::PlotExportPath(None));
|
return Task::done(Message::PlotExportPath(None));
|
||||||
@@ -8336,6 +8430,13 @@ impl OpenCADStudio {
|
|||||||
})
|
})
|
||||||
.map(Message::PlotExportPath)
|
.map(Message::PlotExportPath)
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
Task::perform(
|
||||||
|
crate::io::pdf_export::pick_pdf_path_async(stem),
|
||||||
|
Message::PlotExportPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
Task::perform(
|
Task::perform(
|
||||||
@@ -8363,7 +8464,7 @@ impl OpenCADStudio {
|
|||||||
.and_then(|p: &std::path::Path| p.file_stem())
|
.and_then(|p: &std::path::Path| p.file_stem())
|
||||||
.map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned())
|
.map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned())
|
||||||
.unwrap_or_else(|| "drawing".into());
|
.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 {
|
let Some(window_id) = self.main_window else {
|
||||||
return Task::done(Message::PlotWindowExportPath(None));
|
return Task::done(Message::PlotWindowExportPath(None));
|
||||||
@@ -8373,6 +8474,13 @@ impl OpenCADStudio {
|
|||||||
})
|
})
|
||||||
.map(Message::PlotWindowExportPath)
|
.map(Message::PlotWindowExportPath)
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
Task::perform(
|
||||||
|
crate::io::pdf_export::pick_pdf_path_async(stem),
|
||||||
|
Message::PlotWindowExportPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
Task::perform(
|
Task::perform(
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ impl OpenCADStudio {
|
|||||||
Some(K::AttributeEditor) => crate::tr!("modal", "attribute-editor"),
|
Some(K::AttributeEditor) => crate::tr!("modal", "attribute-editor"),
|
||||||
Some(K::SaveDialog) => crate::tr!("modal", "save-drawing-as"),
|
Some(K::SaveDialog) => crate::tr!("modal", "save-drawing-as"),
|
||||||
Some(K::Recovery) => crate::tr!("modal", "recovery-report"),
|
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::RecoveryPrompt) => crate::tr!("modal", "recovery-prompt"),
|
||||||
Some(K::GpuWarning) => crate::tr!("gpu", "title"),
|
Some(K::GpuWarning) => crate::tr!("gpu", "title"),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
@@ -1568,6 +1569,17 @@ impl OpenCADStudio {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
super::super::ModalKind::MissingFonts => {
|
||||||
|
let fonts = self.missing_fonts.as_ref()?;
|
||||||
|
let font_source = &self.font_source_input;
|
||||||
|
automatic_flow(ex, |flow| {
|
||||||
|
crate::ui::window::missing_fonts::view_window(
|
||||||
|
fonts,
|
||||||
|
&font_source,
|
||||||
|
flow,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
super::super::ModalKind::Recovery => {
|
super::super::ModalKind::Recovery => {
|
||||||
let report = self.recovery_report.as_ref()?;
|
let report = self.recovery_report.as_ref()?;
|
||||||
sized_flow(ex, 680, 460, |flow| {
|
sized_flow(ex, 680, 460, |flow| {
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ pub fn dxf_name(e: &EntityType) -> &'static str {
|
|||||||
EntityType::MLine(_) => "MLINE",
|
EntityType::MLine(_) => "MLINE",
|
||||||
EntityType::RasterImage(_) => "RASTERIMAGE",
|
EntityType::RasterImage(_) => "RASTERIMAGE",
|
||||||
EntityType::Wipeout(_) => "WIPEOUT",
|
EntityType::Wipeout(_) => "WIPEOUT",
|
||||||
|
EntityType::Ole2Frame(_) => "OLE2FRAME",
|
||||||
EntityType::Underlay(_) => "UNDERLAY",
|
EntityType::Underlay(_) => "UNDERLAY",
|
||||||
EntityType::AttributeDefinition(_) => "ATTDEF",
|
EntityType::AttributeDefinition(_) => "ATTDEF",
|
||||||
EntityType::AttributeEntity(_) => "ATTRIB",
|
EntityType::AttributeEntity(_) => "ATTRIB",
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT
|
|||||||
.map(std::path::Path::new)
|
.map(std::path::Path::new)
|
||||||
.and_then(|p| p.parent());
|
.and_then(|p| p.parent());
|
||||||
crate::io::resolve_image_file(file, base)
|
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();
|
.flatten();
|
||||||
if let Some(p) = shx_path {
|
if let Some(p) = shx_path {
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
// Community font repository — detect missing SHX fonts and fetch them from
|
||||||
|
// the project's `fonts/` folder on GitHub.
|
||||||
|
//
|
||||||
|
// DWG files reference SHX fonts by bare name (`vnarial.shx`, `romans.shx`, …).
|
||||||
|
// When the file doesn't resolve on disk the renderer substitutes an embedded
|
||||||
|
// LFF stroke font, which mangles drawings authored with fonts it cannot stand
|
||||||
|
// in for. This module detects those references and downloads the real files
|
||||||
|
// from the community folder, where anyone can contribute (see
|
||||||
|
// `fonts/README.md` in the repository root).
|
||||||
|
|
||||||
|
use std::io::Read as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use acadrust::CadDocument;
|
||||||
|
|
||||||
|
/// The community folder's GitHub contents API (lists name + download URL).
|
||||||
|
const REPO_API_URL: &str =
|
||||||
|
"https://api.github.com/repos/huaninstratech/OpenCADStudio/contents/fonts";
|
||||||
|
|
||||||
|
/// Local store for fonts downloaded from the community repository. Text
|
||||||
|
/// resolution searches here after the drawing folder.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn fonts_dir() -> Option<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 mut bytes = Vec::new();
|
||||||
|
response
|
||||||
|
.body_mut()
|
||||||
|
.as_reader()
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|e| format!("Could not download {file}: {e}"))?;
|
||||||
|
if bytes.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dest = dir.join(file);
|
||||||
|
std::fs::write(&dest, &bytes)
|
||||||
|
.map_err(|e| format!("Could not save {}: {e}", dest.display()))?;
|
||||||
|
downloaded.push((file.to_string(), dest));
|
||||||
|
}
|
||||||
|
Ok(downloaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape path characters that would break a URL (spaces and non-ASCII file
|
||||||
|
/// names are common in font sets).
|
||||||
|
fn percent_encode_path(file: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(file.len());
|
||||||
|
for byte in file.bytes() {
|
||||||
|
match byte {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
out.push(byte as char)
|
||||||
|
}
|
||||||
|
_ => out.push_str(&format!("%{byte:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_the_github_contents_listing() {
|
||||||
|
let body = r#"[
|
||||||
|
{"name":"romans.shx","path":"fonts/romans.shx",
|
||||||
|
"download_url":"https://raw.githubusercontent.com/x/y/main/fonts/romans.shx","type":"file"},
|
||||||
|
{"name":"subset","path":"fonts/subset","type":"dir"},
|
||||||
|
{"name":"no-url.shx","path":"fonts/no-url.shx","type":"file"}
|
||||||
|
]"#;
|
||||||
|
let fonts = parse_contents(body).unwrap();
|
||||||
|
assert_eq!(fonts.len(), 1);
|
||||||
|
assert_eq!(fonts[0].name, "romans.shx");
|
||||||
|
assert!(fonts[0].download_url.ends_with("/fonts/romans.shx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_source_urls_are_trimmed_and_encoded() {
|
||||||
|
assert_eq!(FontSource::from_url(""), FontSource::Community);
|
||||||
|
assert_eq!(
|
||||||
|
FontSource::from_url(" https://intra/fonts/ "),
|
||||||
|
FontSource::Custom("https://intra/fonts".into())
|
||||||
|
);
|
||||||
|
assert_eq!(percent_encode_path("vn 30f.shx"), "vn%2030f.shx");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_non_listing_response() {
|
||||||
|
assert!(parse_contents("{\"message\":\"Not Found\"}").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_fonts_deduplicate_by_name_case_insensitively() {
|
||||||
|
// Two styles referencing the same missing font with different casing
|
||||||
|
// and one whose file resolves next to the drawing must yield one hit.
|
||||||
|
let dir = std::env::temp_dir().join("ocs_font_repo_tests");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join("exists.shx"), b"stub").unwrap();
|
||||||
|
let mut doc = CadDocument::new();
|
||||||
|
let mk = |name: &str, file: &str| {
|
||||||
|
let mut style = acadrust::TextStyle::new(name);
|
||||||
|
style.font_file = file.into();
|
||||||
|
style
|
||||||
|
};
|
||||||
|
let _ = doc.text_styles.add(mk("A", "MISSING1.shx"));
|
||||||
|
let _ = doc.text_styles.add(mk("B", "missing1.SHX"));
|
||||||
|
let _ = doc.text_styles.add(mk("C", "exists.shx"));
|
||||||
|
doc.source_path = Some(dir.join("drawing.dwg").to_string_lossy().into_owned());
|
||||||
|
let missing = missing_shx_fonts(&doc);
|
||||||
|
assert_eq!(missing, vec!["MISSING1.shx".to_string()]);
|
||||||
|
let _ = std::fs::remove_file(dir.join("exists.shx"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@
|
|||||||
// Default save format: DWG (AC1032 / R2018+).
|
// Default save format: DWG (AC1032 / R2018+).
|
||||||
|
|
||||||
pub mod file_association;
|
pub mod file_association;
|
||||||
|
pub mod font_repo;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod edit_lock;
|
pub mod edit_lock;
|
||||||
pub mod obj;
|
pub mod obj;
|
||||||
|
pub mod ole_embed;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod single_instance;
|
pub mod single_instance;
|
||||||
pub mod pdf_export;
|
pub mod pdf_export;
|
||||||
@@ -1554,6 +1556,23 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> {
|
|||||||
Ok((path, w, h))
|
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`.
|
/// Save `doc` to `path` with the given DXF version, overriding `doc.version`.
|
||||||
/// Format is auto-detected from the extension (dwg / dxf).
|
/// Format is auto-detected from the extension (dwg / dxf).
|
||||||
pub fn save_as_version(
|
pub fn save_as_version(
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
// Embed a raster image inside the drawing itself as an OLE2FRAME entity.
|
||||||
|
//
|
||||||
|
// The IMAGE mechanism (RasterImage + ImageDefinition) stores only a file path,
|
||||||
|
// so a drawing copied to another machine loses its pictures. An OLE2FRAME
|
||||||
|
// instead carries the encoded raster inside an OLE compound file embedded in
|
||||||
|
// the entity data — the same mechanism AutoCAD uses for pasted pictures — so
|
||||||
|
// the .dwg is self-contained. cadcodec both builds the compound storage and
|
||||||
|
// reads it back, and the renderer already paints OLE2FRAMEs via
|
||||||
|
// ImageModel::from_ole2frame, so this module only packs and unpacks.
|
||||||
|
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use acadrust::compound_file::{
|
||||||
|
BinaryRecord, CompoundEntry, CompoundFile, CompoundStorage, CompoundStream,
|
||||||
|
CompoundStreamContent, StructuredStoragePayload,
|
||||||
|
};
|
||||||
|
use acadrust::entities::{Ole2Frame, OleFrameEnvelope, OleObjectType};
|
||||||
|
use acadrust::types::Vector3;
|
||||||
|
|
||||||
|
/// OLE compound-file stream holding OLE 1.0 native data. cadcodec's
|
||||||
|
/// presentation extractor looks here first; the body is the encoded raster
|
||||||
|
/// behind a 4-byte little-endian length.
|
||||||
|
const OLE10NATIVE: &str = "\u{1}Ole10Native";
|
||||||
|
/// Leading header marker carried verbatim by the geometry envelope; readers
|
||||||
|
/// use it to recognise the envelope around the compound file.
|
||||||
|
const GEOMETRY_MARKER: u16 = 1;
|
||||||
|
/// Encoded formats the extractor recognises by magic and every DWG consumer
|
||||||
|
/// can decode. Anything else is re-encoded as PNG before embedding.
|
||||||
|
const PORTABLE_FORMATS: [image::ImageFormat; 3] = [
|
||||||
|
image::ImageFormat::Png,
|
||||||
|
image::ImageFormat::Jpeg,
|
||||||
|
image::ImageFormat::Bmp,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A raster image prepared for embedding: encoded bytes plus the metadata the
|
||||||
|
/// placement prompts need.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct EmbeddedImage {
|
||||||
|
/// Encoded raster bytes (PNG / JPEG / BMP as stored or re-encoded).
|
||||||
|
pub bytes: Vec<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
@@ -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
|
/// The parent comes from `iced::window::run`, keeping the portal request tied
|
||||||
/// to the visible app window on Wayland instead of silently resolving to
|
/// to the visible app window on Wayland instead of silently resolving to
|
||||||
/// `None` on desktops that reject a parentless save dialog (#537).
|
/// `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(
|
pub fn pick_pdf_path_owned(
|
||||||
stem: String,
|
stem: String,
|
||||||
parent: &dyn iced::window::Window,
|
parent: &dyn iced::window::Window,
|
||||||
@@ -392,6 +392,25 @@ pub fn pick_pdf_path_owned(
|
|||||||
Some(path)
|
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 ───────────────────────────────────────────────────────────
|
// ── PDF builder ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
|||||||
+562
-25
@@ -103,11 +103,30 @@ pub fn list_printers() -> Vec<String> {
|
|||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn windows_printers() -> std::io::Result<Vec<String>> {
|
fn windows_printers() -> std::io::Result<Vec<String>> {
|
||||||
use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER};
|
|
||||||
use windows_sys::Win32::Graphics::Printing::{
|
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 bytes_needed = 0;
|
||||||
let mut printer_count = 0;
|
let mut printer_count = 0;
|
||||||
let mut buffer = Vec::<usize>::new();
|
let mut buffer = Vec::<usize>::new();
|
||||||
@@ -124,7 +143,7 @@ fn windows_printers() -> std::io::Result<Vec<String>> {
|
|||||||
// aligned, writable allocation of buffer_bytes bytes.
|
// aligned, writable allocation of buffer_bytes bytes.
|
||||||
let success = unsafe {
|
let success = unsafe {
|
||||||
EnumPrintersW(
|
EnumPrintersW(
|
||||||
PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS,
|
flags,
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
4,
|
4,
|
||||||
data,
|
data,
|
||||||
@@ -305,6 +324,297 @@ pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result
|
|||||||
dispatch_to_printer_opts(path, opts)
|
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).
|
/// Open a file with the OS default application (used for print preview).
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> {
|
pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> {
|
||||||
@@ -356,32 +666,51 @@ fn dispatch_to_printer_opts(
|
|||||||
_ => (wide("print"), None, "default printer".to_string()),
|
_ => (wide("print"), None, "default printer".to_string()),
|
||||||
};
|
};
|
||||||
let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null());
|
let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null());
|
||||||
for _ in 0..opts.copies.max(1) {
|
let shell = (|| -> Result<String, String> {
|
||||||
let mut info = SHELLEXECUTEINFOW {
|
for _ in 0..opts.copies.max(1) {
|
||||||
cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as u32,
|
let mut info = SHELLEXECUTEINFOW {
|
||||||
fMask: SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC,
|
cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as u32,
|
||||||
lpVerb: verb.as_ptr(),
|
fMask: SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC,
|
||||||
lpFile: path_wide.as_ptr(),
|
lpVerb: verb.as_ptr(),
|
||||||
lpParameters: params_ptr,
|
lpFile: path_wide.as_ptr(),
|
||||||
nShow: SW_HIDE,
|
lpParameters: params_ptr,
|
||||||
..Default::default()
|
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() }
|
|
||||||
};
|
};
|
||||||
if code == SE_ERR_NOASSOC || code == ERROR_NO_ASSOCIATION {
|
if unsafe { ShellExecuteExW(&mut info) } == 0 {
|
||||||
return Err(
|
let shell_code = info.hInstApp as usize;
|
||||||
"Windows has no PDF application registered with Print support.".into(),
|
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"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
@@ -459,7 +788,215 @@ fn dispatch_to_printer_opts(
|
|||||||
mod printer_properties_tests {
|
mod printer_properties_tests {
|
||||||
use super::printer_properties_command;
|
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")]
|
#[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]
|
#[test]
|
||||||
fn windows_opens_selected_printer_or_printer_list() {
|
fn windows_opens_selected_printer_or_printer_list() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+12
-1
@@ -54,6 +54,7 @@ const EXECUTE_OPS: &[&str] = &[
|
|||||||
"property",
|
"property",
|
||||||
"set_properties",
|
"set_properties",
|
||||||
"action",
|
"action",
|
||||||
|
"embed_image",
|
||||||
"save",
|
"save",
|
||||||
"stop",
|
"stop",
|
||||||
"batch",
|
"batch",
|
||||||
@@ -72,6 +73,7 @@ const BATCH_STEP_OPS: &[&str] = &[
|
|||||||
"property",
|
"property",
|
||||||
"set_properties",
|
"set_properties",
|
||||||
"action",
|
"action",
|
||||||
|
"embed_image",
|
||||||
"save",
|
"save",
|
||||||
"stop",
|
"stop",
|
||||||
];
|
];
|
||||||
@@ -717,6 +719,12 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> {
|
|||||||
)),
|
)),
|
||||||
None => missing("name", r#"{"op":"action","name":"zoom_extents"}"#),
|
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(()),
|
_ => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -902,7 +910,9 @@ fn execute_request_schema() -> Value {
|
|||||||
"camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."},
|
"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."},
|
"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"]},
|
"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."},
|
"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."},
|
"text":{"type":"string","description":"Free text or one option/value token."},
|
||||||
"point":point,
|
"point":point,
|
||||||
@@ -943,6 +953,7 @@ fn execute_request_schema() -> Value {
|
|||||||
{"properties":{"op":{"const":"property"}},"required":["field","value"]},
|
{"properties":{"op":{"const":"property"}},"required":["field","value"]},
|
||||||
{"properties":{"op":{"const":"set_properties"}},"required":["collection","updates"]},
|
{"properties":{"op":{"const":"set_properties"}},"required":["collection","updates"]},
|
||||||
{"properties":{"op":{"const":"action"}},"required":["name"]},
|
{"properties":{"op":{"const":"action"}},"required":["name"]},
|
||||||
|
{"properties":{"op":{"const":"embed_image"}},"required":["path"]},
|
||||||
{"properties":{"op":{"const":"save"}}},
|
{"properties":{"op":{"const":"save"}}},
|
||||||
{"properties":{"op":{"const":"stop"}}},
|
{"properties":{"op":{"const":"stop"}}},
|
||||||
{"properties":{"op":{"const":"batch"}},"required":["steps"]}
|
{"properties":{"op":{"const":"batch"}},"required":["steps"]}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
// IMAGE / IMAGEATTACH command — place a raster image in the drawing.
|
// IMAGE / IMAGEEMBED commands — place a raster image in the drawing.
|
||||||
//
|
//
|
||||||
// Workflow:
|
// IMAGE references the picture by path (RasterImage + ImageDefinition, like
|
||||||
|
// AutoCAD's IMAGEATTACH); IMAGEEMBED packs the encoded raster inside the
|
||||||
|
// drawing as an OLE2FRAME so the .dwg travels alone.
|
||||||
|
//
|
||||||
|
// Workflow (both commands):
|
||||||
// 1. File dialog opens (async, handled in update.rs).
|
// 1. File dialog opens (async, handled in update.rs).
|
||||||
// 2. User picks insertion point (first click).
|
// 2. User picks insertion point (first click).
|
||||||
// 3. User drags to pick width; height is computed from the image's aspect ratio.
|
// 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::t;
|
||||||
|
|
||||||
use crate::command::{CadCommand, CmdResult, WorkingPlane};
|
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;
|
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 {
|
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_width: u32,
|
||||||
pixel_height: u32,
|
pixel_height: u32,
|
||||||
origin: Option<DVec3>,
|
origin: Option<DVec3>,
|
||||||
@@ -26,7 +42,8 @@ pub struct ImageCommand {
|
|||||||
impl ImageCommand {
|
impl ImageCommand {
|
||||||
pub fn new(file_path: String, pixel_width: u32, pixel_height: u32) -> Self {
|
pub fn new(file_path: String, pixel_width: u32, pixel_height: u32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
file_path,
|
source: ImageSource::File(file_path),
|
||||||
|
name: "IMAGE",
|
||||||
pixel_width,
|
pixel_width,
|
||||||
pixel_height,
|
pixel_height,
|
||||||
origin: None,
|
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 {
|
fn aspect(&self) -> f64 {
|
||||||
if self.pixel_height == 0 {
|
if self.pixel_height == 0 {
|
||||||
1.0
|
1.0
|
||||||
@@ -46,21 +82,30 @@ impl ImageCommand {
|
|||||||
let origin = self.plane.to_local(origin);
|
let origin = self.plane.to_local(origin);
|
||||||
let width_pt = self.plane.to_local(width_pt);
|
let width_pt = self.plane.to_local(width_pt);
|
||||||
let world_width = (width_pt.x - origin.x).abs().max(0.001);
|
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 ins = Vector3::new(origin.x, origin.y, origin.z);
|
||||||
|
|
||||||
let mut img = RasterImage::with_size(
|
match &self.source {
|
||||||
&self.file_path,
|
ImageSource::File(path) => {
|
||||||
ins,
|
let world_height = world_width / self.aspect();
|
||||||
self.pixel_width as f64,
|
let mut img = RasterImage::with_size(
|
||||||
self.pixel_height as f64,
|
path,
|
||||||
world_width,
|
ins,
|
||||||
world_height,
|
self.pixel_width as f64,
|
||||||
);
|
self.pixel_height as f64,
|
||||||
img.flags = acadrust::entities::ImageDisplayFlags::SHOW_IMAGE
|
world_width,
|
||||||
| acadrust::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY;
|
world_height,
|
||||||
self.plane.place_entity(EntityType::RasterImage(img))
|
);
|
||||||
|
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 {
|
fn name(&self) -> &'static str {
|
||||||
"IMAGE"
|
self.name
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt(&self) -> String {
|
fn prompt(&self) -> String {
|
||||||
if self.origin.is_none() {
|
if self.origin.is_none() {
|
||||||
t!(
|
t!(
|
||||||
"IMAGE Specify insertion point (%{name}): ",
|
"IMAGE Specify insertion point (%{name}): ",
|
||||||
name = short_name(&self.file_path)
|
name = self.source_name()
|
||||||
)
|
)
|
||||||
.into_owned()
|
.into_owned()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+9
-9
@@ -2153,19 +2153,19 @@ impl Scene {
|
|||||||
|
|
||||||
fn populate_images_from_document_unbumped(&mut self) {
|
fn populate_images_from_document_unbumped(&mut self) {
|
||||||
self.images.clear();
|
self.images.clear();
|
||||||
let entries: Vec<(Handle, acadrust::entities::RasterImage)> = self
|
let entries: Vec<(Handle, acadrust::entities::EntityType)> = self
|
||||||
.document
|
.document
|
||||||
.entities()
|
.entities()
|
||||||
.filter_map(|e| {
|
.filter(|e| {
|
||||||
if let EntityType::RasterImage(img) = e {
|
matches!(
|
||||||
Some((img.common.handle, img.clone()))
|
e,
|
||||||
} else {
|
EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_)
|
||||||
None
|
)
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
.map(|e| (e.common().handle, e.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
for (handle, img) in entries {
|
for (handle, entity) in entries {
|
||||||
if let Some(model) = ImageModel::from_raster_image(&img) {
|
if let Some(model) = self.image_seed_for(&entity) {
|
||||||
self.images.insert(handle, model);
|
self.images.insert(handle, model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub struct PdfPage {
|
|||||||
|
|
||||||
const RASTER_DPI: f32 = 150.0;
|
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>>>> {
|
fn page_cache() -> &'static Mutex<HashMap<PageKey, Option<Arc<PdfPage>>>> {
|
||||||
static CACHE: OnceLock<Mutex<HashMap<PageKey, Option<Arc<PdfPage>>>>> = OnceLock::new();
|
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()
|
page_cache()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|error| error.into_inner())
|
.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.
|
/// Rasterise a 1-based PDF page, memoised by source path and page name.
|
||||||
pub fn rasterize_page(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
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()
|
if let Some(hit) = page_cache()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|error| error.into_inner())
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
@@ -55,7 +61,7 @@ pub fn rasterize_page(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
|||||||
return hit;
|
return hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
let built = rasterize_uncached(path, page);
|
let built = rasterize_uncached(path, page, dpi);
|
||||||
page_cache()
|
page_cache()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|error| error.into_inner())
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
@@ -63,12 +69,12 @@ pub fn rasterize_page(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
|||||||
built
|
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 bytes = source_bytes(path)?;
|
||||||
let pdf = Pdf::new(bytes).ok()?;
|
let pdf = Pdf::new(bytes).ok()?;
|
||||||
let page_no = page.trim().parse::<usize>().unwrap_or(1).max(1);
|
let page_no = page.trim().parse::<usize>().unwrap_or(1).max(1);
|
||||||
let page = pdf.pages().get(page_no - 1)?;
|
let page = pdf.pages().get(page_no - 1)?;
|
||||||
let scale = RASTER_DPI / 72.0;
|
let scale = dpi / 72.0;
|
||||||
let pixmap = hayro::render(
|
let pixmap = hayro::render(
|
||||||
page,
|
page,
|
||||||
&RenderCache::new(),
|
&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()),
|
pixels: Arc::new(pixmap.data_as_u8_slice().to_vec()),
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
dpi: RASTER_DPI,
|
dpi,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ pub mod shortcuts;
|
|||||||
pub mod layers;
|
pub mod layers;
|
||||||
pub mod update_notice;
|
pub mod update_notice;
|
||||||
pub mod open_progress;
|
pub mod open_progress;
|
||||||
|
pub mod missing_fonts;
|
||||||
pub mod recovery;
|
pub mod recovery;
|
||||||
pub mod options;
|
pub mod options;
|
||||||
pub mod attribute_editor;
|
pub mod attribute_editor;
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ pub struct PlotDialogState {
|
|||||||
pub upside_down: bool,
|
pub upside_down: bool,
|
||||||
pub copies: String,
|
pub copies: String,
|
||||||
pub area: 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 center: bool,
|
||||||
pub offset_x: String,
|
pub offset_x: String,
|
||||||
pub offset_y: String,
|
pub offset_y: String,
|
||||||
@@ -219,6 +223,7 @@ impl Default for PlotDialogState {
|
|||||||
upside_down: false,
|
upside_down: false,
|
||||||
copies: "1".into(),
|
copies: "1".into(),
|
||||||
area: "Window".into(),
|
area: "Window".into(),
|
||||||
|
window: None,
|
||||||
center: true,
|
center: true,
|
||||||
offset_x: "0.0".into(),
|
offset_x: "0.0".into(),
|
||||||
offset_y: "0.0".into(),
|
offset_y: "0.0".into(),
|
||||||
@@ -263,6 +268,7 @@ impl PlotDialogState {
|
|||||||
self.upside_down = o.upside_down;
|
self.upside_down = o.upside_down;
|
||||||
self.copies = o.copies.clone();
|
self.copies = o.copies.clone();
|
||||||
self.area = o.area.clone();
|
self.area = o.area.clone();
|
||||||
|
self.window = o.window;
|
||||||
self.center = o.center;
|
self.center = o.center;
|
||||||
self.offset_x = o.offset_x.clone();
|
self.offset_x = o.offset_x.clone();
|
||||||
self.offset_y = o.offset_y.clone();
|
self.offset_y = o.offset_y.clone();
|
||||||
|
|||||||
@@ -619,6 +619,11 @@ impl XrefManagerPanel {
|
|||||||
Some(Message::ImagePick),
|
Some(Message::ImagePick),
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
|
menu_item(
|
||||||
|
crate::t!("Embed Image (in drawing)").into_owned(),
|
||||||
|
Some(Message::ImageEmbedPick),
|
||||||
|
None,
|
||||||
|
),
|
||||||
menu_item(
|
menu_item(
|
||||||
crate::t!("Attach PDF").into_owned(),
|
crate::t!("Attach PDF").into_owned(),
|
||||||
Some(Message::PdfAttachPick),
|
Some(Message::PdfAttachPick),
|
||||||
|
|||||||
Reference in new issue
Block a user