forked from 1b-agent/opencadstudio-upstream
Merge pull request #1311 from ghost/plotting-station-a
This commit is contained in:
commit
1701e672dd
36 files changed
+3567
-425
No files matched your search
+6
-8
@@ -943,8 +943,10 @@ pub(super) struct OpenCADStudio {
|
||||
mtext_click_count: u8,
|
||||
/// Pending model-space plot window (x0, y0, x1, y1) in world XY, or None.
|
||||
plot_window: Option<(f64, f64, f64, f64)>,
|
||||
plot_format: crate::io::paper_sizes::PaperSize,
|
||||
plot_orientation: crate::io::paper_sizes::Orientation,
|
||||
/// Sheet the Plot dialog opens with when the layout has no page setup;
|
||||
/// updated from every plot / apply so the next dialog remembers it.
|
||||
plot_paper: crate::io::paper_catalog::PaperSize,
|
||||
plot_orientation: crate::io::paper_catalog::Orientation,
|
||||
/// Backing state for the full Plot / Print dialog.
|
||||
plot_dialog: crate::ui::window::plot::PlotDialogState,
|
||||
/// Snapshot of the dialog's settings taken when it opened, restored by the
|
||||
@@ -3320,10 +3322,6 @@ pub enum Message {
|
||||
PlotExport,
|
||||
/// Callback after the user picks (or cancels) the export path.
|
||||
PlotExportPath(Option<std::path::PathBuf>),
|
||||
/// User picked a paper size for the model-space window plot.
|
||||
PlotFormat(crate::io::paper_sizes::PaperSize),
|
||||
/// User picked a sheet orientation for the model-space window plot.
|
||||
PlotOrientation(crate::io::paper_sizes::Orientation),
|
||||
/// Export the pending model-space plot window (from PLOTWINDOW) to PDF.
|
||||
PlotWindowExport,
|
||||
/// Callback after the user picks (or cancels) the window-export path.
|
||||
@@ -3947,8 +3945,8 @@ impl OpenCADStudio {
|
||||
mtext_click_off: 0,
|
||||
mtext_click_count: 0,
|
||||
plot_window: None,
|
||||
plot_format: crate::io::paper_sizes::PaperSize::A4,
|
||||
plot_orientation: crate::io::paper_sizes::Orientation::Landscape,
|
||||
plot_paper: crate::io::paper_catalog::default_paper().clone(),
|
||||
plot_orientation: crate::io::paper_catalog::Orientation::Landscape,
|
||||
plot_dialog: crate::ui::window::plot::PlotDialogState::default(),
|
||||
plot_prev: None,
|
||||
plot_setup_template: None,
|
||||
|
||||
+719
-83
@@ -88,35 +88,119 @@ fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool {
|
||||
|
||||
use crate::io::pdf_export::{PdfPageInput, PlotContent};
|
||||
|
||||
fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) {
|
||||
use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize};
|
||||
let orientation = if d.orientation == "Portrait" {
|
||||
/// The dialog's sheet orientation as a catalogue value.
|
||||
fn plot_dialog_orientation(
|
||||
d: &crate::ui::window::plot::PlotDialogState,
|
||||
) -> crate::io::paper_catalog::Orientation {
|
||||
use crate::io::paper_catalog::Orientation;
|
||||
if d.orientation == "Portrait" {
|
||||
Orientation::Portrait
|
||||
} else {
|
||||
Orientation::Landscape
|
||||
};
|
||||
let standard = match d.paper.as_str() {
|
||||
"A3" => Some(PaperSize::A3),
|
||||
"A2" => Some(PaperSize::A2),
|
||||
"A1" => Some(PaperSize::A1),
|
||||
"A0" => Some(PaperSize::A0),
|
||||
"A4" => Some(PaperSize::A4),
|
||||
"Letter" => Some(PaperSize::Letter),
|
||||
"Legal" => Some(PaperSize::Legal),
|
||||
"Tabloid" => Some(PaperSize::Tabloid),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(paper) = standard {
|
||||
return sheet_mm(paper, orientation);
|
||||
}
|
||||
let short = d.paper_width_mm.min(d.paper_height_mm).max(1.0);
|
||||
let long = d.paper_width_mm.max(d.paper_height_mm).max(1.0);
|
||||
match orientation {
|
||||
Orientation::Portrait => (short, long),
|
||||
Orientation::Landscape => (long, short),
|
||||
}
|
||||
|
||||
/// The sheet the dialog names, or — when the name carries no size (an old
|
||||
/// config, a bare driver name) — a custom sheet built from the dialog's stored
|
||||
/// dimensions, so a drawing whose paper we cannot identify still plots at its
|
||||
/// own size.
|
||||
fn plot_dialog_paper(
|
||||
d: &crate::ui::window::plot::PlotDialogState,
|
||||
) -> crate::io::paper_catalog::PaperSize {
|
||||
use crate::io::paper_catalog::{from_drawing, resolve, PaperSize, PaperUnits};
|
||||
if d.paper_width_mm > 0.0 && d.paper_height_mm > 0.0 {
|
||||
from_drawing(&d.paper, d.paper_width_mm, d.paper_height_mm)
|
||||
} else {
|
||||
resolve(&d.paper)
|
||||
.unwrap_or_else(|| PaperSize::custom(210.0, 297.0, PaperUnits::Millimeters))
|
||||
}
|
||||
}
|
||||
|
||||
fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) {
|
||||
plot_dialog_paper(d).sheet_mm(plot_dialog_orientation(d))
|
||||
}
|
||||
|
||||
/// Whether the dialog still names the sheet the stored settings carry.
|
||||
fn paper_unchanged(
|
||||
d: &crate::ui::window::plot::PlotDialogState,
|
||||
stored: &acadrust::objects::PlotSettings,
|
||||
) -> bool {
|
||||
!stored.paper_size.is_empty()
|
||||
&& crate::io::paper_catalog::from_drawing(
|
||||
&stored.paper_size,
|
||||
stored.paper_width,
|
||||
stored.paper_height,
|
||||
)
|
||||
.canonical
|
||||
== d.paper
|
||||
}
|
||||
|
||||
/// The media name to store for the dialog's sheet. A drawing keeps its own
|
||||
/// spelling of a sheet the user did not change — a system printer's `A4` or a
|
||||
/// plotter's custom name must not turn into the catalogue's canonical name
|
||||
/// just by passing through the dialog, or the source application would look the sheet up
|
||||
/// under a name that driver never reported. Only a genuinely different
|
||||
/// selection writes the canonical name.
|
||||
fn paper_name_for_settings(
|
||||
d: &crate::ui::window::plot::PlotDialogState,
|
||||
stored: &acadrust::objects::PlotSettings,
|
||||
) -> String {
|
||||
if paper_unchanged(d, stored) {
|
||||
stored.paper_size.clone()
|
||||
} else {
|
||||
d.paper.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The plot device the dialog currently targets.
|
||||
fn plot_dialog_device(d: &crate::ui::window::plot::PlotDialogState) -> crate::io::plot_device::PlotDevice {
|
||||
use crate::io::plot_device::PlotDevice;
|
||||
if d.to_file {
|
||||
PlotDevice::Pdf
|
||||
} else {
|
||||
match &d.printer {
|
||||
Some(name) => PlotDevice::Printer(name.clone()),
|
||||
None => PlotDevice::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The plotter name to store. Same rule as the sheet: a device name the source application
|
||||
/// wrote (its PDF driver, a plotter `.pc3` we plot to PDF for it) stays as it
|
||||
/// is while the dialog still points at the same kind of device; a changed
|
||||
/// device, or one of our own legacy labels, is written in the source application's spelling.
|
||||
fn device_name_for_settings(
|
||||
d: &crate::ui::window::plot::PlotDialogState,
|
||||
stored: &acadrust::objects::PlotSettings,
|
||||
) -> String {
|
||||
use crate::io::plot_device::PlotDevice;
|
||||
let device = plot_dialog_device(d);
|
||||
let (stored_device, keep_stored) = PlotDevice::from_stored_name(&stored.printer_name);
|
||||
if keep_stored && stored_device == device {
|
||||
stored.printer_name.clone()
|
||||
} else {
|
||||
device.canonical_name()
|
||||
}
|
||||
}
|
||||
|
||||
/// The unprintable margins to store: the drawing's own while neither the
|
||||
/// sheet nor the device changed (they came from the source application's driver and we have
|
||||
/// no better source), otherwise what our device reports for the new sheet —
|
||||
/// the equivalent of the source application's `PAPERUPDATE`.
|
||||
fn margins_for_settings(
|
||||
d: &crate::ui::window::plot::PlotDialogState,
|
||||
stored: &acadrust::objects::PlotSettings,
|
||||
) -> acadrust::objects::PaperMargin {
|
||||
use crate::io::plot_device::PlotDevice;
|
||||
let device = plot_dialog_device(d);
|
||||
let (stored_device, _) = PlotDevice::from_stored_name(&stored.printer_name);
|
||||
if paper_unchanged(d, stored) && stored_device == device {
|
||||
return stored.margins;
|
||||
}
|
||||
let margins = device.margins_mm(&plot_dialog_paper(d), &d.custom_papers);
|
||||
acadrust::objects::PaperMargin::new(margins.left, margins.bottom, margins.right, margins.top)
|
||||
}
|
||||
|
||||
fn plot_content_extents(content: &PlotContent) -> Option<(f64, f64, f64, f64)> {
|
||||
let PlotContent { wires, hatches, wipeouts, images, .. } = content;
|
||||
let mut bounds = (
|
||||
@@ -2916,9 +3000,16 @@ impl OpenCADStudio {
|
||||
.clone()
|
||||
.or_else(|| self.tabs[i].scene.plot_settings_for(&layout_name))
|
||||
.unwrap_or_else(|| PlotSettings::new(""));
|
||||
// Names and margins are decided against the stored settings before
|
||||
// any field is overwritten.
|
||||
let paper_size = paper_name_for_settings(&dialog, &ps);
|
||||
let printer_name = device_name_for_settings(&dialog, &ps);
|
||||
let margins = margins_for_settings(&dialog, &ps);
|
||||
ps.paper_size = paper_size;
|
||||
ps.printer_name = printer_name;
|
||||
ps.margins = margins;
|
||||
ps.paper_width = w;
|
||||
ps.paper_height = h;
|
||||
ps.paper_size = dialog.paper.clone();
|
||||
ps.plot_type = match plot_area {
|
||||
"Window" => PlotType::Window,
|
||||
"Display" => PlotType::LastScreenDisplay,
|
||||
@@ -2955,11 +3046,6 @@ impl OpenCADStudio {
|
||||
ps.standard_scale_factor = factor;
|
||||
ps.flags.use_standard_scale = false;
|
||||
}
|
||||
ps.printer_name = if dialog.to_file {
|
||||
crate::ui::window::plot::OUT_PDF.into()
|
||||
} else {
|
||||
dialog.printer.clone().unwrap_or_default()
|
||||
};
|
||||
ps.current_style_sheet = dialog.style_name.clone();
|
||||
ps.flags.scale_lineweights = dialog.scale_lw;
|
||||
ps.flags.print_lineweights = dialog.lineweights;
|
||||
@@ -3637,7 +3723,7 @@ impl OpenCADStudio {
|
||||
/// Open the full Plot / Print dialog, seeding its state from the active
|
||||
/// layout's plot settings and the printers found on the system.
|
||||
pub(super) fn on_plot_dialog_open(&mut self) -> Task<Message> {
|
||||
use crate::io::paper_sizes::Orientation;
|
||||
use crate::io::paper_catalog::Orientation;
|
||||
let previous = self.plot_dialog.clone();
|
||||
let scales: Vec<(String, f64)> = self.tabs[self.active_tab]
|
||||
.scene
|
||||
@@ -3660,6 +3746,7 @@ impl OpenCADStudio {
|
||||
// Keep session-only choices while loading drawing fields from the layout.
|
||||
let d = &mut self.plot_dialog;
|
||||
d.printers = crate::io::print_to_printer::list_printers();
|
||||
d.default_printer = crate::io::plot_device::default_printer_name();
|
||||
d.plot_styles = crate::io::plot_style::available_ctb_names();
|
||||
d.scales = scales;
|
||||
d.plot_views = plot_views;
|
||||
@@ -3671,7 +3758,8 @@ impl OpenCADStudio {
|
||||
d.scale = one_to_one.clone();
|
||||
}
|
||||
d.paper_space = paper_space;
|
||||
d.paper = self.plot_format.label().to_string();
|
||||
d.paper = self.plot_paper.canonical.to_string();
|
||||
(d.paper_width_mm, d.paper_height_mm) = self.plot_paper.sheet_mm(self.plot_orientation);
|
||||
d.orientation = match self.plot_orientation {
|
||||
Orientation::Portrait => "Portrait",
|
||||
Orientation::Landscape => "Landscape",
|
||||
@@ -3742,7 +3830,188 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
self.active_modal = Some(crate::app::ModalKind::Plot);
|
||||
Task::none()
|
||||
self.request_printer_media()
|
||||
}
|
||||
|
||||
/// Keep the dialog's view of the selected printer's media current: use the
|
||||
/// session cache when the printer was asked before, otherwise ask it in
|
||||
/// the background and clear the stale answer meanwhile. PDF output and
|
||||
/// the default printer list the catalogue instead.
|
||||
fn request_printer_media(&mut self) -> Task<Message> {
|
||||
let d = &mut self.plot_dialog;
|
||||
let Some(printer) = (!d.to_file).then(|| d.printer.clone()).flatten() else {
|
||||
d.printer_media = None;
|
||||
return Task::none();
|
||||
};
|
||||
if let Some(caps) = crate::io::plot_device::cached_printer_capabilities(&printer) {
|
||||
d.printer_media = Some(caps);
|
||||
return Task::none();
|
||||
}
|
||||
d.printer_media = None;
|
||||
let name = printer.clone();
|
||||
background_task(
|
||||
move || crate::io::plot_device::printer_capabilities(&printer),
|
||||
move |caps| Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterMedia(name, caps)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The inline custom-sheet editor of the Plot dialog. A sheet is added
|
||||
/// to (or replaced in) the persisted list under its media name and
|
||||
/// selected at once; the list is saved immediately so it survives a
|
||||
/// crash the way a plotter configuration would.
|
||||
fn on_custom_paper_msg(&mut self, msg: crate::ui::window::plot::CustomPaperMsg) {
|
||||
use crate::ui::window::plot::{CustomPaperDraft, CustomPaperMsg as C, MarginSide};
|
||||
let d = &mut self.plot_dialog;
|
||||
match msg {
|
||||
C::Open => {
|
||||
let paper = plot_dialog_paper(d);
|
||||
let existing = d
|
||||
.custom_papers
|
||||
.iter()
|
||||
.find(|custom| custom.canonical() == paper.canonical);
|
||||
let margins = match existing {
|
||||
Some(custom) => custom.margins_mm(),
|
||||
None => plot_dialog_device(d).margins_mm(&paper, &d.custom_papers),
|
||||
};
|
||||
let mut draft = CustomPaperDraft::from_paper(&paper, margins);
|
||||
// A catalogue sheet is only the starting size; the new sheet
|
||||
// gets its own name (the source application's generic one when left blank).
|
||||
if existing.is_none() {
|
||||
draft.name.clear();
|
||||
}
|
||||
d.custom_editor = Some(draft);
|
||||
}
|
||||
C::Cancel => d.custom_editor = None,
|
||||
C::Name(value) => Self::edit_custom_draft(d, |draft| draft.name = value),
|
||||
C::Width(value) => Self::edit_custom_draft(d, |draft| draft.width = value),
|
||||
C::Height(value) => Self::edit_custom_draft(d, |draft| draft.height = value),
|
||||
C::Units(value) => Self::edit_custom_draft(d, |draft| draft.inches = value == "Inches"),
|
||||
C::Margin(side, value) => Self::edit_custom_draft(d, |draft| {
|
||||
let index = match side {
|
||||
MarginSide::Left => 0,
|
||||
MarginSide::Bottom => 1,
|
||||
MarginSide::Right => 2,
|
||||
MarginSide::Top => 3,
|
||||
};
|
||||
draft.margins[index] = value;
|
||||
}),
|
||||
C::Add => {
|
||||
let Some(draft) = d.custom_editor.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match draft.build() {
|
||||
Ok(custom) => {
|
||||
let canonical = custom.canonical();
|
||||
d.custom_papers.retain(|existing| existing.canonical() != canonical);
|
||||
d.custom_papers.push(custom);
|
||||
d.custom_editor = None;
|
||||
d.paper = canonical;
|
||||
let (w, h) = plot_dialog_sheet_mm(d);
|
||||
d.paper_width_mm = w;
|
||||
d.paper_height_mm = h;
|
||||
self.save_config();
|
||||
}
|
||||
Err(error) => draft.error = Some(error),
|
||||
}
|
||||
}
|
||||
C::Remove => {
|
||||
let selected = plot_dialog_paper(d).canonical.into_owned();
|
||||
let before = d.custom_papers.len();
|
||||
d.custom_papers.retain(|custom| custom.canonical() != selected);
|
||||
if d.custom_papers.len() != before {
|
||||
self.save_config();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_custom_draft(
|
||||
d: &mut crate::ui::window::plot::PlotDialogState,
|
||||
edit: impl FnOnce(&mut crate::ui::window::plot::CustomPaperDraft),
|
||||
) {
|
||||
if let Some(draft) = d.custom_editor.as_mut() {
|
||||
edit(draft);
|
||||
draft.error = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties… for the selected printer.
|
||||
///
|
||||
/// On CUPS platforms (Linux, macOS) the driver's options are listed in an
|
||||
/// in-line editor and applied to every job for that printer through
|
||||
/// `lp -o`. On Windows the driver's own document-properties sheet is
|
||||
/// shown and its result stored as the printer's preferences, which the
|
||||
/// print job honours. Without a named printer, or where the options
|
||||
/// cannot be listed, the platform's printer settings open instead.
|
||||
fn on_printer_properties(&mut self) -> Task<Message> {
|
||||
let Some(printer) = self.plot_dialog.printer.clone().filter(|_| !self.plot_dialog.to_file)
|
||||
else {
|
||||
self.open_printer_settings_fallback();
|
||||
return Task::none();
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return self.edit_windows_printer_preferences(printer);
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
{
|
||||
self.plot_dialog.printer_editor = Some(crate::ui::window::plot::PrinterOptionsDraft {
|
||||
printer: printer.clone(),
|
||||
options: None,
|
||||
choices: Default::default(),
|
||||
error: None,
|
||||
});
|
||||
let name = printer.clone();
|
||||
background_task(
|
||||
move || crate::io::print_to_printer::printer_options(&printer),
|
||||
move |result| {
|
||||
Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterOptionsLoaded(
|
||||
name, result,
|
||||
))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The driver's document-properties sheet, owned by the main window so
|
||||
/// it sits on top of the plot dialog.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn edit_windows_printer_preferences(&mut self, printer: String) -> Task<Message> {
|
||||
let Some(window) = self.main_window else {
|
||||
self.open_printer_settings_fallback();
|
||||
return Task::none();
|
||||
};
|
||||
iced::window::run(window, move |w| {
|
||||
use iced::window::raw_window_handle::RawWindowHandle;
|
||||
let owner = match w.window_handle().ok().map(|h| h.as_raw()) {
|
||||
Some(RawWindowHandle::Win32(handle)) => handle.hwnd.get(),
|
||||
_ => 0,
|
||||
};
|
||||
match crate::io::print_to_printer::edit_printer_preferences(&printer, owner) {
|
||||
Ok(true) => Ok(crate::tf!("Printing preferences saved for {printer}.").into_owned()),
|
||||
Ok(false) => Ok(crate::t!("Printing preferences unchanged.").into_owned()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
})
|
||||
.then(|result| {
|
||||
Task::done(Message::BackgroundIoFinished(
|
||||
result.map(|message| message),
|
||||
false,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The platform's printer settings surface: a settings panel on Linux,
|
||||
/// the printers control panel on Windows, System Settings on macOS.
|
||||
fn open_printer_settings_fallback(&mut self) {
|
||||
match crate::io::print_to_printer::open_printer_properties(
|
||||
self.plot_dialog.printer.as_deref(),
|
||||
) {
|
||||
Ok(()) => self
|
||||
.command_line
|
||||
.push_info(crate::t!("Opened printer properties.").as_ref()),
|
||||
Err(error) => self.command_line.push_error(&error),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_common_plot_dialog(&mut self) {
|
||||
@@ -3767,6 +4036,9 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
M::Printer(s) => {
|
||||
// Driver choices belong to a printer; the editor closes when
|
||||
// the target changes.
|
||||
self.plot_dialog.printer_editor = None;
|
||||
if s == OUT_PDF {
|
||||
self.plot_dialog.to_file = true;
|
||||
} else if s == OUT_DEFAULT {
|
||||
@@ -3776,19 +4048,86 @@ impl OpenCADStudio {
|
||||
self.plot_dialog.to_file = false;
|
||||
self.plot_dialog.printer = Some(s);
|
||||
}
|
||||
self.request_printer_media()
|
||||
}
|
||||
M::PrinterMedia(printer, caps) => {
|
||||
let d = &mut self.plot_dialog;
|
||||
if !d.to_file && d.printer.as_deref() == Some(printer.as_str()) {
|
||||
d.printer_media = caps;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
M::PrinterProperties => {
|
||||
match crate::io::print_to_printer::open_printer_properties(
|
||||
self.plot_dialog.printer.as_deref(),
|
||||
) {
|
||||
Ok(()) => self
|
||||
.command_line
|
||||
.push_info(crate::t!("Opened printer properties.").as_ref()),
|
||||
Err(error) => self.command_line.push_error(&error),
|
||||
M::CustomPaper(msg) => {
|
||||
self.on_custom_paper_msg(msg);
|
||||
Task::none()
|
||||
}
|
||||
M::PrinterProperties => self.on_printer_properties(),
|
||||
M::PrinterOptionsLoaded(printer, result) => {
|
||||
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
|
||||
if draft.printer == printer {
|
||||
match result {
|
||||
Ok(options) => {
|
||||
// Start from the remembered choices, then the
|
||||
// driver's defaults.
|
||||
let remembered = self
|
||||
.plot_dialog
|
||||
.driver_options
|
||||
.get(&printer)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let draft = self.plot_dialog.printer_editor.as_mut().unwrap();
|
||||
draft.choices = options
|
||||
.iter()
|
||||
.map(|option| {
|
||||
let choice = remembered
|
||||
.get(&option.key)
|
||||
.filter(|c| option.choices.iter().any(|x| &x.keyword == *c))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| option.default.clone());
|
||||
(option.key.clone(), choice)
|
||||
})
|
||||
.collect();
|
||||
draft.options = Some(options);
|
||||
}
|
||||
Err(error) => draft.error = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
M::PrinterOptionSet(key, choice) => {
|
||||
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
|
||||
draft.choices.insert(key, choice);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
M::PrinterOptionsReset => {
|
||||
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
|
||||
if let Some(options) = &draft.options {
|
||||
draft.choices = options
|
||||
.iter()
|
||||
.map(|option| (option.key.clone(), option.default.clone()))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
M::PrinterOptionsApply => {
|
||||
if let Some(draft) = self.plot_dialog.printer_editor.take() {
|
||||
let overrides = draft.overrides();
|
||||
if overrides.is_empty() {
|
||||
self.plot_dialog.driver_options.remove(&draft.printer);
|
||||
} else {
|
||||
self.plot_dialog.driver_options.insert(draft.printer.clone(), overrides);
|
||||
}
|
||||
self.save_config();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
M::PrinterOptionsCancel => {
|
||||
self.plot_dialog.printer_editor = None;
|
||||
Task::none()
|
||||
}
|
||||
M::Paper(s) => {
|
||||
self.plot_dialog.paper = s;
|
||||
let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog);
|
||||
@@ -3938,7 +4277,8 @@ impl OpenCADStudio {
|
||||
self.plot_dialog.paper_space = true;
|
||||
self.plot_dialog.area = "Layout".into();
|
||||
}
|
||||
Task::none()
|
||||
// The setup may name a different printer.
|
||||
self.request_printer_media()
|
||||
}
|
||||
M::SetCurrent => {
|
||||
self.apply_dialog_to_layout();
|
||||
@@ -4100,10 +4440,11 @@ impl OpenCADStudio {
|
||||
let is_model = self.tabs[self.active_tab].scene.current_layout == "Model";
|
||||
let d = &mut self.plot_dialog;
|
||||
d.to_file = true;
|
||||
d.paper = "A4".into();
|
||||
let a4 = crate::io::paper_catalog::default_paper();
|
||||
d.paper = a4.canonical.to_string();
|
||||
d.orientation = "Landscape".into();
|
||||
d.paper_width_mm = 297.0;
|
||||
d.paper_height_mm = 210.0;
|
||||
(d.paper_width_mm, d.paper_height_mm) =
|
||||
a4.sheet_mm(crate::io::paper_catalog::Orientation::Landscape);
|
||||
d.area = if is_model {
|
||||
"Window".into()
|
||||
} else {
|
||||
@@ -4164,9 +4505,14 @@ impl OpenCADStudio {
|
||||
settings
|
||||
}
|
||||
};
|
||||
let paper_size = paper_name_for_settings(d, &ps);
|
||||
let printer_name = device_name_for_settings(d, &ps);
|
||||
let margins = margins_for_settings(d, &ps);
|
||||
ps.paper_size = paper_size;
|
||||
ps.printer_name = printer_name;
|
||||
ps.margins = margins;
|
||||
ps.paper_width = w;
|
||||
ps.paper_height = h;
|
||||
ps.paper_size = d.paper.clone();
|
||||
ps.plot_type = match d.area.as_str() {
|
||||
"Window" => PlotType::Window,
|
||||
"Layout" => PlotType::Layout,
|
||||
@@ -4212,11 +4558,6 @@ impl OpenCADStudio {
|
||||
ps.standard_scale_factor = factor;
|
||||
ps.flags.use_standard_scale = false;
|
||||
}
|
||||
ps.printer_name = if d.to_file {
|
||||
crate::ui::window::plot::OUT_PDF.into()
|
||||
} else {
|
||||
d.printer.clone().unwrap_or_default()
|
||||
};
|
||||
ps.current_style_sheet = d.style_name.clone();
|
||||
ps.flags.scale_lineweights = d.scale_lw;
|
||||
ps.flags.print_lineweights = d.lineweights;
|
||||
@@ -4281,16 +4622,23 @@ impl OpenCADStudio {
|
||||
&& active_style_name
|
||||
.as_deref()
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case(&style_name));
|
||||
let (mut paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height);
|
||||
if !matches!(
|
||||
paper.as_str(),
|
||||
"A4" | "A3" | "A2" | "A1" | "A0" | "Letter" | "Legal" | "Tabloid"
|
||||
) && !ps.paper_size.is_empty()
|
||||
{
|
||||
paper = ps.paper_size.clone();
|
||||
// The stored media name wins (it is what the source application wrote); a drawing
|
||||
// without a usable name is matched by size, and an unknown size keeps
|
||||
// its own dimensions as a custom sheet. The sheet's orientation is the
|
||||
// stored width/height order; the rotation below may flip it again.
|
||||
let paper = crate::io::paper_catalog::from_drawing(
|
||||
&ps.paper_size,
|
||||
ps.paper_width,
|
||||
ps.paper_height,
|
||||
);
|
||||
let orient = if ps.paper_width >= ps.paper_height {
|
||||
"Landscape"
|
||||
} else {
|
||||
"Portrait"
|
||||
}
|
||||
.to_string();
|
||||
let d = &mut self.plot_dialog;
|
||||
d.paper = paper;
|
||||
d.paper = paper.canonical.to_string();
|
||||
d.paper_width_mm = ps.paper_width.max(1.0);
|
||||
d.paper_height_mm = ps.paper_height.max(1.0);
|
||||
d.orientation = orient;
|
||||
@@ -4365,12 +4713,19 @@ impl OpenCADStudio {
|
||||
_ => "Normal",
|
||||
}
|
||||
.into();
|
||||
if ps.printer_name.to_ascii_lowercase().contains("pdf") {
|
||||
d.to_file = true;
|
||||
d.printer = None;
|
||||
} else {
|
||||
d.to_file = false;
|
||||
d.printer = (!ps.printer_name.is_empty()).then(|| ps.printer_name.clone());
|
||||
match crate::io::plot_device::PlotDevice::from_stored_name(&ps.printer_name).0 {
|
||||
crate::io::plot_device::PlotDevice::Pdf => {
|
||||
d.to_file = true;
|
||||
d.printer = None;
|
||||
}
|
||||
crate::io::plot_device::PlotDevice::Printer(name) => {
|
||||
d.to_file = false;
|
||||
d.printer = Some(name);
|
||||
}
|
||||
crate::io::plot_device::PlotDevice::None => {
|
||||
d.to_file = false;
|
||||
d.printer = None;
|
||||
}
|
||||
}
|
||||
d.style_name = style_name;
|
||||
d.apply_plot_styles = ps.flags.plot_plot_styles;
|
||||
@@ -4381,25 +4736,9 @@ impl OpenCADStudio {
|
||||
/// Copy transient paper/scale choices out of the dialog without changing
|
||||
/// the active layout.
|
||||
fn sync_dialog_plot_runtime(&mut self) {
|
||||
use crate::io::paper_sizes::{Orientation, PaperSize};
|
||||
let d = self.plot_dialog.clone();
|
||||
let paper = match d.paper.as_str() {
|
||||
"A3" => PaperSize::A3,
|
||||
"A2" => PaperSize::A2,
|
||||
"A1" => PaperSize::A1,
|
||||
"A0" => PaperSize::A0,
|
||||
"Letter" => PaperSize::Letter,
|
||||
"Legal" => PaperSize::Legal,
|
||||
"Tabloid" => PaperSize::Tabloid,
|
||||
_ => PaperSize::A4,
|
||||
};
|
||||
let orient = if d.orientation == "Portrait" {
|
||||
Orientation::Portrait
|
||||
} else {
|
||||
Orientation::Landscape
|
||||
};
|
||||
self.plot_format = paper;
|
||||
self.plot_orientation = orient;
|
||||
let d = &self.plot_dialog;
|
||||
self.plot_paper = plot_dialog_paper(d);
|
||||
self.plot_orientation = plot_dialog_orientation(d);
|
||||
}
|
||||
|
||||
fn apply_dialog_to_layout(&mut self) {
|
||||
@@ -4519,6 +4858,12 @@ impl OpenCADStudio {
|
||||
printer: d.printer.clone(),
|
||||
copies: d.copies.trim().parse::<u32>().unwrap_or(1).max(1),
|
||||
quality: Some(d.quality.clone()),
|
||||
driver_options: d
|
||||
.printer
|
||||
.as_ref()
|
||||
.and_then(|printer| d.driver_options.get(printer))
|
||||
.map(|options| options.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4651,7 +4996,7 @@ impl OpenCADStudio {
|
||||
/// Render a selected rectangle through one shared Model/Paper path. The
|
||||
/// window may lie partly or wholly outside a paper sheet.
|
||||
fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option<PdfPageInput> {
|
||||
use crate::io::paper_sizes::{window_to_sheet, PlotScale};
|
||||
use crate::io::paper_catalog::{window_to_sheet, PlotScale};
|
||||
let i = self.active_tab;
|
||||
let (x0, y0, x1, y1) = window;
|
||||
if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 {
|
||||
@@ -4811,3 +5156,294 @@ impl OpenCADStudio {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod plot_paper_tests {
|
||||
use crate::app::{Message, OpenCADStudio};
|
||||
use crate::ui::window::plot::PlotDlgMsg;
|
||||
|
||||
/// A drawing on `Layout1` whose page setup names its sheet the way a
|
||||
/// Windows printer driver does — a bare `A4` with landscape dimensions.
|
||||
fn app_with_printer_named_sheet() -> OpenCADStudio {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let _ = app.update(Message::LayoutSwitch("Layout1".into()));
|
||||
let i = app.active_tab;
|
||||
let mut ps = app.tabs[i]
|
||||
.scene
|
||||
.plot_settings_for("Layout1")
|
||||
.expect("a fresh layout carries plot settings");
|
||||
ps.paper_size = "A4".into();
|
||||
ps.paper_width = 297.0;
|
||||
ps.paper_height = 210.0;
|
||||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||||
app
|
||||
}
|
||||
|
||||
fn layout_paper(app: &OpenCADStudio) -> (String, f64, f64) {
|
||||
let ps = app.tabs[app.active_tab]
|
||||
.scene
|
||||
.plot_settings_for("Layout1")
|
||||
.unwrap();
|
||||
(ps.paper_size, ps.paper_width, ps.paper_height)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_shows_the_catalogue_sheet_for_a_driver_name() {
|
||||
let mut app = app_with_printer_named_sheet();
|
||||
let _ = app.on_plot_dialog_open();
|
||||
assert_eq!(app.plot_dialog.paper, "ISO_A4_(210.00_x_297.00_MM)");
|
||||
assert_eq!(app.plot_dialog.orientation, "Landscape");
|
||||
assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (297.0, 210.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_an_unchanged_sheet_keeps_the_drawing_media_name() {
|
||||
let mut app = app_with_printer_named_sheet();
|
||||
let _ = app.on_plot_dialog_open();
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||||
let (name, w, h) = layout_paper(&app);
|
||||
assert_eq!(name, "A4", "the driver's own spelling must survive the dialog");
|
||||
assert_eq!((w, h), (297.0, 210.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_another_sheet_writes_the_canonical_name() {
|
||||
let mut app = app_with_printer_named_sheet();
|
||||
let _ = app.on_plot_dialog_open();
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
|
||||
assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (420.0, 297.0));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||||
let (name, w, h) = layout_paper(&app);
|
||||
assert_eq!(name, "ISO_A3_(297.00_x_420.00_MM)");
|
||||
assert_eq!((w, h), (420.0, 297.0));
|
||||
// The next dialog remembers the sheet the user chose.
|
||||
assert_eq!(app.plot_paper.canonical, "ISO_A3_(297.00_x_420.00_MM)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_output_is_stored_as_the_source_app_pdf_driver() {
|
||||
let mut app = app_with_printer_named_sheet();
|
||||
let _ = app.on_plot_dialog_open();
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||||
let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap();
|
||||
assert_eq!(ps.printer_name, "DWG To PDF.pc3");
|
||||
// A changed device takes the PDF driver's printable area for the sheet.
|
||||
assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_pdf_label_is_upgraded_on_save() {
|
||||
let mut app = app_with_printer_named_sheet();
|
||||
let i = app.active_tab;
|
||||
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
ps.printer_name = "Save to PDF file…".into();
|
||||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||||
let _ = app.on_plot_dialog_open();
|
||||
assert!(app.plot_dialog.to_file, "the legacy label still means PDF output");
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||||
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
assert_eq!(ps.printer_name, "DWG To PDF.pc3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plotter_configuration_from_source_app_is_preserved_with_its_margins() {
|
||||
let mut app = app_with_printer_named_sheet();
|
||||
let i = app.active_tab;
|
||||
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
ps.printer_name = "DWF6 ePlot.pc3".into();
|
||||
ps.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into();
|
||||
ps.margins = acadrust::objects::PaperMargin::new(5.793749, 17.793753, 5.793744, 17.793747);
|
||||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||||
let _ = app.on_plot_dialog_open();
|
||||
assert!(app.plot_dialog.to_file, "an unknown plotter plots to PDF here");
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||||
let after = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
assert_eq!(after.printer_name, "DWF6 ePlot.pc3");
|
||||
assert_eq!(after.margins, ps.margins, "the source application's driver margins survive an unchanged setup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_the_sheet_updates_the_printable_area() {
|
||||
let mut app = app_with_printer_named_sheet();
|
||||
let i = app.active_tab;
|
||||
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
ps.printer_name = "DWG To PDF.pc3".into();
|
||||
ps.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into();
|
||||
ps.margins = acadrust::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0);
|
||||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||||
let _ = app.on_plot_dialog_open();
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_full_bleed_A4_(210.00_x_297.00_MM)".into()));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||||
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
assert_eq!(ps.paper_size, "ISO_full_bleed_A4_(210.00_x_297.00_MM)");
|
||||
assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(0.0, 1.0, 0.0, 1.0));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||||
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0));
|
||||
assert_eq!((ps.paper_width, ps.paper_height), (420.0, 297.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_printer_is_stored_by_name() {
|
||||
let mut app = app_with_printer_named_sheet();
|
||||
let _ = app.on_plot_dialog_open();
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
|
||||
assert!(!app.plot_dialog.to_file);
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||||
let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap();
|
||||
assert_eq!(ps.printer_name, "OCS Test Printer");
|
||||
// Reopening resolves the stored name back to the same printer.
|
||||
let _ = app.on_plot_dialog_open();
|
||||
assert_eq!(app.plot_dialog.printer.as_deref(), Some("OCS Test Printer"));
|
||||
// A media answer for a printer that is no longer selected is ignored.
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
|
||||
let caps = std::sync::Arc::new(crate::io::plot_device::PrinterCapabilities::default());
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterMedia("OCS Test Printer".into(), Some(caps)));
|
||||
assert!(app.plot_dialog.printer_media.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_sheet_is_added_selected_persisted_and_written_with_its_margins() {
|
||||
use crate::ui::window::plot::{CustomPaperMsg as C, MarginSide};
|
||||
let mut app = app_with_printer_named_sheet();
|
||||
let _ = app.on_plot_dialog_open();
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
|
||||
let custom = |m: C| PlotDlgMsg::CustomPaper(m);
|
||||
let _ = app.on_plot_dlg(custom(C::Open));
|
||||
// Seeded from the selected ISO A4 on the PDF driver, nameless.
|
||||
let draft = app.plot_dialog.custom_editor.clone().expect("editor open");
|
||||
assert_eq!((draft.width.as_str(), draft.height.as_str()), ("210", "297"));
|
||||
assert_eq!(draft.margins, ["5", "17", "6", "18"]);
|
||||
assert!(draft.name.is_empty());
|
||||
let _ = app.on_plot_dlg(custom(C::Name("Roll 24".into())));
|
||||
let _ = app.on_plot_dlg(custom(C::Width("1500".into())));
|
||||
let _ = app.on_plot_dlg(custom(C::Height("609.6".into())));
|
||||
let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Left, "3".into())));
|
||||
let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Top, "4".into())));
|
||||
let _ = app.on_plot_dlg(custom(C::Add));
|
||||
assert!(app.plot_dialog.custom_editor.is_none(), "Add closes the editor");
|
||||
assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)");
|
||||
assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (1500.0, 609.6));
|
||||
assert_eq!(app.current_config().plot.custom_papers.len(), 1, "persisted in the plot config");
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||||
let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap();
|
||||
assert_eq!(ps.paper_size, "Roll_24_(609.60_x_1500.00_MM)");
|
||||
assert_eq!((ps.paper_width, ps.paper_height), (1500.0, 609.6));
|
||||
assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(3.0, 17.0, 6.0, 4.0));
|
||||
// Re-adding the same size replaces the definition instead of duplicating it.
|
||||
let _ = app.on_plot_dlg(custom(C::Open));
|
||||
assert_eq!(app.plot_dialog.custom_editor.as_ref().map(|d| d.name.as_str()), Some("Roll 24"));
|
||||
let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Left, "9".into())));
|
||||
let _ = app.on_plot_dlg(custom(C::Add));
|
||||
assert_eq!(app.plot_dialog.custom_papers.len(), 1);
|
||||
assert_eq!(app.plot_dialog.custom_papers[0].margins.left, 9.0);
|
||||
// Remove drops the definition; the sheet itself stays selectable.
|
||||
let _ = app.on_plot_dlg(custom(C::Remove));
|
||||
assert!(app.plot_dialog.custom_papers.is_empty());
|
||||
assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_custom_sheet_is_refused_with_a_reason() {
|
||||
use crate::ui::window::plot::{CustomPaperError, CustomPaperMsg as C};
|
||||
let mut app = app_with_printer_named_sheet();
|
||||
let _ = app.on_plot_dialog_open();
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Open));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Width("-5".into())));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Add));
|
||||
let draft = app.plot_dialog.custom_editor.as_ref().expect("editor stays open");
|
||||
assert_eq!(draft.error, Some(CustomPaperError::Size));
|
||||
assert!(app.plot_dialog.custom_papers.is_empty());
|
||||
// Typing again clears the message.
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Width("500".into())));
|
||||
assert!(app.plot_dialog.custom_editor.as_ref().unwrap().error.is_none());
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Cancel));
|
||||
assert!(app.plot_dialog.custom_editor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driver_options_are_edited_remembered_and_sent_with_the_job() {
|
||||
use crate::io::print_to_printer::parse_lpoptions;
|
||||
const LPOPTIONS: &str = "MediaType/Media Type: *Stationery Photographic\n\
|
||||
ColorModel/Print Color Mode: *RGB Gray\n\
|
||||
cupsPrintQuality/Print Quality: *Normal High\n";
|
||||
let mut app = app_with_printer_named_sheet();
|
||||
let _ = app.on_plot_dialog_open();
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties);
|
||||
let draft = app.plot_dialog.printer_editor.as_ref().expect("editor opens");
|
||||
assert_eq!(draft.printer, "OCS Test Printer");
|
||||
assert!(draft.options.is_none(), "options load in the background");
|
||||
// An answer for another printer is ignored; the right one fills the editor.
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
|
||||
"Other".into(),
|
||||
Ok(parse_lpoptions(LPOPTIONS)),
|
||||
));
|
||||
assert!(app.plot_dialog.printer_editor.as_ref().unwrap().options.is_none());
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
|
||||
"OCS Test Printer".into(),
|
||||
Ok(parse_lpoptions(LPOPTIONS)),
|
||||
));
|
||||
let draft = app.plot_dialog.printer_editor.as_ref().unwrap();
|
||||
assert_eq!(draft.options.as_ref().map(|o| o.len()), Some(3));
|
||||
assert_eq!(draft.choices["ColorModel"], "RGB");
|
||||
assert!(draft.overrides().is_empty(), "defaults are not overrides");
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionSet("ColorModel".into(), "Gray".into()));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionSet("cupsPrintQuality".into(), "High".into()));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsApply);
|
||||
assert!(app.plot_dialog.printer_editor.is_none(), "Apply closes the editor");
|
||||
let remembered = &app.plot_dialog.driver_options["OCS Test Printer"];
|
||||
assert_eq!(remembered.len(), 2);
|
||||
assert_eq!(remembered["ColorModel"], "Gray");
|
||||
// The job carries exactly those overrides…
|
||||
let opts = app.plot_print_options(&app.plot_dialog);
|
||||
assert_eq!(
|
||||
opts.driver_options,
|
||||
vec![("ColorModel".to_string(), "Gray".to_string()), ("cupsPrintQuality".to_string(), "High".to_string())]
|
||||
);
|
||||
// …they survive in the config, and another printer gets none of them.
|
||||
assert_eq!(app.current_config().plot.driver_options.len(), 1);
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("Another".into()));
|
||||
assert!(app.plot_print_options(&app.plot_dialog).driver_options.is_empty());
|
||||
// Reopening the editor starts from the remembered choices; Reset then
|
||||
// Apply forgets them.
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties);
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
|
||||
"OCS Test Printer".into(),
|
||||
Ok(parse_lpoptions(LPOPTIONS)),
|
||||
));
|
||||
assert_eq!(app.plot_dialog.printer_editor.as_ref().unwrap().choices["ColorModel"], "Gray");
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsReset);
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsApply);
|
||||
assert!(app.plot_dialog.driver_options.get("OCS Test Printer").is_none());
|
||||
// A failed listing is reported inside the editor, not lost.
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties);
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
|
||||
"OCS Test Printer".into(),
|
||||
Err("no cups".into()),
|
||||
));
|
||||
assert_eq!(app.plot_dialog.printer_editor.as_ref().unwrap().error.as_deref(), Some("no cups"));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsCancel);
|
||||
assert!(app.plot_dialog.printer_editor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_sheet_from_a_drawing_keeps_its_own_dimensions() {
|
||||
let mut app = app_with_printer_named_sheet();
|
||||
let i = app.active_tab;
|
||||
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
ps.paper_size = "Roll_24_(609.60_x_1500.00_MM)".into();
|
||||
ps.paper_width = 1500.0;
|
||||
ps.paper_height = 609.6;
|
||||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||||
let _ = app.on_plot_dialog_open();
|
||||
assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)");
|
||||
assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (1500.0, 609.6));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
|
||||
assert_eq!(layout_paper(&app).0, "Roll_24_(609.60_x_1500.00_MM)");
|
||||
}
|
||||
}
|
||||
@@ -8512,14 +8512,6 @@ impl OpenCADStudio {
|
||||
Message::PlotExportPath(None) => Task::none(),
|
||||
Message::PlotExportPath(Some(path)) => self.on_plot_export_path_some(path),
|
||||
|
||||
Message::PlotFormat(f) => {
|
||||
self.plot_format = f;
|
||||
Task::none()
|
||||
}
|
||||
Message::PlotOrientation(o) => {
|
||||
self.plot_orientation = o;
|
||||
Task::none()
|
||||
}
|
||||
Message::PlotWindowExport => {
|
||||
let i = self.active_tab;
|
||||
let stem = self.tabs[i]
|
||||
|
||||
@@ -62,27 +62,6 @@ pub(super) fn layout_entry_name(s: &str) -> &str {
|
||||
s.trim_start_matches('*').trim_end_matches('*')
|
||||
}
|
||||
|
||||
/// Infer an A-series label when dimensions match; otherwise retain the size.
|
||||
pub(super) fn paper_label_from_dims(w: f64, h: f64) -> (String, String) {
|
||||
use crate::io::paper_sizes::PaperSize;
|
||||
let orient = if w >= h { "Landscape" } else { "Portrait" };
|
||||
let (short, long) = if w <= h { (w, h) } else { (h, w) };
|
||||
let mut best = ("A4".to_string(), f64::INFINITY);
|
||||
for p in PaperSize::ALL {
|
||||
let (pw, ph) = p.dimensions_mm(); // portrait: pw < ph
|
||||
let err = (pw - short).abs() + (ph - long).abs();
|
||||
if err < best.1 {
|
||||
best = (p.label().to_string(), err);
|
||||
}
|
||||
}
|
||||
let label = if best.1 <= 2.0 {
|
||||
best.0
|
||||
} else {
|
||||
format!("{w:.2} × {h:.2} mm")
|
||||
};
|
||||
(label, orient.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn parse_plot_scale(s: &str) -> (f64, f64) {
|
||||
if s == "Fit" {
|
||||
return (1.0, 1.0);
|
||||
|
||||
@@ -64,8 +64,8 @@ impl OpenCADStudio {
|
||||
) -> Element<'s, Message> {
|
||||
sized_flow(
|
||||
extra,
|
||||
760,
|
||||
540,
|
||||
940,
|
||||
620,
|
||||
|flow| {
|
||||
crate::ui::window::plot::view_window(
|
||||
&self.plot_dialog,
|
||||
|
||||
+2
-1
@@ -20,7 +20,8 @@ pub mod xref_model;
|
||||
pub mod linetypes;
|
||||
pub mod patterns;
|
||||
pub mod update_check;
|
||||
pub mod paper_sizes;
|
||||
pub mod paper_catalog;
|
||||
pub mod plot_device;
|
||||
pub mod thumbnail;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web_worker;
|
||||
|
||||
@@ -0,0 +1,867 @@
|
||||
//! Paper catalogue with the source application-compatible media names.
|
||||
//!
|
||||
//! the source application stores a layout's paper as the plotter driver's *canonical media
|
||||
//! name* — `ISO_A4_(210.00_x_297.00_MM)`, `ANSI_B_(11.00_x_17.00_Inches)`,
|
||||
//! `ARCH_full_bleed_D_(24.00_x_36.00_Inches)` — always with the portrait
|
||||
//! dimensions; landscape is expressed through the plot rotation. A drawing
|
||||
//! saved here has to carry those exact names so the source application finds the media in its
|
||||
//! `DWG To PDF.pc3` list instead of silently falling back to a default sheet,
|
||||
//! and a name that arrives from the source application (including a custom size this catalogue
|
||||
//! has never seen) must still resolve to its dimensions and go back out
|
||||
//! untouched. The name itself encodes the dimensions, which is what makes the
|
||||
//! second half possible: [`PaperSize::parse_canonical`] never needs a table.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Unit the media is defined in. the source application keeps ISO/JIS sheets in millimetres
|
||||
/// and ANSI/ARCH sheets in inches, and the unit is part of the media name.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum PaperUnits {
|
||||
Millimeters,
|
||||
Inches,
|
||||
}
|
||||
|
||||
impl PaperUnits {
|
||||
pub const MM_PER_INCH: f64 = 25.4;
|
||||
|
||||
pub fn to_mm(self, value: f64) -> f64 {
|
||||
match self {
|
||||
PaperUnits::Millimeters => value,
|
||||
PaperUnits::Inches => value * Self::MM_PER_INCH,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_mm(self, value: f64) -> f64 {
|
||||
match self {
|
||||
PaperUnits::Millimeters => value,
|
||||
PaperUnits::Inches => value / Self::MM_PER_INCH,
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit token the source application writes inside the media name.
|
||||
fn canonical_suffix(self) -> &'static str {
|
||||
match self {
|
||||
PaperUnits::Millimeters => "MM",
|
||||
PaperUnits::Inches => "Inches",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_canonical_suffix(token: &str) -> Option<Self> {
|
||||
if token.eq_ignore_ascii_case("MM") {
|
||||
Some(PaperUnits::Millimeters)
|
||||
} else if token.eq_ignore_ascii_case("Inches") || token.eq_ignore_ascii_case("Inch") {
|
||||
Some(PaperUnits::Inches)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Short unit label for display (`mm` / `in`).
|
||||
pub fn short_label(self) -> &'static str {
|
||||
match self {
|
||||
PaperUnits::Millimeters => "mm",
|
||||
PaperUnits::Inches => "in",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet family, used to group the picker and to prefer a standard sheet
|
||||
/// over its full-bleed twin when matching by dimensions.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
|
||||
pub enum PaperSeries {
|
||||
IsoA,
|
||||
IsoB,
|
||||
Ansi,
|
||||
Arch,
|
||||
Jis,
|
||||
/// Letter / Legal / Tabloid under their US names, and any user-defined sheet.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// the source application offers three printable-area variants of most sheets: the standard
|
||||
/// one with driver margins, *full bleed* with zero margins, and *expand*
|
||||
/// (the driver's maximum printable area). The sheet dimensions are identical;
|
||||
/// only the media name and the margins differ.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
|
||||
pub enum PaperVariant {
|
||||
Standard,
|
||||
FullBleed,
|
||||
Expand,
|
||||
}
|
||||
|
||||
impl PaperVariant {
|
||||
/// The infix the source application places between the series and the sheet letter.
|
||||
fn canonical_infix(self) -> &'static str {
|
||||
match self {
|
||||
PaperVariant::Standard => "",
|
||||
PaperVariant::FullBleed => "full_bleed_",
|
||||
PaperVariant::Expand => "expand_",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum Orientation {
|
||||
Portrait,
|
||||
Landscape,
|
||||
}
|
||||
|
||||
/// Unprintable margins of a sheet, in the order the source application stores them. The unit
|
||||
/// is whatever the context says — millimetres in a drawing, the sheet's own
|
||||
/// unit in a user-defined size.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Margins {
|
||||
pub left: f64,
|
||||
pub bottom: f64,
|
||||
pub right: f64,
|
||||
pub top: f64,
|
||||
}
|
||||
|
||||
impl Margins {
|
||||
pub const ZERO: Margins = Margins {
|
||||
left: 0.0,
|
||||
bottom: 0.0,
|
||||
right: 0.0,
|
||||
top: 0.0,
|
||||
};
|
||||
|
||||
pub const fn uniform(value: f64) -> Self {
|
||||
Margins {
|
||||
left: value,
|
||||
bottom: value,
|
||||
right: value,
|
||||
top: value,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same margins expressed in millimetres when they were given in
|
||||
/// `units`.
|
||||
pub fn to_mm(self, units: PaperUnits) -> Self {
|
||||
Margins {
|
||||
left: units.to_mm(self.left),
|
||||
bottom: units.to_mm(self.bottom),
|
||||
right: units.to_mm(self.right),
|
||||
top: units.to_mm(self.top),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet the user defined, kept in the application settings the way
|
||||
/// the source application keeps custom sizes in a plotter configuration: a name, the
|
||||
/// dimensions in the sheet's own unit, and the printable margins.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CustomPaper {
|
||||
/// Human name, e.g. `Roll 24`; becomes `Roll_24_(…)` in the media name.
|
||||
pub name: String,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub units: PaperUnits,
|
||||
/// Unprintable margins in `units`.
|
||||
pub margins: Margins,
|
||||
}
|
||||
|
||||
impl Default for CustomPaper {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
units: PaperUnits::Millimeters,
|
||||
margins: Margins::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CustomPaper {
|
||||
/// The catalogue sheet this definition describes.
|
||||
pub fn paper(&self) -> PaperSize {
|
||||
PaperSize::custom_named(&self.name, self.width, self.height, self.units)
|
||||
}
|
||||
|
||||
pub fn canonical(&self) -> String {
|
||||
self.paper().canonical.into_owned()
|
||||
}
|
||||
|
||||
pub fn margins_mm(&self) -> Margins {
|
||||
self.margins.to_mm(self.units)
|
||||
}
|
||||
}
|
||||
|
||||
/// One sheet of the catalogue, or a sheet reconstructed from a media name.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct PaperSize {
|
||||
/// the source application canonical media name, e.g. `ISO_A4_(210.00_x_297.00_MM)`. This is
|
||||
/// the value written to `PlotSettings::paper_size` and the key the plot
|
||||
/// dialog stores.
|
||||
pub canonical: Cow<'static, str>,
|
||||
/// Human name without dimensions: `ISO A4`, `ANSI full bleed B`.
|
||||
pub label: Cow<'static, str>,
|
||||
pub series: PaperSeries,
|
||||
pub variant: PaperVariant,
|
||||
/// Portrait width in `units` (width ≤ height).
|
||||
pub width: f64,
|
||||
/// Portrait height in `units`.
|
||||
pub height: f64,
|
||||
pub units: PaperUnits,
|
||||
}
|
||||
|
||||
impl PaperSize {
|
||||
/// Portrait dimensions in millimetres.
|
||||
pub fn portrait_mm(&self) -> (f64, f64) {
|
||||
(self.units.to_mm(self.width), self.units.to_mm(self.height))
|
||||
}
|
||||
|
||||
/// Sheet dimensions in millimetres for the given orientation.
|
||||
pub fn sheet_mm(&self, orientation: Orientation) -> (f64, f64) {
|
||||
let (w, h) = self.portrait_mm();
|
||||
match orientation {
|
||||
Orientation::Portrait => (w, h),
|
||||
Orientation::Landscape => (h, w),
|
||||
}
|
||||
}
|
||||
|
||||
/// Picker text: the human name followed by the dimensions in the sheet's
|
||||
/// own unit — `ISO A4 (210 × 297 mm)`, `ANSI B (11.00 × 17.00 in)`.
|
||||
pub fn display(&self) -> String {
|
||||
format!(
|
||||
"{} ({} × {} {})",
|
||||
self.label,
|
||||
format_dimension(self.width, self.units),
|
||||
format_dimension(self.height, self.units),
|
||||
self.units.short_label()
|
||||
)
|
||||
}
|
||||
|
||||
/// A user-defined sheet, named the way the source application names custom media created
|
||||
/// in the plot dialog (`UserDefinedMetric_(420.00_x_297.00_MM)`). The
|
||||
/// dimensions are normalised to portrait so the name stays canonical.
|
||||
pub fn custom(width: f64, height: f64, units: PaperUnits) -> Self {
|
||||
let label = match units {
|
||||
PaperUnits::Millimeters => "UserDefinedMetric",
|
||||
PaperUnits::Inches => "UserDefinedInches",
|
||||
};
|
||||
Self::custom_named(label, width, height, units)
|
||||
}
|
||||
|
||||
/// A user-defined sheet under its own name, the way a plotter
|
||||
/// configuration's custom sizes are named (`Roll_24_(609.60_x_1500.00_MM)`).
|
||||
/// A blank name falls back to the source application's generic one; spaces become
|
||||
/// underscores in the media name and parentheses are dropped so the name
|
||||
/// stays parseable.
|
||||
pub fn custom_named(name: &str, width: f64, height: f64, units: PaperUnits) -> Self {
|
||||
let (width, height) = if width <= height {
|
||||
(width, height)
|
||||
} else {
|
||||
(height, width)
|
||||
};
|
||||
let cleaned: String = name
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| !matches!(c, '(' | ')'))
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if cleaned.is_empty() {
|
||||
return Self::custom(width, height, units);
|
||||
}
|
||||
Self {
|
||||
canonical: Cow::Owned(canonical_name(
|
||||
&cleaned.replace(' ', "_"),
|
||||
width,
|
||||
height,
|
||||
units,
|
||||
)),
|
||||
label: Cow::Owned(cleaned),
|
||||
series: PaperSeries::Other,
|
||||
variant: PaperVariant::Standard,
|
||||
width,
|
||||
height,
|
||||
units,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild a sheet from any media name that follows the source application's
|
||||
/// `<name>_(<width>_x_<height>_<MM|Inches>)` convention, whether or not the
|
||||
/// catalogue knows it. The dialog form with spaces (`ISO A4 (210.00 x
|
||||
/// 297.00 MM)`) is accepted too. Returns `None` for names without an
|
||||
/// embedded dimension group (a bare printer paper name such as `A4`, or a
|
||||
/// raster `Pixels` size).
|
||||
pub fn parse_canonical(name: &str) -> Option<Self> {
|
||||
let name = name.trim();
|
||||
let open = name.rfind('(')?;
|
||||
let close = name[open..].find(')')? + open;
|
||||
let dims = name[open + 1..close].trim_matches(['_', ' ']);
|
||||
let raw_label = name[..open].trim_matches(['_', ' ']);
|
||||
if raw_label.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// `210.00_x_297.00_MM`, or `210.00 x 297.00 MM` from a dialog string.
|
||||
let normalized: String = dims.replace(' ', "_");
|
||||
let mut parts = normalized.split('_').filter(|part| !part.is_empty());
|
||||
let width: f64 = parts.next()?.parse().ok()?;
|
||||
if !parts.next()?.eq_ignore_ascii_case("x") {
|
||||
return None;
|
||||
}
|
||||
let height: f64 = parts.next()?.parse().ok()?;
|
||||
let units = PaperUnits::from_canonical_suffix(parts.next()?)?;
|
||||
if parts.next().is_some()
|
||||
|| width <= 0.0
|
||||
|| height <= 0.0
|
||||
|| !width.is_finite()
|
||||
|| !height.is_finite()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let label = raw_label.replace('_', " ");
|
||||
let (series, variant) = classify_label(&label);
|
||||
let (portrait_width, portrait_height) = if width <= height {
|
||||
(width, height)
|
||||
} else {
|
||||
(height, width)
|
||||
};
|
||||
Some(Self {
|
||||
canonical: Cow::Owned(canonical_name(
|
||||
&raw_label.replace(' ', "_"),
|
||||
width,
|
||||
height,
|
||||
units,
|
||||
)),
|
||||
label: Cow::Owned(label),
|
||||
series,
|
||||
variant,
|
||||
width: portrait_width,
|
||||
height: portrait_height,
|
||||
units,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn format_dimension(value: f64, units: PaperUnits) -> String {
|
||||
match units {
|
||||
// Millimetre sheets are whole numbers in every standard; keep the
|
||||
// fraction only when a custom size needs it.
|
||||
PaperUnits::Millimeters if (value - value.round()).abs() < 1e-6 => {
|
||||
format!("{}", value.round() as i64)
|
||||
}
|
||||
PaperUnits::Millimeters => format!("{value:.1}"),
|
||||
PaperUnits::Inches => format!("{value:.2}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<prefix>_(<w>_x_<h>_<units>)` exactly as the source application spells it: two decimals,
|
||||
/// portrait order.
|
||||
fn canonical_name(prefix: &str, width: f64, height: f64, units: PaperUnits) -> String {
|
||||
format!(
|
||||
"{prefix}_({width:.2}_x_{height:.2}_{})",
|
||||
units.canonical_suffix()
|
||||
)
|
||||
}
|
||||
|
||||
/// Series and variant from a human label (`ISO full bleed A4`, `ARCH expand D`).
|
||||
fn classify_label(label: &str) -> (PaperSeries, PaperVariant) {
|
||||
let lower = label.to_ascii_lowercase();
|
||||
let variant = if lower.contains("full bleed") {
|
||||
PaperVariant::FullBleed
|
||||
} else if lower.contains("expand") {
|
||||
PaperVariant::Expand
|
||||
} else {
|
||||
PaperVariant::Standard
|
||||
};
|
||||
let series = if lower.starts_with("iso b") {
|
||||
PaperSeries::IsoB
|
||||
} else if lower.starts_with("iso") {
|
||||
PaperSeries::IsoA
|
||||
} else if lower.starts_with("ansi") {
|
||||
PaperSeries::Ansi
|
||||
} else if lower.starts_with("arch") {
|
||||
PaperSeries::Arch
|
||||
} else if lower.starts_with("jis") {
|
||||
PaperSeries::Jis
|
||||
} else {
|
||||
PaperSeries::Other
|
||||
};
|
||||
(series, variant)
|
||||
}
|
||||
|
||||
// ── Catalogue ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Compact source row: series prefix as the source application writes it, sheet letter,
|
||||
/// portrait width/height, units, and whether the source application ships full-bleed /
|
||||
/// expand twins of it in `DWG To PDF.pc3`.
|
||||
struct Row {
|
||||
series: PaperSeries,
|
||||
prefix: &'static str,
|
||||
sheet: &'static str,
|
||||
width: f64,
|
||||
height: f64,
|
||||
units: PaperUnits,
|
||||
twins: bool,
|
||||
}
|
||||
|
||||
const fn mm(
|
||||
series: PaperSeries,
|
||||
prefix: &'static str,
|
||||
sheet: &'static str,
|
||||
w: f64,
|
||||
h: f64,
|
||||
twins: bool,
|
||||
) -> Row {
|
||||
Row {
|
||||
series,
|
||||
prefix,
|
||||
sheet,
|
||||
width: w,
|
||||
height: h,
|
||||
units: PaperUnits::Millimeters,
|
||||
twins,
|
||||
}
|
||||
}
|
||||
|
||||
const fn inch(
|
||||
series: PaperSeries,
|
||||
prefix: &'static str,
|
||||
sheet: &'static str,
|
||||
w: f64,
|
||||
h: f64,
|
||||
twins: bool,
|
||||
) -> Row {
|
||||
Row {
|
||||
series,
|
||||
prefix,
|
||||
sheet,
|
||||
width: w,
|
||||
height: h,
|
||||
units: PaperUnits::Inches,
|
||||
twins,
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheets the source application's `DWG To PDF.pc3` offers, in its display order, plus
|
||||
/// the ISO B / JIS B series that other drivers add and the US names system
|
||||
/// printers report.
|
||||
const ROWS: &[Row] = &[
|
||||
mm(PaperSeries::IsoA, "ISO", "A0", 841.0, 1189.0, true),
|
||||
mm(PaperSeries::IsoA, "ISO", "A1", 594.0, 841.0, true),
|
||||
mm(PaperSeries::IsoA, "ISO", "A2", 420.0, 594.0, true),
|
||||
mm(PaperSeries::IsoA, "ISO", "A3", 297.0, 420.0, true),
|
||||
mm(PaperSeries::IsoA, "ISO", "A4", 210.0, 297.0, true),
|
||||
mm(PaperSeries::IsoA, "ISO", "A5", 148.0, 210.0, false),
|
||||
mm(PaperSeries::IsoB, "ISO", "B0", 1000.0, 1414.0, false),
|
||||
mm(PaperSeries::IsoB, "ISO", "B1", 707.0, 1000.0, false),
|
||||
mm(PaperSeries::IsoB, "ISO", "B2", 500.0, 707.0, false),
|
||||
mm(PaperSeries::IsoB, "ISO", "B3", 353.0, 500.0, false),
|
||||
mm(PaperSeries::IsoB, "ISO", "B4", 250.0, 353.0, false),
|
||||
mm(PaperSeries::IsoB, "ISO", "B5", 176.0, 250.0, false),
|
||||
inch(PaperSeries::Ansi, "ANSI", "A", 8.5, 11.0, true),
|
||||
inch(PaperSeries::Ansi, "ANSI", "B", 11.0, 17.0, true),
|
||||
inch(PaperSeries::Ansi, "ANSI", "C", 17.0, 22.0, true),
|
||||
inch(PaperSeries::Ansi, "ANSI", "D", 22.0, 34.0, true),
|
||||
inch(PaperSeries::Ansi, "ANSI", "E", 34.0, 44.0, true),
|
||||
inch(PaperSeries::Arch, "ARCH", "A", 9.0, 12.0, true),
|
||||
inch(PaperSeries::Arch, "ARCH", "B", 12.0, 18.0, true),
|
||||
inch(PaperSeries::Arch, "ARCH", "C", 18.0, 24.0, true),
|
||||
inch(PaperSeries::Arch, "ARCH", "D", 24.0, 36.0, true),
|
||||
inch(PaperSeries::Arch, "ARCH", "E", 36.0, 48.0, true),
|
||||
inch(PaperSeries::Arch, "ARCH", "E1", 30.0, 42.0, true),
|
||||
mm(PaperSeries::Jis, "JIS", "B0", 1030.0, 1456.0, false),
|
||||
mm(PaperSeries::Jis, "JIS", "B1", 728.0, 1030.0, false),
|
||||
mm(PaperSeries::Jis, "JIS", "B2", 515.0, 728.0, false),
|
||||
mm(PaperSeries::Jis, "JIS", "B3", 364.0, 515.0, false),
|
||||
mm(PaperSeries::Jis, "JIS", "B4", 257.0, 364.0, false),
|
||||
mm(PaperSeries::Jis, "JIS", "B5", 182.0, 257.0, false),
|
||||
inch(PaperSeries::Other, "Letter", "", 8.5, 11.0, false),
|
||||
inch(PaperSeries::Other, "Legal", "", 8.5, 14.0, false),
|
||||
inch(PaperSeries::Other, "Tabloid", "", 11.0, 17.0, false),
|
||||
];
|
||||
|
||||
fn build_catalog() -> Vec<PaperSize> {
|
||||
let mut sizes = Vec::with_capacity(ROWS.len() * 3);
|
||||
for row in ROWS {
|
||||
let variants: &[PaperVariant] = if row.twins {
|
||||
&[
|
||||
PaperVariant::Standard,
|
||||
PaperVariant::FullBleed,
|
||||
PaperVariant::Expand,
|
||||
]
|
||||
} else {
|
||||
&[PaperVariant::Standard]
|
||||
};
|
||||
for &variant in variants {
|
||||
// `ISO_full_bleed_A4`, `ANSI_expand_B`, `Letter`.
|
||||
let prefix = if row.sheet.is_empty() {
|
||||
row.prefix.to_string()
|
||||
} else {
|
||||
format!("{}_{}{}", row.prefix, variant.canonical_infix(), row.sheet)
|
||||
};
|
||||
sizes.push(PaperSize {
|
||||
canonical: Cow::Owned(canonical_name(&prefix, row.width, row.height, row.units)),
|
||||
label: Cow::Owned(prefix.replace('_', " ")),
|
||||
series: row.series,
|
||||
variant,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
units: row.units,
|
||||
});
|
||||
}
|
||||
}
|
||||
sizes
|
||||
}
|
||||
|
||||
/// Every catalogued sheet in picker order.
|
||||
pub fn catalog() -> &'static [PaperSize] {
|
||||
static CATALOG: OnceLock<Vec<PaperSize>> = OnceLock::new();
|
||||
CATALOG.get_or_init(build_catalog)
|
||||
}
|
||||
|
||||
/// The sheet a fresh drawing or a page setup without one falls back to.
|
||||
pub fn default_paper() -> &'static PaperSize {
|
||||
catalog()
|
||||
.iter()
|
||||
.find(|paper| paper.canonical == "ISO_A4_(210.00_x_297.00_MM)")
|
||||
.expect("catalogue always carries ISO A4")
|
||||
}
|
||||
|
||||
/// Short names a system printer driver, an older Open CAD Studio config or a
|
||||
/// user at the command line may spell a sheet with, mapped onto the canonical
|
||||
/// sheet. `Ledger` is the landscape name of Tabloid; the sheet is the same.
|
||||
const ALIASES: &[(&str, &str)] = &[
|
||||
("A0", "ISO_A0_(841.00_x_1189.00_MM)"),
|
||||
("A1", "ISO_A1_(594.00_x_841.00_MM)"),
|
||||
("A2", "ISO_A2_(420.00_x_594.00_MM)"),
|
||||
("A3", "ISO_A3_(297.00_x_420.00_MM)"),
|
||||
("A4", "ISO_A4_(210.00_x_297.00_MM)"),
|
||||
("A5", "ISO_A5_(148.00_x_210.00_MM)"),
|
||||
("B4", "ISO_B4_(250.00_x_353.00_MM)"),
|
||||
("B5", "ISO_B5_(176.00_x_250.00_MM)"),
|
||||
("Letter", "Letter_(8.50_x_11.00_Inches)"),
|
||||
("US Letter", "Letter_(8.50_x_11.00_Inches)"),
|
||||
("Legal", "Legal_(8.50_x_14.00_Inches)"),
|
||||
("US Legal", "Legal_(8.50_x_14.00_Inches)"),
|
||||
("Tabloid", "Tabloid_(11.00_x_17.00_Inches)"),
|
||||
("Ledger", "Tabloid_(11.00_x_17.00_Inches)"),
|
||||
];
|
||||
|
||||
/// Resolve any spelling of a sheet: an exact canonical name (case-insensitive,
|
||||
/// underscores or spaces), a driver/config alias such as `A4` or `Letter`, or
|
||||
/// an unknown but well-formed the source application name, which is reconstructed from its
|
||||
/// embedded dimensions. `None` only for names that carry no size at all.
|
||||
pub fn resolve(name: &str) -> Option<PaperSize> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let spaced = name.replace('_', " ");
|
||||
if let Some(paper) = catalog().iter().find(|paper| {
|
||||
paper
|
||||
.canonical
|
||||
.replace('_', " ")
|
||||
.eq_ignore_ascii_case(&spaced)
|
||||
}) {
|
||||
return Some(paper.clone());
|
||||
}
|
||||
if let Some((_, canonical)) = ALIASES
|
||||
.iter()
|
||||
.find(|(alias, _)| alias.eq_ignore_ascii_case(name))
|
||||
{
|
||||
return catalog()
|
||||
.iter()
|
||||
.find(|paper| paper.canonical == *canonical)
|
||||
.cloned();
|
||||
}
|
||||
PaperSize::parse_canonical(name)
|
||||
}
|
||||
|
||||
/// The catalogued sheet whose portrait size matches `(w, h)` in millimetres
|
||||
/// (either order) within half a millimetre. Standard variants win over their
|
||||
/// full-bleed / expand twins so a size read from a drawing without a usable
|
||||
/// name lands on the plain sheet.
|
||||
pub fn match_dimensions_mm(w: f64, h: f64) -> Option<&'static PaperSize> {
|
||||
const TOLERANCE_MM: f64 = 0.5;
|
||||
let (short, long) = if w <= h { (w, h) } else { (h, w) };
|
||||
catalog()
|
||||
.iter()
|
||||
.filter(|paper| paper.variant == PaperVariant::Standard)
|
||||
.filter(|paper| {
|
||||
let (pw, ph) = paper.portrait_mm();
|
||||
(pw - short).abs() <= TOLERANCE_MM && (ph - long).abs() <= TOLERANCE_MM
|
||||
})
|
||||
// ANSI A and Letter share a size; prefer the named series.
|
||||
.min_by_key(|paper| paper.series)
|
||||
}
|
||||
|
||||
/// Sheet for a name as read from a drawing: the catalogued or reconstructed
|
||||
/// sheet when the name carries one, otherwise the catalogued sheet with the
|
||||
/// given dimensions, otherwise a custom sheet built from the dimensions alone.
|
||||
pub fn from_drawing(name: &str, width_mm: f64, height_mm: f64) -> PaperSize {
|
||||
if let Some(paper) = resolve(name) {
|
||||
return paper;
|
||||
}
|
||||
if let Some(paper) = match_dimensions_mm(width_mm, height_mm) {
|
||||
return paper.clone();
|
||||
}
|
||||
if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
|
||||
return default_paper().clone();
|
||||
}
|
||||
PaperSize::custom(width_mm, height_mm, PaperUnits::Millimeters)
|
||||
}
|
||||
|
||||
// ── Plot geometry helpers ─────────────────────────────────────────────────
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Debug)]
|
||||
pub enum PlotScale {
|
||||
/// Scale so the window fills the sheet minus a 5% margin.
|
||||
Fit,
|
||||
/// Exact scale factor (mm per drawing unit).
|
||||
Ratio(f64),
|
||||
}
|
||||
|
||||
/// Map a world-space window onto a sheet. Returns (scale, offset_x, offset_y),
|
||||
/// where the scaled window is centered on the sheet and (offset_x, offset_y) is
|
||||
/// the sheet-mm position of the window's min corner. The caller turns that into
|
||||
/// its own coordinate offset.
|
||||
pub fn window_to_sheet(
|
||||
window_wh: (f64, f64),
|
||||
sheet_mm: (f64, f64),
|
||||
scale: PlotScale,
|
||||
) -> (f64, f64, f64) {
|
||||
let (ww, wh) = (window_wh.0.max(1e-9), window_wh.1.max(1e-9));
|
||||
let scale = match scale {
|
||||
PlotScale::Ratio(r) => r.max(1e-9),
|
||||
PlotScale::Fit => {
|
||||
const MARGIN: f64 = 1.05;
|
||||
let sx = (sheet_mm.0 / MARGIN) / ww;
|
||||
let sy = (sheet_mm.1 / MARGIN) / wh;
|
||||
sx.min(sy)
|
||||
}
|
||||
};
|
||||
let scaled_w = ww * scale;
|
||||
let scaled_h = wh * scale;
|
||||
let offset_x = (sheet_mm.0 - scaled_w) / 2.0;
|
||||
let offset_y = (sheet_mm.1 - scaled_h) / 2.0;
|
||||
(scale, offset_x, offset_y)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn a4() -> PaperSize {
|
||||
resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogue_names_follow_source_app_spelling() {
|
||||
let names: Vec<&str> = catalog().iter().map(|p| p.canonical.as_ref()).collect();
|
||||
for expected in [
|
||||
"ISO_A4_(210.00_x_297.00_MM)",
|
||||
"ISO_full_bleed_A4_(210.00_x_297.00_MM)",
|
||||
"ISO_expand_A0_(841.00_x_1189.00_MM)",
|
||||
"ISO_B4_(250.00_x_353.00_MM)",
|
||||
"ANSI_A_(8.50_x_11.00_Inches)",
|
||||
"ANSI_full_bleed_B_(11.00_x_17.00_Inches)",
|
||||
"ARCH_D_(24.00_x_36.00_Inches)",
|
||||
"ARCH_expand_E1_(30.00_x_42.00_Inches)",
|
||||
"JIS_B4_(257.00_x_364.00_MM)",
|
||||
"Letter_(8.50_x_11.00_Inches)",
|
||||
"Legal_(8.50_x_14.00_Inches)",
|
||||
"Tabloid_(11.00_x_17.00_Inches)",
|
||||
] {
|
||||
assert!(names.contains(&expected), "missing {expected}");
|
||||
}
|
||||
// Every name is portrait and unique.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for paper in catalog() {
|
||||
assert!(
|
||||
paper.width <= paper.height,
|
||||
"{} is not portrait",
|
||||
paper.canonical
|
||||
);
|
||||
assert!(
|
||||
seen.insert(paper.canonical.clone()),
|
||||
"duplicate {}",
|
||||
paper.canonical
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_catalogue_name_round_trips_through_the_parser() {
|
||||
for paper in catalog() {
|
||||
let parsed = PaperSize::parse_canonical(&paper.canonical)
|
||||
.unwrap_or_else(|| panic!("cannot parse {}", paper.canonical));
|
||||
assert_eq!(parsed.canonical, paper.canonical);
|
||||
assert_eq!(parsed.label, paper.label);
|
||||
assert_eq!(parsed.series, paper.series);
|
||||
assert_eq!(parsed.variant, paper.variant);
|
||||
assert_eq!(parsed.units, paper.units);
|
||||
assert!((parsed.width - paper.width).abs() < 1e-9);
|
||||
assert!((parsed.height - paper.height).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_canonical_names_are_reconstructed_and_preserved() {
|
||||
// A custom size created in a plot dialog.
|
||||
let custom = resolve("UserDefinedMetric_(420.00_x_297.00_MM)").unwrap();
|
||||
assert_eq!(custom.canonical, "UserDefinedMetric_(420.00_x_297.00_MM)");
|
||||
assert_eq!(custom.label, "UserDefinedMetric");
|
||||
assert_eq!(custom.series, PaperSeries::Other);
|
||||
assert_eq!(custom.portrait_mm(), (297.0, 420.0));
|
||||
// A driver sheet the catalogue does not list, in inches.
|
||||
let oversize = resolve("Oversize_A1_(625.00_x_880.00_MM)").unwrap();
|
||||
assert_eq!(oversize.portrait_mm(), (625.0, 880.0));
|
||||
// The dialog spelling with spaces resolves to the same canonical name.
|
||||
let spaced = resolve("ISO full bleed A3 (297.00 x 420.00 MM)").unwrap();
|
||||
assert_eq!(spaced.canonical, "ISO_full_bleed_A3_(297.00_x_420.00_MM)");
|
||||
assert_eq!(spaced.variant, PaperVariant::FullBleed);
|
||||
assert_eq!(spaced.series, PaperSeries::IsoA);
|
||||
// A raster size or a bare driver name carries no usable dimensions.
|
||||
assert!(resolve("Sun_Hi-Res_(1600.00_x_1280.00_Pixels)").is_none());
|
||||
assert!(resolve("A4 Plain").is_none());
|
||||
assert!(resolve("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliases_and_case_insensitive_names_resolve() {
|
||||
assert_eq!(resolve("A4").unwrap().canonical, a4().canonical);
|
||||
assert_eq!(
|
||||
resolve("a3").unwrap().canonical,
|
||||
"ISO_A3_(297.00_x_420.00_MM)"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve("Letter").unwrap().canonical,
|
||||
"Letter_(8.50_x_11.00_Inches)"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve("Ledger").unwrap().canonical,
|
||||
"Tabloid_(11.00_x_17.00_Inches)"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve("iso_a4_(210.00_x_297.00_mm)").unwrap().canonical,
|
||||
a4().canonical
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_match_the_plain_sheet_in_either_orientation() {
|
||||
assert_eq!(
|
||||
match_dimensions_mm(297.0, 210.0).unwrap().canonical,
|
||||
a4().canonical
|
||||
);
|
||||
assert_eq!(
|
||||
match_dimensions_mm(210.0, 297.0).unwrap().canonical,
|
||||
a4().canonical
|
||||
);
|
||||
assert_eq!(
|
||||
match_dimensions_mm(210.3, 297.4).unwrap().canonical,
|
||||
a4().canonical
|
||||
);
|
||||
assert!(match_dimensions_mm(215.0, 297.0).is_none());
|
||||
// 8.5 × 11 in prefers ANSI A over the Letter alias sheet.
|
||||
assert_eq!(
|
||||
match_dimensions_mm(215.9, 279.4).unwrap().canonical,
|
||||
"ANSI_A_(8.50_x_11.00_Inches)"
|
||||
);
|
||||
assert_eq!(
|
||||
match_dimensions_mm(609.6, 914.4).unwrap().canonical,
|
||||
"ARCH_D_(24.00_x_36.00_Inches)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_drawing_falls_back_from_name_to_size_to_custom() {
|
||||
assert_eq!(
|
||||
from_drawing("ISO_A3_(297.00_x_420.00_MM)", 0.0, 0.0).label,
|
||||
"ISO A3"
|
||||
);
|
||||
// A legacy drawing with a bare driver name and A4 dimensions.
|
||||
assert_eq!(
|
||||
from_drawing("Plain paper", 297.0, 210.0).canonical,
|
||||
a4().canonical
|
||||
);
|
||||
let custom = from_drawing("", 300.0, 500.0);
|
||||
assert_eq!(custom.canonical, "UserDefinedMetric_(300.00_x_500.00_MM)");
|
||||
assert_eq!(custom.portrait_mm(), (300.0, 500.0));
|
||||
assert_eq!(from_drawing("", 0.0, f64::INFINITY), *default_paper());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_non_finite_dimensions() {
|
||||
assert!(PaperSize::parse_canonical("Custom_(inf_x_297.00_MM)").is_none());
|
||||
assert!(PaperSize::parse_canonical("Custom_(210.00_x_NaN_MM)").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_and_display_formatting() {
|
||||
assert_eq!(a4().sheet_mm(Orientation::Portrait), (210.0, 297.0));
|
||||
assert_eq!(a4().sheet_mm(Orientation::Landscape), (297.0, 210.0));
|
||||
assert_eq!(a4().display(), "ISO A4 (210 × 297 mm)");
|
||||
let ansi_b = resolve("ANSI_B_(11.00_x_17.00_Inches)").unwrap();
|
||||
assert_eq!(ansi_b.display(), "ANSI B (11.00 × 17.00 in)");
|
||||
let (w, h) = ansi_b.portrait_mm();
|
||||
assert!((w - 279.4).abs() < 1e-9 && (h - 431.8).abs() < 1e-9);
|
||||
assert_eq!(
|
||||
PaperSize::custom(297.5, 210.0, PaperUnits::Millimeters).display(),
|
||||
"UserDefinedMetric (210 × 297.5 mm)"
|
||||
);
|
||||
assert_eq!(default_paper().canonical, a4().canonical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_defined_sheets_carry_their_name_and_margins() {
|
||||
let roll = CustomPaper {
|
||||
name: "Roll 24 (long)".into(),
|
||||
width: 1500.0,
|
||||
height: 609.6,
|
||||
units: PaperUnits::Millimeters,
|
||||
margins: Margins {
|
||||
left: 5.0,
|
||||
bottom: 17.0,
|
||||
right: 6.0,
|
||||
top: 18.0,
|
||||
},
|
||||
};
|
||||
assert_eq!(roll.canonical(), "Roll_24_long_(609.60_x_1500.00_MM)");
|
||||
assert_eq!(roll.paper().label, "Roll 24 long");
|
||||
assert_eq!(roll.paper().display(), "Roll 24 long (609.6 × 1500 mm)");
|
||||
// Round-trips through the parser like any the source application name.
|
||||
assert_eq!(
|
||||
resolve(&roll.canonical()).unwrap().portrait_mm(),
|
||||
(609.6, 1500.0)
|
||||
);
|
||||
let arch = CustomPaper {
|
||||
name: String::new(),
|
||||
width: 30.0,
|
||||
height: 42.0,
|
||||
units: PaperUnits::Inches,
|
||||
margins: Margins::uniform(0.25),
|
||||
};
|
||||
assert_eq!(arch.canonical(), "UserDefinedInches_(30.00_x_42.00_Inches)");
|
||||
assert_eq!(arch.margins_mm(), Margins::uniform(6.35));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_centers_and_scales_within_margin() {
|
||||
// 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width:
|
||||
// usable = 210/1.05 = 200; scale = 200/100 = 2.0.
|
||||
let (s, ox, oy) = window_to_sheet((100.0, 100.0), (210.0, 297.0), PlotScale::Fit);
|
||||
assert!((s - 2.0).abs() < 1e-9, "scale {s}");
|
||||
// Window is 100*2 = 200 wide/tall; centered on 210×297.
|
||||
assert!((ox - (210.0 - 200.0) / 2.0).abs() < 1e-9, "ox {ox}");
|
||||
assert!((oy - (297.0 - 200.0) / 2.0).abs() < 1e-9, "oy {oy}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ratio_applies_exact_scale_and_centers() {
|
||||
// Ratio(0.5): scale is exactly 0.5; the scaled window is centered and
|
||||
// (ox, oy) is the sheet-mm position of its min corner.
|
||||
let (s, ox, oy) = window_to_sheet((100.0, 80.0), (210.0, 297.0), PlotScale::Ratio(0.5));
|
||||
assert!((s - 0.5).abs() < 1e-9);
|
||||
// scaled window = 50×40; centered box origin = ((210-50)/2,(297-40)/2).
|
||||
assert!((ox - (210.0 - 50.0) / 2.0).abs() < 1e-9, "ox {ox}");
|
||||
assert!((oy - (297.0 - 40.0) / 2.0).abs() < 1e-9, "oy {oy}");
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
//! Standard paper sizes and sheet orientation for window plotting.
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum PaperSize {
|
||||
A4,
|
||||
A3,
|
||||
A2,
|
||||
A1,
|
||||
A0,
|
||||
Letter,
|
||||
Legal,
|
||||
Tabloid,
|
||||
}
|
||||
|
||||
impl PaperSize {
|
||||
pub const ALL: [PaperSize; 8] = [
|
||||
PaperSize::A4,
|
||||
PaperSize::A3,
|
||||
PaperSize::A2,
|
||||
PaperSize::A1,
|
||||
PaperSize::A0,
|
||||
PaperSize::Letter,
|
||||
PaperSize::Legal,
|
||||
PaperSize::Tabloid,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PaperSize::A4 => "A4",
|
||||
PaperSize::A3 => "A3",
|
||||
PaperSize::A2 => "A2",
|
||||
PaperSize::A1 => "A1",
|
||||
PaperSize::A0 => "A0",
|
||||
PaperSize::Letter => "Letter",
|
||||
PaperSize::Legal => "Legal",
|
||||
PaperSize::Tabloid => "Tabloid",
|
||||
}
|
||||
}
|
||||
|
||||
/// Portrait dimensions in mm (width, height); width < height.
|
||||
pub fn dimensions_mm(self) -> (f64, f64) {
|
||||
match self {
|
||||
PaperSize::A4 => (210.0, 297.0),
|
||||
PaperSize::A3 => (297.0, 420.0),
|
||||
PaperSize::A2 => (420.0, 594.0),
|
||||
PaperSize::A1 => (594.0, 841.0),
|
||||
PaperSize::A0 => (841.0, 1189.0),
|
||||
PaperSize::Letter => (215.9, 279.4),
|
||||
PaperSize::Legal => (215.9, 355.6),
|
||||
PaperSize::Tabloid => (279.4, 431.8),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum Orientation {
|
||||
Portrait,
|
||||
Landscape,
|
||||
}
|
||||
|
||||
/// Sheet dimensions in mm for the given size and orientation.
|
||||
pub fn sheet_mm(size: PaperSize, o: Orientation) -> (f64, f64) {
|
||||
let (w, h) = size.dimensions_mm();
|
||||
match o {
|
||||
Orientation::Portrait => (w, h),
|
||||
Orientation::Landscape => (h, w),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Debug)]
|
||||
pub enum PlotScale {
|
||||
/// Scale so the window fills the sheet minus a 5% margin.
|
||||
Fit,
|
||||
/// Exact scale factor (mm per drawing unit).
|
||||
Ratio(f64),
|
||||
}
|
||||
|
||||
/// Map a world-space window onto a sheet. Returns (scale, offset_x, offset_y),
|
||||
/// where the scaled window is centered on the sheet and (offset_x, offset_y) is
|
||||
/// the sheet-mm position of the window's min corner. The caller turns that into
|
||||
/// its own coordinate offset.
|
||||
pub fn window_to_sheet(
|
||||
window_wh: (f64, f64),
|
||||
sheet_mm: (f64, f64),
|
||||
scale: PlotScale,
|
||||
) -> (f64, f64, f64) {
|
||||
let (ww, wh) = (window_wh.0.max(1e-9), window_wh.1.max(1e-9));
|
||||
let scale = match scale {
|
||||
PlotScale::Ratio(r) => r.max(1e-9),
|
||||
PlotScale::Fit => {
|
||||
const MARGIN: f64 = 1.05;
|
||||
let sx = (sheet_mm.0 / MARGIN) / ww;
|
||||
let sy = (sheet_mm.1 / MARGIN) / wh;
|
||||
sx.min(sy)
|
||||
}
|
||||
};
|
||||
let scaled_w = ww * scale;
|
||||
let scaled_h = wh * scale;
|
||||
let offset_x = (sheet_mm.0 - scaled_w) / 2.0;
|
||||
let offset_y = (sheet_mm.1 - scaled_h) / 2.0;
|
||||
(scale, offset_x, offset_y)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn paper_dimensions_and_orientation() {
|
||||
assert_eq!(PaperSize::A4.dimensions_mm(), (210.0, 297.0));
|
||||
assert_eq!(PaperSize::A0.dimensions_mm(), (841.0, 1189.0));
|
||||
assert_eq!(PaperSize::Letter.dimensions_mm(), (215.9, 279.4));
|
||||
assert_eq!(PaperSize::Legal.dimensions_mm(), (215.9, 355.6));
|
||||
assert_eq!(PaperSize::Tabloid.dimensions_mm(), (279.4, 431.8));
|
||||
assert_eq!(PaperSize::ALL.len(), 8);
|
||||
assert_eq!(PaperSize::A3.label(), "A3");
|
||||
// Portrait keeps (w,h); landscape swaps.
|
||||
assert_eq!(sheet_mm(PaperSize::A4, Orientation::Portrait), (210.0, 297.0));
|
||||
assert_eq!(sheet_mm(PaperSize::A4, Orientation::Landscape), (297.0, 210.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_centers_and_scales_within_margin() {
|
||||
// 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width:
|
||||
// usable = 210/1.05 = 200; scale = 200/100 = 2.0.
|
||||
let (s, ox, oy) = window_to_sheet((100.0, 100.0), (210.0, 297.0), PlotScale::Fit);
|
||||
assert!((s - 2.0).abs() < 1e-9, "scale {s}");
|
||||
// Window is 100*2 = 200 wide/tall; centered on 210×297.
|
||||
assert!((ox - (210.0 - 200.0) / 2.0).abs() < 1e-9, "ox {ox}");
|
||||
assert!((oy - (297.0 - 200.0) / 2.0).abs() < 1e-9, "oy {oy}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ratio_applies_exact_scale_and_centers() {
|
||||
// Ratio(0.5): scale is exactly 0.5; the scaled window is centered and
|
||||
// (ox, oy) is the sheet-mm position of its min corner.
|
||||
let (s, ox, oy) = window_to_sheet((100.0, 80.0), (210.0, 297.0), PlotScale::Ratio(0.5));
|
||||
assert!((s - 0.5).abs() < 1e-9);
|
||||
// scaled window = 50×40; centered box origin = ((210-50)/2,(297-40)/2).
|
||||
assert!((ox - (210.0 - 50.0) / 2.0).abs() < 1e-9, "ox {ox}");
|
||||
assert!((oy - (297.0 - 40.0) / 2.0).abs() < 1e-9, "oy {oy}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
//! Plot devices: what a page setup names as its plotter, and what that device
|
||||
//! can print.
|
||||
//!
|
||||
//! the source application stores the device in `PlotSettings::printer_name` as the plotter
|
||||
//! configuration's name — `DWG To PDF.pc3` for its PDF driver, the Windows
|
||||
//! printer's name for a system printer, `None` when nothing is assigned. A
|
||||
//! drawing saved here has to use the same spelling so the source application attaches the
|
||||
//! layout to its own PDF driver instead of reporting a missing device; and a
|
||||
//! device name that arrives from the source application (an HP plotter's `.pc3`, `DWF6
|
||||
//! ePlot.pc3`) must go back out untouched even though we cannot drive it.
|
||||
//!
|
||||
//! The device also decides the *printable area*: the sheet inset by the
|
||||
//! driver's unprintable margins, which the source application writes into the layout and
|
||||
//! draws as the dashed rectangle in paper space. The PDF driver's margins are
|
||||
//! a fixed table; a CUPS printer reports its media and margins over IPP
|
||||
//! (`media-col-database`), which is queried once per printer and cached.
|
||||
|
||||
pub use crate::io::paper_catalog::Margins;
|
||||
use crate::io::paper_catalog::{self, CustomPaper, PaperSize, PaperUnits, PaperVariant};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
/// the source application's plotter configuration name for PDF output.
|
||||
pub const PDF_DEVICE_NAME: &str = "DWG To PDF.pc3";
|
||||
/// the source application's spelling for "no plotter assigned" in the plot dialog…
|
||||
pub const NO_DEVICE_NAME: &str = "None";
|
||||
/// …and the name it actually stores in the drawing for that choice.
|
||||
const NO_DEVICE_STORED_NAME: &str = "none_device";
|
||||
/// The label older Open CAD Studio builds wrote into drawings for PDF output.
|
||||
/// Recognised on read so those drawings keep plotting to PDF, and always
|
||||
/// rewritten with [`PDF_DEVICE_NAME`] on save.
|
||||
const LEGACY_PDF_LABEL: &str = "Save to PDF file…";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PlotDevice {
|
||||
/// No plotter assigned (the source application `None`). We plot to the system default
|
||||
/// printer in that case, which is what the plot dialog offers.
|
||||
None,
|
||||
/// PDF file output, the source application's `DWG To PDF.pc3`.
|
||||
Pdf,
|
||||
/// A system printer by its queue / spooler name.
|
||||
Printer(String),
|
||||
}
|
||||
|
||||
impl PlotDevice {
|
||||
/// The name the source application would store for this device.
|
||||
pub fn canonical_name(&self) -> String {
|
||||
match self {
|
||||
PlotDevice::None => NO_DEVICE_STORED_NAME.to_string(),
|
||||
PlotDevice::Pdf => PDF_DEVICE_NAME.to_string(),
|
||||
PlotDevice::Printer(name) => name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret a stored `printer_name`. Returns the device and whether the
|
||||
/// stored spelling should be preserved on save: an the source application plotter
|
||||
/// configuration we cannot drive (`DWF6 ePlot.pc3`, an HP `.pc3`) is
|
||||
/// treated as PDF output but keeps its name, so the drawing still attaches
|
||||
/// to that plotter in the source application; our own legacy label and an empty name are
|
||||
/// not worth preserving.
|
||||
pub fn from_stored_name(name: &str) -> (Self, bool) {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.eq_ignore_ascii_case(NO_DEVICE_NAME) {
|
||||
return (PlotDevice::None, false);
|
||||
}
|
||||
if name.eq_ignore_ascii_case(NO_DEVICE_STORED_NAME) {
|
||||
// the source application's own spelling: keep it rather than rewriting it.
|
||||
return (PlotDevice::None, true);
|
||||
}
|
||||
if name == LEGACY_PDF_LABEL {
|
||||
return (PlotDevice::Pdf, false);
|
||||
}
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower.ends_with(".pc3") {
|
||||
// Any the source application plotter configuration: PDF ones are ours to drive,
|
||||
// the rest plot to PDF here but keep their name for the source application.
|
||||
return (PlotDevice::Pdf, true);
|
||||
}
|
||||
(PlotDevice::Printer(name.to_string()), true)
|
||||
}
|
||||
|
||||
/// Unprintable margins the device imposes on `paper`, in millimetres.
|
||||
/// A sheet the user defined carries its own margins for every device,
|
||||
/// the way a custom size in a plotter configuration does; a printer that
|
||||
/// reports the same size still wins, since it knows its hardware.
|
||||
pub fn margins_mm(&self, paper: &PaperSize, custom: &[CustomPaper]) -> Margins {
|
||||
let user_defined = custom
|
||||
.iter()
|
||||
.find(|c| c.canonical() == paper.canonical)
|
||||
.map(CustomPaper::margins_mm);
|
||||
match self {
|
||||
PlotDevice::Pdf => user_defined.unwrap_or_else(|| pdf_margins_mm(paper)),
|
||||
PlotDevice::Printer(name) => cached_printer_capabilities(name)
|
||||
.and_then(|caps| caps.margins_for(paper))
|
||||
.or(user_defined)
|
||||
.unwrap_or(Margins::ZERO),
|
||||
// No device: the source application keeps the margins of the last device or its
|
||||
// defaults; without one we show the full sheet as printable.
|
||||
PlotDevice::None => user_defined.unwrap_or(Margins::ZERO),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── PDF driver ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Unprintable margins of the source application's `DWG To PDF.pc3` sheets, as its Plotter
|
||||
/// Configuration Editor lists them (Modify Standard Paper Sizes). They depend
|
||||
/// on the sheet family and variant, not on the individual size: every ISO
|
||||
/// sheet shares one set in millimetres, every ANSI / ARCH sheet one set in
|
||||
/// inches. Order inside each set is left, bottom, right, top.
|
||||
const PDF_MM_STANDARD: Margins = Margins {
|
||||
left: 5.0,
|
||||
bottom: 17.0,
|
||||
right: 6.0,
|
||||
top: 18.0,
|
||||
};
|
||||
const PDF_MM_EXPAND: Margins = Margins {
|
||||
left: 5.0,
|
||||
bottom: 10.0,
|
||||
right: 6.0,
|
||||
top: 11.0,
|
||||
};
|
||||
const PDF_MM_FULL_BLEED: Margins = Margins {
|
||||
left: 0.0,
|
||||
bottom: 1.0,
|
||||
right: 0.0,
|
||||
top: 1.0,
|
||||
};
|
||||
const PDF_IN_STANDARD: Margins = Margins {
|
||||
left: 0.23,
|
||||
bottom: 0.70,
|
||||
right: 0.23,
|
||||
top: 0.70,
|
||||
};
|
||||
const PDF_IN_EXPAND: Margins = Margins {
|
||||
left: 0.23,
|
||||
bottom: 0.42,
|
||||
right: 0.23,
|
||||
top: 0.42,
|
||||
};
|
||||
const PDF_IN_FULL_BLEED: Margins = Margins::uniform(0.03);
|
||||
|
||||
fn pdf_margins_mm(paper: &PaperSize) -> Margins {
|
||||
let set = match (paper.units, paper.variant) {
|
||||
(PaperUnits::Millimeters, PaperVariant::Standard) => PDF_MM_STANDARD,
|
||||
(PaperUnits::Millimeters, PaperVariant::Expand) => PDF_MM_EXPAND,
|
||||
(PaperUnits::Millimeters, PaperVariant::FullBleed) => PDF_MM_FULL_BLEED,
|
||||
(PaperUnits::Inches, PaperVariant::Standard) => PDF_IN_STANDARD,
|
||||
(PaperUnits::Inches, PaperVariant::Expand) => PDF_IN_EXPAND,
|
||||
(PaperUnits::Inches, PaperVariant::FullBleed) => PDF_IN_FULL_BLEED,
|
||||
};
|
||||
set.to_mm(paper.units)
|
||||
}
|
||||
|
||||
// ── System printers ───────────────────────────────────────────────────────
|
||||
|
||||
/// One sheet a printer reports it can load, with its unprintable margins.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PrinterMedia {
|
||||
/// The sheet as our catalogue knows it (matched by size), or a custom
|
||||
/// sheet built from the reported dimensions.
|
||||
pub paper: PaperSize,
|
||||
pub margins: Margins,
|
||||
/// `true` when the printer also reports a borderless variant of this size.
|
||||
pub borderless: bool,
|
||||
}
|
||||
|
||||
/// What a printer reported about its media, cached for the session.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PrinterCapabilities {
|
||||
/// Sheets in the printer's own order, one entry per distinct size.
|
||||
pub media: Vec<PrinterMedia>,
|
||||
/// The printer's default sheet, when reported.
|
||||
pub default_paper: Option<PaperSize>,
|
||||
}
|
||||
|
||||
impl PrinterCapabilities {
|
||||
/// Margins the printer reports for `paper`, matched by size. `None` when
|
||||
/// the printer never mentioned a sheet of that size.
|
||||
pub fn margins_for(&self, paper: &PaperSize) -> Option<Margins> {
|
||||
let (w, h) = paper.portrait_mm();
|
||||
self.media
|
||||
.iter()
|
||||
.find(|media| {
|
||||
let (mw, mh) = media.paper.portrait_mm();
|
||||
(mw - w).abs() <= 0.5 && (mh - h).abs() <= 0.5
|
||||
})
|
||||
.map(|media| media.margins)
|
||||
}
|
||||
}
|
||||
|
||||
fn capability_cache() -> &'static Mutex<HashMap<String, Arc<PrinterCapabilities>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, Arc<PrinterCapabilities>>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// The cached capabilities of `printer`, if they were queried already.
|
||||
pub fn cached_printer_capabilities(printer: &str) -> Option<Arc<PrinterCapabilities>> {
|
||||
capability_cache().lock().ok()?.get(printer).cloned()
|
||||
}
|
||||
|
||||
/// Query `printer` for its media (blocking, one process spawn) and cache the
|
||||
/// answer. Returns `None` when the platform cannot answer — the plot dialog
|
||||
/// then falls back to the paper catalogue with unverified margins.
|
||||
pub fn printer_capabilities(printer: &str) -> Option<Arc<PrinterCapabilities>> {
|
||||
if let Some(cached) = cached_printer_capabilities(printer) {
|
||||
return Some(cached);
|
||||
}
|
||||
let caps = Arc::new(query_printer_capabilities(printer)?);
|
||||
if let Ok(mut cache) = capability_cache().lock() {
|
||||
cache.insert(printer.to_string(), Arc::clone(&caps));
|
||||
}
|
||||
Some(caps)
|
||||
}
|
||||
|
||||
/// Name of the system default printer, when the platform reports one.
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||
pub fn default_printer_name() -> Option<String> {
|
||||
// `lpstat -d` → "system default destination: NAME" (the prefix is
|
||||
// localized, the name follows the last colon).
|
||||
let output = std::process::Command::new("lpstat")
|
||||
.arg("-d")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let line = text.lines().find(|line| line.contains(':'))?;
|
||||
let name = line.rsplit(':').next()?.trim();
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// Windows default printer discovery lands with native GDI printing.
|
||||
#[cfg(any(target_arch = "wasm32", target_os = "windows"))]
|
||||
pub fn default_printer_name() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||
fn query_printer_capabilities(printer: &str) -> Option<PrinterCapabilities> {
|
||||
// cupsd answers for the queue itself, so this is quick even when the
|
||||
// printer is off; `ipptool` is part of every CUPS install that has
|
||||
// `lpstat`. The standard test file asks for every attribute; the two we
|
||||
// read are `media-col-database` and `media-default`.
|
||||
let uri = format!("ipp://localhost/printers/{}", printer.replace(' ', "%20"));
|
||||
let output = std::process::Command::new("ipptool")
|
||||
.args(["-tv", &uri, "get-printer-attributes.test"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_ipp_media(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
/// Windows media enumeration (`DeviceCapabilities`) lands with native GDI
|
||||
/// printing; until then the catalogue serves every printer.
|
||||
#[cfg(any(target_arch = "wasm32", target_os = "windows"))]
|
||||
fn query_printer_capabilities(_printer: &str) -> Option<PrinterCapabilities> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Read the media a printer reports from `ipptool -tv` output.
|
||||
///
|
||||
/// The relevant lines look like
|
||||
/// `media-col-database (1setOf collection) = {media-size={x-dimension=21000
|
||||
/// y-dimension=29700} media-bottom-margin=300 media-left-margin=300 …},{…}`
|
||||
/// with sizes and margins in hundredths of a millimetre, and
|
||||
/// `media-default (keyword) = iso_a4_210x297mm`. Borderless variants repeat a
|
||||
/// size with zero margins; they collapse onto the bordered entry.
|
||||
fn parse_ipp_media(text: &str) -> Option<PrinterCapabilities> {
|
||||
let database = attribute_value(text, "media-col-database")?;
|
||||
let mut media: Vec<PrinterMedia> = Vec::new();
|
||||
for entry in database.split("},{") {
|
||||
let Some((w, h)) = collection_size_mm(entry) else {
|
||||
continue;
|
||||
};
|
||||
let margins = Margins {
|
||||
left: hundredths_mm(entry, "media-left-margin"),
|
||||
bottom: hundredths_mm(entry, "media-bottom-margin"),
|
||||
right: hundredths_mm(entry, "media-right-margin"),
|
||||
top: hundredths_mm(entry, "media-top-margin"),
|
||||
};
|
||||
let borderless = margins == Margins::ZERO;
|
||||
if let Some(existing) = media.iter_mut().find(|m| {
|
||||
let (mw, mh) = m.paper.portrait_mm();
|
||||
let (pw, ph) = portrait(w, h);
|
||||
(mw - pw).abs() <= 0.5 && (mh - ph).abs() <= 0.5
|
||||
}) {
|
||||
// Keep the bordered margins as the sheet's printable area and
|
||||
// remember that edge-to-edge is possible too.
|
||||
if borderless {
|
||||
existing.borderless = true;
|
||||
} else if existing.margins == Margins::ZERO {
|
||||
existing.margins = margins;
|
||||
existing.borderless = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (pw, ph) = portrait(w, h);
|
||||
let paper = paper_catalog::match_dimensions_mm(pw, ph)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| PaperSize::custom(pw, ph, PaperUnits::Millimeters));
|
||||
media.push(PrinterMedia {
|
||||
paper,
|
||||
margins,
|
||||
borderless,
|
||||
});
|
||||
}
|
||||
if media.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let default_paper = attribute_value(text, "media-default")
|
||||
.and_then(|name| pwg_size_mm(name.trim()))
|
||||
.and_then(|(w, h)| {
|
||||
let (w, h) = portrait(w, h);
|
||||
media
|
||||
.iter()
|
||||
.find(|m| {
|
||||
let (mw, mh) = m.paper.portrait_mm();
|
||||
(mw - w).abs() <= 0.5 && (mh - h).abs() <= 0.5
|
||||
})
|
||||
.map(|m| m.paper.clone())
|
||||
});
|
||||
Some(PrinterCapabilities {
|
||||
media,
|
||||
default_paper,
|
||||
})
|
||||
}
|
||||
|
||||
/// The text after `name (…) = ` on the line that declares `name`.
|
||||
fn attribute_value<'a>(text: &'a str, name: &str) -> Option<&'a str> {
|
||||
text.lines().find_map(|line| {
|
||||
let line = line.trim_start();
|
||||
let rest = line.strip_prefix(name)?;
|
||||
// The attribute name is followed by its syntax in parentheses.
|
||||
if !rest.starts_with(' ') && !rest.starts_with('(') {
|
||||
return None;
|
||||
}
|
||||
let (_, value) = rest.split_once('=')?;
|
||||
Some(value.trim())
|
||||
})
|
||||
}
|
||||
|
||||
fn collection_size_mm(entry: &str) -> Option<(f64, f64)> {
|
||||
let x = hundredths(entry, "x-dimension")?;
|
||||
let y = hundredths(entry, "y-dimension")?;
|
||||
(x > 0.0 && y > 0.0).then_some((x, y))
|
||||
}
|
||||
|
||||
fn hundredths(entry: &str, key: &str) -> Option<f64> {
|
||||
let start = entry.find(key)? + key.len();
|
||||
let rest = entry[start..].trim_start_matches('=');
|
||||
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
digits.parse::<f64>().ok().map(|v| v / 100.0)
|
||||
}
|
||||
|
||||
fn hundredths_mm(entry: &str, key: &str) -> f64 {
|
||||
hundredths(entry, key).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn portrait(w: f64, h: f64) -> (f64, f64) {
|
||||
if w <= h {
|
||||
(w, h)
|
||||
} else {
|
||||
(h, w)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dimensions from a PWG self-describing media name such as
|
||||
/// `iso_a4_210x297mm` or `na_letter_8.5x11in`.
|
||||
fn pwg_size_mm(name: &str) -> Option<(f64, f64)> {
|
||||
let dims = name.rsplit('_').next()?;
|
||||
let (dims, units) = dims
|
||||
.strip_suffix("mm")
|
||||
.map(|d| (d, PaperUnits::Millimeters))
|
||||
.or_else(|| dims.strip_suffix("in").map(|d| (d, PaperUnits::Inches)))?;
|
||||
let (w, h) = dims.split_once('x')?;
|
||||
let w: f64 = w.parse().ok()?;
|
||||
let h: f64 = h.parse().ok()?;
|
||||
Some((units.to_mm(w), units.to_mm(h)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const IPP_SAMPLE: &str = " media-default (keyword) = iso_a4_210x297mm\n\
|
||||
media-supported (1setOf keyword) = iso_a4_210x297mm,om_a-4.borderless_210x297mm,na_letter_8.5x11in,custom_215x345mm_215x345mm\n\
|
||||
media-col-database (1setOf collection) = {media-size={x-dimension=21000 y-dimension=29700} media-bottom-margin=300 media-left-margin=300 media-right-margin=300 media-top-margin=300},{media-size={x-dimension=21000 y-dimension=29700} media-bottom-margin=0 media-left-margin=0 media-right-margin=0 media-top-margin=0},{media-size={x-dimension=21590 y-dimension=27940} media-bottom-margin=635 media-left-margin=635 media-right-margin=635 media-top-margin=635},{media-size={x-dimension=21500 y-dimension=34500} media-bottom-margin=300 media-left-margin=300 media-right-margin=300 media-top-margin=300}\n";
|
||||
|
||||
#[test]
|
||||
fn canonical_names_follow_source_app() {
|
||||
assert_eq!(PlotDevice::Pdf.canonical_name(), "DWG To PDF.pc3");
|
||||
// What the source application writes for "None" in its plot dialog.
|
||||
assert_eq!(PlotDevice::None.canonical_name(), "none_device");
|
||||
assert_eq!(
|
||||
PlotDevice::Printer("L3270".into()).canonical_name(),
|
||||
"L3270"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_names_resolve_and_say_whether_to_keep_them() {
|
||||
assert_eq!(PlotDevice::from_stored_name(""), (PlotDevice::None, false));
|
||||
assert_eq!(
|
||||
PlotDevice::from_stored_name("None"),
|
||||
(PlotDevice::None, false)
|
||||
);
|
||||
assert_eq!(
|
||||
PlotDevice::from_stored_name("none_device"),
|
||||
(PlotDevice::None, true)
|
||||
);
|
||||
assert_eq!(
|
||||
PlotDevice::from_stored_name("NONE_DEVICE"),
|
||||
(PlotDevice::None, true)
|
||||
);
|
||||
assert_eq!(
|
||||
PlotDevice::from_stored_name("Save to PDF file…"),
|
||||
(PlotDevice::Pdf, false)
|
||||
);
|
||||
assert_eq!(
|
||||
PlotDevice::from_stored_name("DWG To PDF.pc3"),
|
||||
(PlotDevice::Pdf, true)
|
||||
);
|
||||
assert_eq!(
|
||||
PlotDevice::from_stored_name("the source application PDF (High Quality Print).pc3"),
|
||||
(PlotDevice::Pdf, true)
|
||||
);
|
||||
// A plotter we cannot drive plots to PDF here but keeps its name.
|
||||
assert_eq!(
|
||||
PlotDevice::from_stored_name("DWF6 ePlot.pc3"),
|
||||
(PlotDevice::Pdf, true)
|
||||
);
|
||||
assert_eq!(
|
||||
PlotDevice::from_stored_name("HP LaserJet 4"),
|
||||
(PlotDevice::Printer("HP LaserJet 4".into()), true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_margins_follow_source_app_per_family_and_variant() {
|
||||
let m =
|
||||
|name: &str| PlotDevice::Pdf.margins_mm(&paper_catalog::resolve(name).unwrap(), &[]);
|
||||
// ISO sheets share one millimetre set regardless of size.
|
||||
let iso_standard = Margins {
|
||||
left: 5.0,
|
||||
bottom: 17.0,
|
||||
right: 6.0,
|
||||
top: 18.0,
|
||||
};
|
||||
assert_eq!(m("ISO_A4_(210.00_x_297.00_MM)"), iso_standard);
|
||||
assert_eq!(m("ISO_A0_(841.00_x_1189.00_MM)"), iso_standard);
|
||||
assert_eq!(
|
||||
m("ISO_expand_A4_(210.00_x_297.00_MM)"),
|
||||
Margins {
|
||||
left: 5.0,
|
||||
bottom: 10.0,
|
||||
right: 6.0,
|
||||
top: 11.0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
m("ISO_full_bleed_A4_(210.00_x_297.00_MM)"),
|
||||
Margins {
|
||||
left: 0.0,
|
||||
bottom: 1.0,
|
||||
right: 0.0,
|
||||
top: 1.0
|
||||
}
|
||||
);
|
||||
// Inch sheets are listed in inches and stored in millimetres.
|
||||
let close = |a: Margins, b: Margins| {
|
||||
(a.left - b.left).abs() < 1e-9
|
||||
&& (a.bottom - b.bottom).abs() < 1e-9
|
||||
&& (a.right - b.right).abs() < 1e-9
|
||||
&& (a.top - b.top).abs() < 1e-9
|
||||
};
|
||||
let inch = |l: f64, b: f64, r: f64, t: f64| Margins {
|
||||
left: l * 25.4,
|
||||
bottom: b * 25.4,
|
||||
right: r * 25.4,
|
||||
top: t * 25.4,
|
||||
};
|
||||
assert!(close(
|
||||
m("ANSI_A_(8.50_x_11.00_Inches)"),
|
||||
inch(0.23, 0.70, 0.23, 0.70)
|
||||
));
|
||||
assert!(close(
|
||||
m("ARCH_D_(24.00_x_36.00_Inches)"),
|
||||
inch(0.23, 0.70, 0.23, 0.70)
|
||||
));
|
||||
assert!(close(
|
||||
m("ARCH_expand_D_(24.00_x_36.00_Inches)"),
|
||||
inch(0.23, 0.42, 0.23, 0.42)
|
||||
));
|
||||
assert!(close(
|
||||
m("ANSI_full_bleed_B_(11.00_x_17.00_Inches)"),
|
||||
inch(0.03, 0.03, 0.03, 0.03)
|
||||
));
|
||||
assert_eq!(
|
||||
PlotDevice::None.margins_mm(
|
||||
&paper_catalog::resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap(),
|
||||
&[]
|
||||
),
|
||||
Margins::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_defined_sheets_bring_their_own_margins() {
|
||||
let roll = CustomPaper {
|
||||
name: "Roll".into(),
|
||||
width: 900.0,
|
||||
height: 1200.0,
|
||||
units: PaperUnits::Millimeters,
|
||||
margins: Margins::uniform(3.0),
|
||||
};
|
||||
let paper = roll.paper();
|
||||
assert_eq!(
|
||||
PlotDevice::Pdf.margins_mm(&paper, &[roll.clone()]),
|
||||
Margins::uniform(3.0)
|
||||
);
|
||||
assert_eq!(
|
||||
PlotDevice::None.margins_mm(&paper, &[roll.clone()]),
|
||||
Margins::uniform(3.0)
|
||||
);
|
||||
// An unknown printer falls back to the user's margins too.
|
||||
assert_eq!(
|
||||
PlotDevice::Printer("Nowhere".into()).margins_mm(&paper, &[roll]),
|
||||
Margins::uniform(3.0)
|
||||
);
|
||||
// Without a definition a custom-looking sheet gets the family set.
|
||||
assert_eq!(
|
||||
PlotDevice::Pdf.margins_mm(&paper, &[]),
|
||||
Margins {
|
||||
left: 5.0,
|
||||
bottom: 17.0,
|
||||
right: 6.0,
|
||||
top: 18.0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipp_media_parses_sizes_margins_borderless_and_default() {
|
||||
let caps = parse_ipp_media(IPP_SAMPLE).expect("media parsed");
|
||||
assert_eq!(
|
||||
caps.media.len(),
|
||||
3,
|
||||
"borderless A4 folds into the bordered entry"
|
||||
);
|
||||
let a4 = &caps.media[0];
|
||||
assert_eq!(a4.paper.canonical, "ISO_A4_(210.00_x_297.00_MM)");
|
||||
assert_eq!(a4.margins, Margins::uniform(3.0));
|
||||
assert!(a4.borderless);
|
||||
let letter = &caps.media[1];
|
||||
assert_eq!(letter.paper.canonical, "ANSI_A_(8.50_x_11.00_Inches)");
|
||||
assert_eq!(letter.margins, Margins::uniform(6.35));
|
||||
assert!(!letter.borderless);
|
||||
let custom = &caps.media[2];
|
||||
assert_eq!(
|
||||
custom.paper.canonical,
|
||||
"UserDefinedMetric_(215.00_x_345.00_MM)"
|
||||
);
|
||||
assert_eq!(
|
||||
caps.default_paper.as_ref().map(|p| p.canonical.as_ref()),
|
||||
Some("ISO_A4_(210.00_x_297.00_MM)")
|
||||
);
|
||||
// Margin lookup by sheet size, either orientation, tolerant of the
|
||||
// catalogue's inch → mm rounding.
|
||||
let ansi_a = paper_catalog::resolve("Letter_(8.50_x_11.00_Inches)").unwrap();
|
||||
assert_eq!(caps.margins_for(&ansi_a), Some(Margins::uniform(6.35)));
|
||||
let a3 = paper_catalog::resolve("ISO_A3_(297.00_x_420.00_MM)").unwrap();
|
||||
assert_eq!(caps.margins_for(&a3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipp_output_without_media_is_rejected() {
|
||||
assert!(parse_ipp_media("printer-state (enum) = 3\n").is_none());
|
||||
assert!(parse_ipp_media(
|
||||
"media-col-database (1setOf collection) = {media-size={x-dimension=0 y-dimension=0}}\n"
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pwg_names_carry_their_size() {
|
||||
assert_eq!(pwg_size_mm("iso_a4_210x297mm"), Some((210.0, 297.0)));
|
||||
let (w, h) = pwg_size_mm("na_letter_8.5x11in").unwrap();
|
||||
assert!((w - 215.9).abs() < 1e-9 && (h - 279.4).abs() < 1e-9);
|
||||
assert_eq!(
|
||||
pwg_size_mm("custom_215x345mm_215x345mm"),
|
||||
Some((215.0, 345.0))
|
||||
);
|
||||
assert_eq!(pwg_size_mm("iso_a4"), None);
|
||||
}
|
||||
}
|
||||
+306
-2
@@ -47,6 +47,12 @@ pub struct PrintOptions {
|
||||
/// setting wins, so the field is legitimately unread there.
|
||||
#[cfg_attr(target_os = "windows", allow(dead_code))]
|
||||
pub quality: Option<String>,
|
||||
/// Driver options chosen in the printer-properties editor, as CUPS
|
||||
/// `key=value` pairs (`ColorModel=Gray`, `MediaType=Photographic`). Read
|
||||
/// on the CUPS path (`lp -o`); Windows keeps such choices in the
|
||||
/// printer's own preferences instead.
|
||||
#[cfg_attr(target_os = "windows", allow(dead_code))]
|
||||
pub driver_options: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -238,13 +244,16 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec<Strin
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty());
|
||||
|
||||
// `/e` opens the driver's *printing preferences* (paper, tray, duplex,
|
||||
// quality — what a print job honours), not the device's admin properties
|
||||
// sheet that `/p` shows.
|
||||
#[cfg(target_os = "windows")]
|
||||
let command = match named {
|
||||
Some(name) => (
|
||||
"rundll32.exe",
|
||||
vec![
|
||||
"printui.dll,PrintUIEntry".to_string(),
|
||||
"/p".to_string(),
|
||||
"/e".to_string(),
|
||||
"/n".to_string(),
|
||||
name.to_string(),
|
||||
],
|
||||
@@ -263,6 +272,7 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec<Strin
|
||||
|
||||
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
|
||||
let command = {
|
||||
// Reached only outside Linux; Linux opens the desktop's own panel.
|
||||
let target = named
|
||||
.map(|name| format!("http://localhost:631/printers/{name}"))
|
||||
.unwrap_or_else(|| "http://localhost:631/printers".to_string());
|
||||
@@ -275,6 +285,11 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec<Strin
|
||||
/// Open the operating system's printer configuration surface.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_printer_properties(printer: Option<&str>) -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return open_desktop_printer_settings(printer);
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
let (program, args) = printer_properties_command(printer);
|
||||
std::process::Command::new(program)
|
||||
.args(args)
|
||||
@@ -406,6 +421,10 @@ fn dispatch_to_printer_opts(
|
||||
};
|
||||
cmd.arg("-o").arg(format!("print-quality={pq}"));
|
||||
}
|
||||
// The driver's own options, exactly as the PPD names them.
|
||||
for (key, value) in &opts.driver_options {
|
||||
cmd.arg("-o").arg(format!("{key}={value}"));
|
||||
}
|
||||
let lp_result = cmd
|
||||
.arg("--")
|
||||
.arg(path_str.as_ref())
|
||||
@@ -468,7 +487,7 @@ mod printer_properties_tests {
|
||||
"rundll32.exe",
|
||||
vec![
|
||||
"printui.dll,PrintUIEntry".to_string(),
|
||||
"/p".to_string(),
|
||||
"/e".to_string(),
|
||||
"/n".to_string(),
|
||||
"Office LaserJet".to_string(),
|
||||
],
|
||||
@@ -523,3 +542,288 @@ mod printer_properties_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Driver options (CUPS PPD) ─────────────────────────────────────────────
|
||||
|
||||
/// One option a CUPS printer's driver exposes, as `lpoptions -l` lists it:
|
||||
/// the PPD keyword, its description, the choices, and the current default.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PrinterOption {
|
||||
/// PPD keyword, e.g. `MediaType`, passed back verbatim as `-o key=value`.
|
||||
pub key: String,
|
||||
/// The driver's description, e.g. `Media Type`.
|
||||
pub label: String,
|
||||
/// Keyword of the currently selected choice.
|
||||
pub default: String,
|
||||
pub choices: Vec<PrinterChoice>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PrinterChoice {
|
||||
/// PPD choice keyword, e.g. `PhotographicHighGloss`.
|
||||
pub keyword: String,
|
||||
/// Readable form of the keyword, e.g. `Photographic High Gloss`.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl PrinterOption {
|
||||
pub fn label_for(&self, keyword: &str) -> String {
|
||||
self.choices
|
||||
.iter()
|
||||
.find(|choice| choice.keyword == keyword)
|
||||
.map(|choice| choice.label.clone())
|
||||
.unwrap_or_else(|| humanize_keyword(keyword))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask CUPS for the options of `printer` (blocking, one `lpoptions` spawn).
|
||||
/// The sheet (`PageSize`) is left out: the plot dialog chooses it.
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||
pub fn printer_options(printer: &str) -> Result<Vec<PrinterOption>, String> {
|
||||
let output = std::process::Command::new("lpoptions")
|
||||
.args(["-p", printer, "-l"])
|
||||
.output()
|
||||
.map_err(|error| format!("lpoptions: {error}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
|
||||
}
|
||||
Ok(parse_lpoptions(&String::from_utf8_lossy(&output.stdout)))
|
||||
}
|
||||
|
||||
/// Windows drivers keep their options behind `DocumentProperties`; there is
|
||||
/// no list to show in-line.
|
||||
#[cfg(any(target_arch = "wasm32", target_os = "windows"))]
|
||||
pub fn printer_options(_printer: &str) -> Result<Vec<PrinterOption>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Parse `lpoptions -l` output: one option per line as
|
||||
/// `Key/Description: choice *default choice …`, the asterisk marking the
|
||||
/// current default. Numeric-range options print the same way and are kept.
|
||||
pub fn parse_lpoptions(text: &str) -> Vec<PrinterOption> {
|
||||
let mut options = Vec::new();
|
||||
for line in text.lines() {
|
||||
let Some((head, tail)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let (key, label) = head
|
||||
.split_once('/')
|
||||
.map(|(key, label)| (key.trim(), label.trim()))
|
||||
.unwrap_or((head.trim(), head.trim()));
|
||||
if key.is_empty() || key.eq_ignore_ascii_case("PageSize") {
|
||||
continue;
|
||||
}
|
||||
let mut default = String::new();
|
||||
let choices: Vec<PrinterChoice> = tail
|
||||
.split_whitespace()
|
||||
.map(|token| {
|
||||
let (keyword, is_default) = match token.strip_prefix('*') {
|
||||
Some(keyword) => (keyword, true),
|
||||
None => (token, false),
|
||||
};
|
||||
if is_default {
|
||||
default = keyword.to_string();
|
||||
}
|
||||
PrinterChoice {
|
||||
keyword: keyword.to_string(),
|
||||
label: humanize_keyword(keyword),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if default.is_empty() {
|
||||
default = choices[0].keyword.clone();
|
||||
}
|
||||
options.push(PrinterOption {
|
||||
key: key.to_string(),
|
||||
label: if label.is_empty() { key.to_string() } else { label.to_string() },
|
||||
default,
|
||||
choices,
|
||||
});
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
/// `PhotographicHighGloss` → `Photographic High Gloss`, `DuplexNoTumble` →
|
||||
/// `Duplex No Tumble`; keywords that are already words or numbers pass
|
||||
/// through. Consecutive capitals (`RGB`, `A4`) stay together.
|
||||
pub fn humanize_keyword(keyword: &str) -> String {
|
||||
let mut out = String::with_capacity(keyword.len() + 4);
|
||||
let chars: Vec<char> = keyword.chars().collect();
|
||||
for (index, &ch) in chars.iter().enumerate() {
|
||||
if index > 0 && ch.is_ascii_uppercase() {
|
||||
let previous = chars[index - 1];
|
||||
let next_lower = chars.get(index + 1).is_some_and(|c| c.is_ascii_lowercase());
|
||||
if previous.is_ascii_lowercase() || (previous.is_ascii_uppercase() && next_lower) {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The desktop environment's printer settings panel — where the driver
|
||||
/// options cannot be listed (no CUPS) the user still gets somewhere useful.
|
||||
/// Tries the panel of the running desktop first, then the
|
||||
/// distribution-neutral tools, and only then the CUPS web interface.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn open_desktop_printer_settings(printer: Option<&str>) -> Result<(), String> {
|
||||
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let mut candidates: Vec<(&str, Vec<String>)> = Vec::new();
|
||||
if desktop.contains("gnome") || desktop.contains("ubuntu") || desktop.contains("cinnamon") {
|
||||
candidates.push(("gnome-control-center", vec!["printers".into()]));
|
||||
}
|
||||
if desktop.contains("kde") || desktop.contains("plasma") {
|
||||
candidates.push(("kcmshell6", vec!["kcm_printer_manager".into()]));
|
||||
candidates.push(("kcmshell5", vec!["kcm_printer_manager".into()]));
|
||||
}
|
||||
candidates.push(("system-config-printer", Vec::new()));
|
||||
candidates.push(("gnome-control-center", vec!["printers".into()]));
|
||||
for (program, args) in candidates {
|
||||
if std::process::Command::new(program)
|
||||
.args(&args)
|
||||
.spawn()
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let target = printer
|
||||
.map(|name| format!("http://localhost:631/printers/{name}"))
|
||||
.unwrap_or_else(|| "http://localhost:631/printers".to_string());
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(target)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("Could not open printer settings: {error}"))
|
||||
}
|
||||
|
||||
/// Show the driver's own document-properties sheet for `printer`, owned by
|
||||
/// the window `owner` (an `HWND`, 0 for none), and on OK store the result as
|
||||
/// the user's printing preferences for that printer — which is what the
|
||||
/// print job then honours. Returns `Ok(false)` when the user cancelled.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn edit_printer_preferences(printer: &str, owner: isize) -> Result<bool, String> {
|
||||
use windows_sys::Win32::Foundation::HANDLE;
|
||||
use windows_sys::Win32::Graphics::Gdi::DEVMODEW;
|
||||
use windows_sys::Win32::Graphics::Printing::{
|
||||
ClosePrinter, DocumentPropertiesW, OpenPrinterW, SetPrinterW, PRINTER_INFO_9W,
|
||||
};
|
||||
// winspool.h: DM_OUT_BUFFER = DM_COPY, DM_IN_PROMPT = DM_PROMPT,
|
||||
// DM_IN_BUFFER = DM_MODIFY.
|
||||
const DM_OUT_BUFFER: u32 = 2;
|
||||
const DM_IN_PROMPT: u32 = 4;
|
||||
const DM_IN_BUFFER: u32 = 8;
|
||||
const IDOK: i32 = 1;
|
||||
|
||||
let name: Vec<u16> = printer.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut handle: HANDLE = std::ptr::null_mut();
|
||||
// SAFETY: `name` is NUL-terminated and outlives the call; a null default
|
||||
// asks for PRINTER_ACCESS_USE, enough for per-user preferences.
|
||||
if unsafe { OpenPrinterW(name.as_ptr(), &mut handle, std::ptr::null()) } == 0 {
|
||||
return Err(format!("Could not open printer \"{printer}\""));
|
||||
}
|
||||
let owner = owner as windows_sys::Win32::Foundation::HWND;
|
||||
let outcome = (|| {
|
||||
// SAFETY: with null buffers the call only reports the DEVMODE size.
|
||||
let size = unsafe {
|
||||
DocumentPropertiesW(owner, handle, name.as_ptr(), std::ptr::null_mut(), std::ptr::null(), 0)
|
||||
};
|
||||
if size <= 0 {
|
||||
return Err(format!("Printer \"{printer}\" reports no document properties"));
|
||||
}
|
||||
// DEVMODEW is u16-aligned; a u16 buffer keeps the cast sound.
|
||||
let words = (size as usize).div_ceil(2);
|
||||
let mut current = vec![0u16; words];
|
||||
let mut edited = vec![0u16; words];
|
||||
// SAFETY: both buffers hold `size` bytes as the driver requested.
|
||||
let fetched = unsafe {
|
||||
DocumentPropertiesW(
|
||||
owner,
|
||||
handle,
|
||||
name.as_ptr(),
|
||||
current.as_mut_ptr().cast::<DEVMODEW>(),
|
||||
std::ptr::null(),
|
||||
DM_OUT_BUFFER,
|
||||
)
|
||||
};
|
||||
if fetched < 0 {
|
||||
return Err(format!("Could not read the preferences of \"{printer}\""));
|
||||
}
|
||||
edited.copy_from_slice(¤t);
|
||||
// SAFETY: as above; DM_IN_PROMPT runs the driver's modal sheet.
|
||||
let response = unsafe {
|
||||
DocumentPropertiesW(
|
||||
owner,
|
||||
handle,
|
||||
name.as_ptr(),
|
||||
edited.as_mut_ptr().cast::<DEVMODEW>(),
|
||||
current.as_ptr().cast::<DEVMODEW>(),
|
||||
DM_IN_BUFFER | DM_IN_PROMPT | DM_OUT_BUFFER,
|
||||
)
|
||||
};
|
||||
if response != IDOK {
|
||||
return Ok(false);
|
||||
}
|
||||
let info = PRINTER_INFO_9W {
|
||||
pDevMode: edited.as_mut_ptr().cast::<DEVMODEW>(),
|
||||
};
|
||||
// SAFETY: level 9 takes a PRINTER_INFO_9W; the DEVMODE outlives the call.
|
||||
if unsafe { SetPrinterW(handle, 9, (&info as *const PRINTER_INFO_9W).cast::<u8>(), 0) } == 0 {
|
||||
return Err(format!("Could not save the preferences of \"{printer}\""));
|
||||
}
|
||||
Ok(true)
|
||||
})();
|
||||
// SAFETY: `handle` came from OpenPrinterW above.
|
||||
unsafe { ClosePrinter(handle) };
|
||||
outcome
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod driver_option_tests {
|
||||
use super::*;
|
||||
|
||||
const LPOPTIONS: &str = "PageSize/Media Size: 101.6x180.6mm *A4 A4.Borderless A5\n\
|
||||
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic Envelope\n\
|
||||
ColorModel/Print Color Mode: *RGB Gray AutoGray ProcessGray\n\
|
||||
Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\
|
||||
cupsPrintQuality/Print Quality: *Normal High\n\
|
||||
print-scaling/Print Scaling: auto auto-fit fill *fit none\n\
|
||||
Broken line without colon\n";
|
||||
|
||||
#[test]
|
||||
fn lpoptions_output_becomes_options_with_defaults() {
|
||||
let options = parse_lpoptions(LPOPTIONS);
|
||||
let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect();
|
||||
// PageSize is the plot dialog's business; everything else is listed.
|
||||
assert_eq!(
|
||||
keys,
|
||||
["MediaType", "ColorModel", "Duplex", "cupsPrintQuality", "print-scaling"]
|
||||
);
|
||||
let media = &options[0];
|
||||
assert_eq!(media.label, "Media Type");
|
||||
assert_eq!(media.default, "Stationery");
|
||||
assert_eq!(media.choices[1].keyword, "PhotographicHighGloss");
|
||||
assert_eq!(media.choices[1].label, "Photographic High Gloss");
|
||||
assert_eq!(media.label_for("Envelope"), "Envelope");
|
||||
let scaling = &options[4];
|
||||
assert_eq!(scaling.default, "fit");
|
||||
assert_eq!(scaling.choices.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keywords_read_as_words() {
|
||||
assert_eq!(humanize_keyword("DuplexNoTumble"), "Duplex No Tumble");
|
||||
assert_eq!(humanize_keyword("RGB"), "RGB");
|
||||
assert_eq!(humanize_keyword("AutoGray"), "Auto Gray");
|
||||
assert_eq!(humanize_keyword("ProcessGray"), "Process Gray");
|
||||
assert_eq!(humanize_keyword("auto-fit"), "auto-fit");
|
||||
assert_eq!(humanize_keyword("PhotographicSemiGloss"), "Photographic Semi Gloss");
|
||||
assert_eq!(humanize_keyword("A4"), "A4");
|
||||
}
|
||||
}
|
||||
@@ -1456,6 +1456,19 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"Layout Print Configuration" => Some(("properties", "layout-print-configuration")),
|
||||
"Layout name cannot be empty." => Some(("common", "layout-name-cannot-be-empty")),
|
||||
"Layout plots the current sheet using the selected paper size." => Some(("plot", "layout-plots-the-current-sheet-using-the-selec")),
|
||||
"Sheet sizes and printable areas reported by the printer." => Some(("plot", "sheet-sizes-and-printable-areas-reported-by-th")),
|
||||
"Custom paper size" => Some(("plot", "custom-paper-size")),
|
||||
"Printable margins" => Some(("plot", "printable-margins")),
|
||||
"Enter a positive width and height." => Some(("plot", "enter-a-positive-width-and-height")),
|
||||
"Paper size" => Some(("plot", "paper-size")),
|
||||
"{count} driver option(s) set for this printer." => Some(("plot", "count-driver-option-s-set-for-this-printer")),
|
||||
"Printer properties — {printer}" => Some(("plot", "printer-properties-printer")),
|
||||
"This printer reports no driver options." => Some(("plot", "this-printer-reports-no-driver-options")),
|
||||
"Could not read the printer's options: {error}" => Some(("plot", "could-not-read-the-printer-s-options-error")),
|
||||
"Reading the printer's options…" => Some(("plot", "reading-the-printer-s-options")),
|
||||
"Printing preferences saved for {printer}." => Some(("plot", "printing-preferences-saved-for-printer")),
|
||||
"Printing preferences unchanged." => Some(("plot", "printing-preferences-unchanged")),
|
||||
"Printer properties" => Some(("plot", "printer-properties")),
|
||||
"Layout renamed: '{old_name}' → '{new_name}'" => Some(("common", "layout-renamed-old-name-new-name")),
|
||||
"Layouts" => Some(("layout", "layouts")),
|
||||
"Leader" => Some(("common", "leader")),
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Form building blocks shared by the in-canvas dialogs.
|
||||
//!
|
||||
//! The Plot dialog grew its own `label : control` rows, field and button
|
||||
//! styles, and section dividers; every dialog that followed copied them. They
|
||||
//! live here once so a new dialog reads like the existing ones and a theme
|
||||
//! tweak lands everywhere.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::ui::style::common::muted_style;
|
||||
use iced::widget::{button, container, pick_list, row, text, text_input, Space};
|
||||
use iced::{Background, Border, Element, Length, Theme};
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Width of the label column in a `label : control` row.
|
||||
pub const LABEL_WIDTH: f32 = 92.0;
|
||||
|
||||
/// Dialog button: accent (primary action) or neutral.
|
||||
pub fn button_style(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
move |theme: &Theme, status| {
|
||||
let palette = theme.palette();
|
||||
let pair = match (accent, status) {
|
||||
(true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong,
|
||||
(false, button::Status::Hovered | button::Status::Pressed) => palette.background.strong,
|
||||
(true, _) => palette.primary.base,
|
||||
_ => palette.background.weak,
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(pair.color)),
|
||||
text_color: pair.text,
|
||||
border: Border {
|
||||
color: palette.background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text field: base background, neutral border that turns primary on focus.
|
||||
pub fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
|
||||
let palette = theme.palette();
|
||||
let border = match status {
|
||||
text_input::Status::Focused { .. } => palette.primary.base.color,
|
||||
_ => palette.background.neutral.color,
|
||||
};
|
||||
text_input::Style {
|
||||
background: Background::Color(palette.background.base.color),
|
||||
border: Border {
|
||||
color: border,
|
||||
width: 1.0,
|
||||
radius: 3.0.into(),
|
||||
},
|
||||
icon: palette.background.base.text,
|
||||
placeholder: palette.background.base.text.scale_alpha(0.48),
|
||||
value: palette.background.base.text,
|
||||
selection: palette.primary.base.color.scale_alpha(0.5),
|
||||
}
|
||||
}
|
||||
|
||||
/// One-pixel horizontal rule.
|
||||
pub fn hdivider<'a>(width: Length) -> Element<'a, Message> {
|
||||
container(Space::new().width(width).height(1))
|
||||
.width(width)
|
||||
.height(1)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().background.neutral.color)),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// One-pixel vertical rule.
|
||||
pub fn vseparator<'a>(height: Length) -> Element<'a, Message> {
|
||||
container(Space::new().width(1).height(height))
|
||||
.width(1)
|
||||
.height(height)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().background.neutral.color)),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Muted section heading.
|
||||
pub fn section_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> {
|
||||
text(label).size(11).style(muted_style).into()
|
||||
}
|
||||
|
||||
fn row_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> {
|
||||
text(label)
|
||||
.size(11)
|
||||
.style(muted_style)
|
||||
.width(LABEL_WIDTH)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A `label : dropdown` row.
|
||||
pub fn labeled_pick_list<'a, T>(
|
||||
label: Cow<'static, str>,
|
||||
options: Vec<T>,
|
||||
selected: Option<T>,
|
||||
on_select: impl Fn(T) -> Message + 'a,
|
||||
width: Length,
|
||||
) -> Element<'a, Message>
|
||||
where
|
||||
T: ToString + Clone + PartialEq + 'a,
|
||||
{
|
||||
let list = pick_list(selected, options, |value| value.to_string())
|
||||
.on_select(on_select)
|
||||
.text_size(12)
|
||||
.padding([3, 6])
|
||||
.width(width);
|
||||
row![row_label(label), list]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A `label : dropdown` row that renders read-only when `enabled` is false.
|
||||
pub fn labeled_pick_list_enabled<'a, T>(
|
||||
label: Cow<'static, str>,
|
||||
options: Vec<T>,
|
||||
selected: Option<T>,
|
||||
on_select: impl Fn(T) -> Message + 'a,
|
||||
width: Length,
|
||||
enabled: bool,
|
||||
) -> Element<'a, Message>
|
||||
where
|
||||
T: ToString + Clone + PartialEq + 'a,
|
||||
{
|
||||
if enabled {
|
||||
return labeled_pick_list(label, options, selected, on_select, width);
|
||||
}
|
||||
row![
|
||||
row_label(label),
|
||||
crate::ui::read_only::field(
|
||||
selected
|
||||
.map(|choice| choice.to_string())
|
||||
.unwrap_or_default()
|
||||
.as_str(),
|
||||
12.0,
|
||||
width,
|
||||
),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A `label : text field` row.
|
||||
pub fn labeled_field<'a>(
|
||||
label: Cow<'static, str>,
|
||||
value: &'a str,
|
||||
on_input: impl Fn(String) -> Message + 'a,
|
||||
width: f32,
|
||||
) -> Element<'a, Message> {
|
||||
row![
|
||||
row_label(label),
|
||||
text_input("", value)
|
||||
.on_input(on_input)
|
||||
.style(field_style)
|
||||
.size(12)
|
||||
.width(width),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A `label : text field` row that renders read-only when `enabled` is false.
|
||||
pub fn labeled_field_enabled<'a>(
|
||||
label: Cow<'static, str>,
|
||||
value: &'a str,
|
||||
on_input: impl Fn(String) -> Message + 'a,
|
||||
width: f32,
|
||||
enabled: bool,
|
||||
) -> Element<'a, Message> {
|
||||
if enabled {
|
||||
return labeled_field(label, value, on_input, width);
|
||||
}
|
||||
row![
|
||||
row_label(label),
|
||||
crate::ui::read_only::field(value, 12.0, Length::Fixed(width)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A `label : text field` pair with its own label width, for rows that put
|
||||
/// several small values side by side.
|
||||
pub fn labeled_field_compact<'a>(
|
||||
label: Cow<'static, str>,
|
||||
value: &'a str,
|
||||
on_input: impl Fn(String) -> Message + 'a,
|
||||
label_width: f32,
|
||||
field_width: f32,
|
||||
) -> Element<'a, Message> {
|
||||
row![
|
||||
text(label).size(11).style(muted_style).width(label_width),
|
||||
text_input("", value)
|
||||
.on_input(on_input)
|
||||
.style(field_style)
|
||||
.size(12)
|
||||
.padding([3, 6])
|
||||
.width(field_width),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod scale_manager;
|
||||
pub mod anno_object_scale;
|
||||
pub mod style_manager;
|
||||
pub mod common;
|
||||
pub mod form;
|
||||
|
||||
#[cfg(test)]
|
||||
mod common_tests {
|
||||
|
||||
+565
-156
@@ -5,7 +5,10 @@
|
||||
//! PDF. Styled to match the other OCS dialogs (dark pills + fields).
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::io::paper_sizes::PaperSize;
|
||||
use crate::io::paper_catalog::{self, CustomPaper, Margins, PaperSize, PaperUnits};
|
||||
use crate::io::plot_device::PrinterCapabilities;
|
||||
use crate::ui::style::common::muted_style;
|
||||
use crate::ui::style::form;
|
||||
use iced::widget::{
|
||||
button, checkbox, column, container, mouse_area, row, scrollable, text, text_input,
|
||||
Space,
|
||||
@@ -28,25 +31,39 @@ pub const STYLE_NONE: &str = "<none>";
|
||||
/// A plot dropdown value keeps its persisted/raw value separate from the
|
||||
/// localized label shown by Iced. Printer names, scale names, paper sizes, and
|
||||
/// style-table file names remain verbatim; built-in choices use the catalog.
|
||||
/// A paper choice keeps the canonical media name as its value and shows the
|
||||
/// sheet's human name with its dimensions.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct PlotChoice {
|
||||
raw: String,
|
||||
localized: bool,
|
||||
/// Display text when it differs from `raw` (paper sizes).
|
||||
display: Option<String>,
|
||||
}
|
||||
|
||||
impl PlotChoice {
|
||||
fn raw(value: impl Into<String>) -> Self {
|
||||
Self { raw: value.into(), localized: false }
|
||||
Self { raw: value.into(), localized: false, display: None }
|
||||
}
|
||||
|
||||
fn localized(value: impl Into<String>) -> Self {
|
||||
Self { raw: value.into(), localized: true }
|
||||
Self { raw: value.into(), localized: true, display: None }
|
||||
}
|
||||
|
||||
fn paper(paper: &PaperSize) -> Self {
|
||||
Self {
|
||||
raw: paper.canonical.to_string(),
|
||||
localized: false,
|
||||
display: Some(paper.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PlotChoice {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if self.localized {
|
||||
if let Some(display) = &self.display {
|
||||
formatter.write_str(display)
|
||||
} else if self.localized {
|
||||
if let Some(name) = self.raw.strip_prefix("View: ") {
|
||||
formatter.write_str(crate::tf!("View: {name}").as_ref())
|
||||
} else {
|
||||
@@ -60,7 +77,45 @@ impl fmt::Display for PlotChoice {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PlotChoice;
|
||||
use super::{CustomPaperDraft, CustomPaperError, PlotChoice};
|
||||
use crate::io::paper_catalog::{resolve, Margins, PaperUnits};
|
||||
|
||||
#[test]
|
||||
fn custom_sheet_draft_validates_and_builds() {
|
||||
let mut draft = CustomPaperDraft {
|
||||
name: " Roll 24 ".into(),
|
||||
width: "1500".into(),
|
||||
height: "609,6".into(),
|
||||
inches: false,
|
||||
margins: ["5".into(), "17".into(), "".into(), "abc".into()],
|
||||
error: None,
|
||||
};
|
||||
let custom = draft.build().expect("valid sheet");
|
||||
assert_eq!(custom.name, "Roll 24");
|
||||
assert_eq!((custom.width, custom.height), (1500.0, 609.6));
|
||||
assert_eq!(custom.units, PaperUnits::Millimeters);
|
||||
// Blank or unparsable margins mean no margin, never a refusal.
|
||||
assert_eq!(custom.margins, Margins { left: 5.0, bottom: 17.0, right: 0.0, top: 0.0 });
|
||||
assert_eq!(custom.canonical(), "Roll_24_(609.60_x_1500.00_MM)");
|
||||
draft.width = "0".into();
|
||||
assert_eq!(draft.build().unwrap_err(), CustomPaperError::Size);
|
||||
draft.width = "x".into();
|
||||
assert_eq!(draft.build().unwrap_err(), CustomPaperError::Size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_sheet_draft_seeds_from_a_sheet_in_its_own_unit() {
|
||||
let arch_d = resolve("ARCH_D_(24.00_x_36.00_Inches)").unwrap();
|
||||
let draft = CustomPaperDraft::from_paper(&arch_d, Margins::uniform(6.35));
|
||||
assert_eq!(draft.name, "ARCH D");
|
||||
assert_eq!((draft.width.as_str(), draft.height.as_str()), ("24", "36"));
|
||||
assert!(draft.inches);
|
||||
assert_eq!(draft.margins, ["0.25", "0.25", "0.25", "0.25"]);
|
||||
let a4 = resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap();
|
||||
let draft = CustomPaperDraft::from_paper(&a4, Margins { left: 5.0, bottom: 17.0, right: 6.0, top: 18.0 });
|
||||
assert!(!draft.inches);
|
||||
assert_eq!(draft.margins, ["5", "17", "6", "18"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_view_display_preserves_the_saved_plot_area() {
|
||||
@@ -98,8 +153,26 @@ pub enum PlotDlgMsg {
|
||||
Commit,
|
||||
Preview,
|
||||
PrinterProperties,
|
||||
/// The driver options of a printer arrived (or failed to), for the
|
||||
/// in-line properties editor; carries the printer's name so a late
|
||||
/// answer for a printer the user has since left is ignored.
|
||||
PrinterOptionsLoaded(String, Result<Vec<crate::io::print_to_printer::PrinterOption>, String>),
|
||||
/// One option of the properties editor was set to a choice keyword.
|
||||
PrinterOptionSet(String, String),
|
||||
/// Keep the editor's choices for the printer and close it.
|
||||
PrinterOptionsApply,
|
||||
/// Put every option back to the driver's default.
|
||||
PrinterOptionsReset,
|
||||
/// Close the editor without keeping its changes.
|
||||
PrinterOptionsCancel,
|
||||
Printer(String),
|
||||
/// A printer answered (or failed) the media query started when it was
|
||||
/// selected; carries the printer's name so a stale answer for a printer
|
||||
/// the user has since left is ignored.
|
||||
PrinterMedia(String, Option<std::sync::Arc<PrinterCapabilities>>),
|
||||
Paper(String),
|
||||
/// Edits of the inline "custom paper size" editor under the Size row.
|
||||
CustomPaper(CustomPaperMsg),
|
||||
Orientation(String),
|
||||
Area(String),
|
||||
Scale(String),
|
||||
@@ -134,6 +207,151 @@ pub enum PlotDlgMsg {
|
||||
NameCancel,
|
||||
}
|
||||
|
||||
/// Which side of the printable-margin row a value belongs to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MarginSide {
|
||||
Left,
|
||||
Bottom,
|
||||
Right,
|
||||
Top,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CustomPaperMsg {
|
||||
/// Open the editor, seeded from the selected sheet.
|
||||
Open,
|
||||
Cancel,
|
||||
Name(String),
|
||||
Width(String),
|
||||
Height(String),
|
||||
/// Units picked from the dropdown, by their catalogue label.
|
||||
Units(String),
|
||||
Margin(MarginSide, String),
|
||||
/// Add (or replace) the sheet and select it.
|
||||
Add,
|
||||
/// Remove the selected user-defined sheet.
|
||||
Remove,
|
||||
}
|
||||
|
||||
/// The inline custom-sheet editor's fields, kept as typed text so a half-typed
|
||||
/// number does not snap to a value while editing. Everything is in the
|
||||
/// draft's own units.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct CustomPaperDraft {
|
||||
pub name: String,
|
||||
pub width: String,
|
||||
pub height: String,
|
||||
pub inches: bool,
|
||||
/// Left, bottom, right, top.
|
||||
pub margins: [String; 4],
|
||||
/// Why the last Add was refused, shown under the fields.
|
||||
pub error: Option<CustomPaperError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CustomPaperError {
|
||||
/// Width or height is not a positive number.
|
||||
Size,
|
||||
}
|
||||
|
||||
impl CustomPaperDraft {
|
||||
fn units(&self) -> PaperUnits {
|
||||
if self.inches {
|
||||
PaperUnits::Inches
|
||||
} else {
|
||||
PaperUnits::Millimeters
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed the editor from a sheet: its dimensions and unit, and the margins
|
||||
/// given (in millimetres) converted into that unit.
|
||||
pub fn from_paper(paper: &PaperSize, margins_mm: Margins) -> Self {
|
||||
let units = paper.units;
|
||||
let fmt = |v: f64| {
|
||||
let v = units.from_mm(v);
|
||||
if (v - v.round()).abs() < 1e-6 {
|
||||
format!("{}", v.round() as i64)
|
||||
} else {
|
||||
format!("{v:.2}")
|
||||
}
|
||||
};
|
||||
let dim = |v: f64| {
|
||||
if (v - v.round()).abs() < 1e-6 {
|
||||
format!("{}", v.round() as i64)
|
||||
} else {
|
||||
format!("{v:.2}")
|
||||
}
|
||||
};
|
||||
Self {
|
||||
name: paper.label.to_string(),
|
||||
width: dim(paper.width),
|
||||
height: dim(paper.height),
|
||||
inches: units == PaperUnits::Inches,
|
||||
margins: [
|
||||
fmt(margins_mm.left),
|
||||
fmt(margins_mm.bottom),
|
||||
fmt(margins_mm.right),
|
||||
fmt(margins_mm.top),
|
||||
],
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet the fields describe, or the reason they do not describe one.
|
||||
pub fn build(&self) -> Result<CustomPaper, CustomPaperError> {
|
||||
let number = |text: &str| text.trim().replace(',', ".").parse::<f64>().ok();
|
||||
let (Some(width), Some(height)) = (number(&self.width), number(&self.height)) else {
|
||||
return Err(CustomPaperError::Size);
|
||||
};
|
||||
if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
|
||||
return Err(CustomPaperError::Size);
|
||||
}
|
||||
let margin = |text: &str| number(text).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0);
|
||||
Ok(CustomPaper {
|
||||
name: self.name.trim().to_string(),
|
||||
width,
|
||||
height,
|
||||
units: self.units(),
|
||||
margins: Margins {
|
||||
left: margin(&self.margins[0]),
|
||||
bottom: margin(&self.margins[1]),
|
||||
right: margin(&self.margins[2]),
|
||||
top: margin(&self.margins[3]),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-line printer-properties editor: the printer it belongs to, the
|
||||
/// driver's options, and the choice currently shown for each.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PrinterOptionsDraft {
|
||||
pub printer: String,
|
||||
/// `None` while the options are still being fetched.
|
||||
pub options: Option<Vec<crate::io::print_to_printer::PrinterOption>>,
|
||||
/// Current choice keyword per option key.
|
||||
pub choices: std::collections::BTreeMap<String, String>,
|
||||
/// Why the options could not be listed, when they could not.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl PrinterOptionsDraft {
|
||||
/// The choices that differ from the driver's defaults — all a job needs
|
||||
/// to pass along.
|
||||
pub fn overrides(&self) -> std::collections::BTreeMap<String, String> {
|
||||
let Some(options) = &self.options else {
|
||||
return std::collections::BTreeMap::new();
|
||||
};
|
||||
options
|
||||
.iter()
|
||||
.filter_map(|option| {
|
||||
let choice = self.choices.get(&option.key)?;
|
||||
(choice != &option.default).then(|| (option.key.clone(), choice.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Transient state backing the Plot dialog. Seeded from the layout's plot
|
||||
/// settings when the dialog opens; consumed on commit.
|
||||
// The persisted fields form the "plot" section of the app config
|
||||
@@ -147,6 +365,26 @@ pub struct PlotDialogState {
|
||||
/// sentinels.
|
||||
#[serde(skip)]
|
||||
pub printers: Vec<String>,
|
||||
/// Name of the system default printer, shown next to the default entry.
|
||||
#[serde(skip)]
|
||||
pub default_printer: Option<String>,
|
||||
/// Sheets and printable areas the selected printer reported; `None`
|
||||
/// while unknown, when the platform cannot ask, or for PDF output.
|
||||
#[serde(skip)]
|
||||
pub printer_media: Option<std::sync::Arc<PrinterCapabilities>>,
|
||||
/// Sheets the user defined, offered for every device and remembered
|
||||
/// across sessions.
|
||||
pub custom_papers: Vec<CustomPaper>,
|
||||
/// The inline custom-sheet editor while it is open.
|
||||
#[serde(skip)]
|
||||
pub custom_editor: Option<CustomPaperDraft>,
|
||||
/// Driver options chosen per printer (`printer → key → choice`), the way
|
||||
/// a plotter configuration remembers its device settings; applied to
|
||||
/// every job sent to that printer and kept across sessions.
|
||||
pub driver_options: std::collections::BTreeMap<String, std::collections::BTreeMap<String, String>>,
|
||||
/// The in-line printer-properties editor while it is open.
|
||||
#[serde(skip)]
|
||||
pub printer_editor: Option<PrinterOptionsDraft>,
|
||||
/// Chosen printer name, or `None` for the system default.
|
||||
pub printer: Option<String>,
|
||||
/// Output goes to a PDF file instead of a printer.
|
||||
@@ -210,9 +448,15 @@ impl Default for PlotDialogState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
printers: Vec::new(),
|
||||
default_printer: None,
|
||||
printer_media: None,
|
||||
custom_papers: Vec::new(),
|
||||
custom_editor: None,
|
||||
driver_options: std::collections::BTreeMap::new(),
|
||||
printer_editor: None,
|
||||
printer: None,
|
||||
to_file: false,
|
||||
paper: "A4".into(),
|
||||
paper: paper_catalog::default_paper().canonical.to_string(),
|
||||
paper_width_mm: 297.0,
|
||||
paper_height_mm: 210.0,
|
||||
orientation: "Landscape".into(),
|
||||
@@ -290,80 +534,19 @@ fn legacy_fit_to_paper_default() -> bool {
|
||||
}
|
||||
|
||||
fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
move |theme: &Theme, st| {
|
||||
let palette = theme.palette();
|
||||
let pair = match (accent, st) {
|
||||
(true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong,
|
||||
(false, button::Status::Hovered | button::Status::Pressed) => {
|
||||
palette.background.strong
|
||||
}
|
||||
(true, _) => palette.primary.base,
|
||||
_ => palette.background.weak,
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(pair.color)),
|
||||
text_color: pair.text,
|
||||
border: Border {
|
||||
color: palette.background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
|
||||
let palette = theme.palette();
|
||||
let border = match status {
|
||||
text_input::Status::Focused { .. } => palette.primary.base.color,
|
||||
_ => palette.background.neutral.color,
|
||||
};
|
||||
text_input::Style {
|
||||
background: Background::Color(palette.background.base.color),
|
||||
border: Border { color: border, width: 1.0, radius: 3.0.into() },
|
||||
icon: palette.background.base.text,
|
||||
placeholder: palette.background.base.text.scale_alpha(0.48),
|
||||
value: palette.background.base.text,
|
||||
selection: palette.primary.base.color.scale_alpha(0.5),
|
||||
}
|
||||
}
|
||||
|
||||
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
|
||||
iced::widget::text::Style {
|
||||
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
|
||||
}
|
||||
form::button_style(accent)
|
||||
}
|
||||
|
||||
fn hdivider<'a>(width: Length) -> Element<'a, Message> {
|
||||
container(Space::new().width(width).height(1))
|
||||
.width(width)
|
||||
.height(1)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(
|
||||
theme.palette().background.neutral.color
|
||||
)),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
form::hdivider(width)
|
||||
}
|
||||
|
||||
fn section_label<'a>(s: Cow<'static, str>) -> Element<'a, Message> {
|
||||
text(s).size(11).style(muted_style).into()
|
||||
form::section_label(s)
|
||||
}
|
||||
|
||||
fn vsep<'a>(height: Length) -> Element<'a, Message> {
|
||||
container(Space::new().width(1).height(height))
|
||||
.width(1)
|
||||
.height(height)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(
|
||||
theme.palette().background.neutral.color
|
||||
)),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
form::vseparator(height)
|
||||
}
|
||||
|
||||
fn setup_row<'a>(
|
||||
@@ -376,7 +559,7 @@ fn setup_row<'a>(
|
||||
return text_input("", rename_buf)
|
||||
.on_input(|value| Message::PlotDlg(PlotDlgMsg::NameInput(value)))
|
||||
.on_submit(Message::PlotDlg(PlotDlgMsg::NameCommit))
|
||||
.style(field_style)
|
||||
.style(form::field_style)
|
||||
.size(11)
|
||||
.padding([4, 8])
|
||||
.width(Fit)
|
||||
@@ -414,15 +597,7 @@ fn drop_row<'a>(
|
||||
ctor: fn(String) -> PlotDlgMsg,
|
||||
width: Length,
|
||||
) -> Element<'a, Message> {
|
||||
let pl = iced::widget::pick_list(selected, options, |value| value.to_string())
|
||||
.on_select(move |choice| Message::PlotDlg(ctor(choice.raw)))
|
||||
.text_size(12)
|
||||
.padding([3, 6])
|
||||
.width(width);
|
||||
row![text(label).size(11).style(muted_style).width(92), pl]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
form::labeled_pick_list(label, options, selected, move |choice| Message::PlotDlg(ctor(choice.raw)), width)
|
||||
}
|
||||
|
||||
fn drop_row_enabled<'a>(
|
||||
@@ -433,20 +608,14 @@ fn drop_row_enabled<'a>(
|
||||
width: Length,
|
||||
enabled: bool,
|
||||
) -> Element<'a, Message> {
|
||||
if enabled {
|
||||
return drop_row(label, options, selected, ctor, width);
|
||||
}
|
||||
row![
|
||||
text(label).size(11).style(muted_style).width(92),
|
||||
crate::ui::read_only::field(
|
||||
selected.map(|choice| choice.to_string()).unwrap_or_default().as_str(),
|
||||
12.0,
|
||||
width,
|
||||
),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
form::labeled_pick_list_enabled(
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
move |choice| Message::PlotDlg(ctor(choice.raw)),
|
||||
width,
|
||||
enabled,
|
||||
)
|
||||
}
|
||||
|
||||
/// A `label : text field` row.
|
||||
@@ -456,17 +625,7 @@ fn field_row<'a>(
|
||||
ctor: fn(String) -> PlotDlgMsg,
|
||||
width: u16,
|
||||
) -> Element<'a, Message> {
|
||||
row![
|
||||
text(label).size(11).style(muted_style).width(92),
|
||||
text_input("", value)
|
||||
.on_input(move |s| Message::PlotDlg(ctor(s)))
|
||||
.style(field_style)
|
||||
.size(12)
|
||||
.width(width as f32),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
form::labeled_field(label, value, move |s| Message::PlotDlg(ctor(s)), width as f32)
|
||||
}
|
||||
|
||||
fn field_row_enabled<'a>(
|
||||
@@ -476,19 +635,9 @@ fn field_row_enabled<'a>(
|
||||
width: u16,
|
||||
enabled: bool,
|
||||
) -> Element<'a, Message> {
|
||||
if enabled {
|
||||
return field_row(label, value, ctor, width);
|
||||
}
|
||||
row![
|
||||
text(label).size(11).style(muted_style).width(92),
|
||||
crate::ui::read_only::field(value, 12.0, Length::Fixed(width as f32)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
form::labeled_field_enabled(label, value, move |s| Message::PlotDlg(ctor(s)), width as f32, enabled)
|
||||
}
|
||||
|
||||
/// A single option checkbox bound to a `PlotFlag`.
|
||||
fn check<'a>(label: Cow<'static, str>, on: bool, flag: PlotFlag) -> Element<'a, Message> {
|
||||
checkbox(on)
|
||||
.label(label)
|
||||
@@ -525,6 +674,196 @@ fn choices(items: &[&str]) -> Vec<PlotChoice> {
|
||||
items.iter().map(|&value| PlotChoice::localized(value)).collect()
|
||||
}
|
||||
|
||||
/// The in-line printer-properties editor: one `label : choice` row per
|
||||
/// driver option (media type, colour mode, quality, duplex, trays…), with
|
||||
/// Reset / Cancel / Apply. Mirrors what LibreOffice's Properties… shows for a
|
||||
/// CUPS printer, without leaving the plot dialog.
|
||||
fn printer_options_editor<'a>(draft: &'a PrinterOptionsDraft, width: Length) -> Element<'a, Message> {
|
||||
let mut rows = column![section_label(Cow::Owned(crate::tf!(
|
||||
"Printer properties — {printer}",
|
||||
printer = draft.printer
|
||||
).into_owned()))]
|
||||
.spacing(6);
|
||||
match (&draft.options, &draft.error) {
|
||||
(Some(options), _) if options.is_empty() => {
|
||||
rows = rows.push(
|
||||
text(t!("This printer reports no driver options."))
|
||||
.size(10)
|
||||
.style(muted_style),
|
||||
);
|
||||
}
|
||||
(Some(options), _) => {
|
||||
for option in options {
|
||||
let current = draft
|
||||
.choices
|
||||
.get(&option.key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| option.default.clone());
|
||||
let choices: Vec<PlotChoice> = option
|
||||
.choices
|
||||
.iter()
|
||||
.map(|choice| PlotChoice {
|
||||
raw: choice.keyword.clone(),
|
||||
localized: false,
|
||||
display: Some(choice.label.clone()),
|
||||
})
|
||||
.collect();
|
||||
let selected = choices.iter().find(|c| c.raw == current).cloned();
|
||||
let key = option.key.clone();
|
||||
rows = rows.push(form::labeled_pick_list(
|
||||
Cow::Owned(option.label.clone()),
|
||||
choices,
|
||||
selected,
|
||||
move |choice| Message::PlotDlg(PlotDlgMsg::PrinterOptionSet(key.clone(), choice.raw)),
|
||||
Length::Fill,
|
||||
));
|
||||
}
|
||||
}
|
||||
(None, Some(error)) => {
|
||||
rows = rows.push(
|
||||
text(crate::tf!("Could not read the printer's options: {error}"))
|
||||
.size(10)
|
||||
.style(|theme: &Theme| iced::widget::text::Style {
|
||||
color: Some(theme.palette().danger.base.color),
|
||||
}),
|
||||
);
|
||||
}
|
||||
(None, None) => {
|
||||
rows = rows.push(text(t!("Reading the printer's options…")).size(10).style(muted_style));
|
||||
}
|
||||
}
|
||||
let mut buttons = row![
|
||||
button(text(t!("Reset")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsReset))
|
||||
.style(btn(false))
|
||||
.padding([4, 10]),
|
||||
Space::new().width(Length::Fill),
|
||||
button(text(t!("Cancel")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsCancel))
|
||||
.style(btn(false))
|
||||
.padding([4, 10]),
|
||||
]
|
||||
.spacing(6);
|
||||
if draft.options.is_some() {
|
||||
buttons = buttons.push(
|
||||
button(text(t!("Apply")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsApply))
|
||||
.style(btn(true))
|
||||
.padding([4, 12]),
|
||||
);
|
||||
}
|
||||
rows = rows.push(buttons);
|
||||
container(rows)
|
||||
.padding(8)
|
||||
.width(width)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().background.weak.color)),
|
||||
border: Border {
|
||||
color: theme.palette().background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The inline editor for a user-defined sheet: name, dimensions and unit,
|
||||
/// printable margins, Add / Cancel. Numbers stay text until Add validates
|
||||
/// them. Every margin field carries its own side label so the four values
|
||||
/// cannot be confused, and the unit is repeated where the numbers are typed.
|
||||
fn custom_paper_editor<'a>(draft: &'a CustomPaperDraft, width: Length) -> Element<'a, Message> {
|
||||
let msg = |m: CustomPaperMsg| Message::PlotDlg(PlotDlgMsg::CustomPaper(m));
|
||||
let unit = if draft.inches { "in" } else { "mm" };
|
||||
let unit_options: Vec<PlotChoice> = vec![
|
||||
PlotChoice::localized("Millimeters"),
|
||||
PlotChoice::localized("Inches"),
|
||||
];
|
||||
let unit_selected = Some(PlotChoice::localized(if draft.inches { "Inches" } else { "Millimeters" }));
|
||||
const SIDE_LABEL: f32 = 52.0;
|
||||
const NUMBER: f32 = 64.0;
|
||||
let dimension = |label: Cow<'static, str>, value: &'a str, ctor: fn(String) -> CustomPaperMsg| {
|
||||
form::labeled_field_compact(label, value, move |v| msg(ctor(v)), SIDE_LABEL, NUMBER)
|
||||
};
|
||||
let margin = |label: Cow<'static, str>, side: MarginSide, index: usize| {
|
||||
form::labeled_field_compact(
|
||||
label,
|
||||
&draft.margins[index],
|
||||
move |v| msg(CustomPaperMsg::Margin(side, v)),
|
||||
SIDE_LABEL,
|
||||
NUMBER,
|
||||
)
|
||||
};
|
||||
let error: Element<'_, Message> = match draft.error {
|
||||
Some(CustomPaperError::Size) => text(t!("Enter a positive width and height."))
|
||||
.size(10)
|
||||
.style(|theme: &Theme| iced::widget::text::Style {
|
||||
color: Some(theme.palette().danger.base.color),
|
||||
})
|
||||
.into(),
|
||||
None => Space::new().height(0).into(),
|
||||
};
|
||||
container(
|
||||
column![
|
||||
section_label(t!("Custom paper size")),
|
||||
form::labeled_field(t!("Name"), &draft.name, move |v| msg(CustomPaperMsg::Name(v)), 200.0),
|
||||
form::labeled_pick_list(
|
||||
t!("Units"),
|
||||
unit_options,
|
||||
unit_selected,
|
||||
move |choice| msg(CustomPaperMsg::Units(choice.raw)),
|
||||
Length::Fixed(150.0),
|
||||
),
|
||||
row![
|
||||
dimension(t!("Width"), &draft.width, CustomPaperMsg::Width),
|
||||
dimension(t!("Height"), &draft.height, CustomPaperMsg::Height),
|
||||
text(unit).size(11).style(muted_style),
|
||||
]
|
||||
.spacing(14)
|
||||
.align_y(iced::Center),
|
||||
text(format!("{} ({unit})", t!("Printable margins")))
|
||||
.size(11)
|
||||
.style(muted_style),
|
||||
row![
|
||||
margin(t!("Left"), MarginSide::Left, 0),
|
||||
margin(t!("Right"), MarginSide::Right, 2),
|
||||
]
|
||||
.spacing(14),
|
||||
row![
|
||||
margin(t!("Top"), MarginSide::Top, 3),
|
||||
margin(t!("Bottom"), MarginSide::Bottom, 1),
|
||||
]
|
||||
.spacing(14),
|
||||
error,
|
||||
row![
|
||||
Space::new().width(Length::Fill),
|
||||
button(text(t!("Cancel")).size(11))
|
||||
.on_press(msg(CustomPaperMsg::Cancel))
|
||||
.style(btn(false))
|
||||
.padding([4, 10]),
|
||||
button(text(t!("Add")).size(11))
|
||||
.on_press(msg(CustomPaperMsg::Add))
|
||||
.style(btn(true))
|
||||
.padding([4, 12]),
|
||||
]
|
||||
.spacing(6),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.padding(8)
|
||||
.width(width)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().background.weak.color)),
|
||||
border: Border {
|
||||
color: theme.palette().background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn view_window(
|
||||
s: &PlotDialogState,
|
||||
print_all_options: bool,
|
||||
@@ -571,7 +910,7 @@ pub fn view_window(
|
||||
text_input("", rename_buf)
|
||||
.on_input(|value| Message::PlotDlg(PlotDlgMsg::NameInput(value)))
|
||||
.on_submit(Message::PlotDlg(PlotDlgMsg::NameCommit))
|
||||
.style(field_style)
|
||||
.style(form::field_style)
|
||||
.size(11)
|
||||
.padding([4, 8])
|
||||
.width(Length::Fill),
|
||||
@@ -648,7 +987,17 @@ pub fn view_window(
|
||||
.spacing(4);
|
||||
|
||||
// ── Printer / plotter ─────────────────────────────────────────────────
|
||||
let mut printer_opts = vec![PlotChoice::localized(OUT_DEFAULT)];
|
||||
// The default entry names the printer it resolves to, when known, so the
|
||||
// user sees where a plot will go without leaving the dialog.
|
||||
let default_entry = match &s.default_printer {
|
||||
Some(name) => PlotChoice {
|
||||
raw: OUT_DEFAULT.to_string(),
|
||||
localized: true,
|
||||
display: Some(format!("{} ({name})", crate::i18n::translate(OUT_DEFAULT))),
|
||||
},
|
||||
None => PlotChoice::localized(OUT_DEFAULT),
|
||||
};
|
||||
let mut printer_opts = vec![default_entry.clone()];
|
||||
printer_opts.extend(s.printers.iter().cloned().map(PlotChoice::raw));
|
||||
printer_opts.push(PlotChoice::localized(OUT_PDF));
|
||||
let printer_sel = if s.to_file {
|
||||
@@ -656,13 +1005,44 @@ pub fn view_window(
|
||||
} else {
|
||||
Some(match &s.printer {
|
||||
Some(printer) => PlotChoice::raw(printer.clone()),
|
||||
None => PlotChoice::localized(OUT_DEFAULT),
|
||||
None => default_entry,
|
||||
})
|
||||
};
|
||||
let mut paper_opts: Vec<String> = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect();
|
||||
if !paper_opts.iter().any(|name| name == &s.paper) {
|
||||
paper_opts.push(s.paper.clone());
|
||||
// A printer that reported its media lists those sheets (with the
|
||||
// printable areas it will actually honour); PDF output and printers that
|
||||
// could not be asked list the catalogue by series. A sheet that only
|
||||
// exists in the drawing (a driver's custom size, an old config's bare
|
||||
// name) is added at the top so the current selection stays selectable.
|
||||
let selected_paper = paper_catalog::from_drawing(&s.paper, s.paper_width_mm, s.paper_height_mm);
|
||||
let printer_sheets = (!s.to_file).then_some(()).and(s.printer_media.as_deref());
|
||||
let mut paper_opts: Vec<PlotChoice> = match printer_sheets {
|
||||
Some(caps) => caps.media.iter().map(|media| PlotChoice::paper(&media.paper)).collect(),
|
||||
None => paper_catalog::catalog().iter().map(PlotChoice::paper).collect(),
|
||||
};
|
||||
// User-defined sheets come last, after whatever the device offers.
|
||||
for custom in &s.custom_papers {
|
||||
let choice = PlotChoice::paper(&custom.paper());
|
||||
if !paper_opts.contains(&choice) {
|
||||
paper_opts.push(choice);
|
||||
}
|
||||
}
|
||||
let paper_sel = PlotChoice::paper(&selected_paper);
|
||||
if !paper_opts.contains(&paper_sel) {
|
||||
paper_opts.insert(0, paper_sel.clone());
|
||||
}
|
||||
let selected_is_custom = s
|
||||
.custom_papers
|
||||
.iter()
|
||||
.any(|custom| custom.canonical() == selected_paper.canonical);
|
||||
let paper_source_note: Element<'_, Message> = if printer_sheets.is_some() {
|
||||
text(t!("Sheet sizes and printable areas reported by the printer."))
|
||||
.size(10)
|
||||
.style(muted_style)
|
||||
.width(width)
|
||||
.into()
|
||||
} else {
|
||||
Space::new().height(0).into()
|
||||
};
|
||||
let paper_note: Element<'_, Message> = if s.area == "Layout" {
|
||||
text(t!("Layout plots the current sheet using the selected paper size."))
|
||||
.size(10)
|
||||
@@ -672,7 +1052,11 @@ pub fn view_window(
|
||||
} else {
|
||||
Space::new().height(0).into()
|
||||
};
|
||||
let mut output_row = row![
|
||||
// Heading and picker share one line — the section has nothing else to
|
||||
// announce — and the picker takes the whole row so a long queue name
|
||||
// stays readable; the driver's Properties… button sits beneath it.
|
||||
let output_field = row![
|
||||
text(t!("Printer / plotter")).size(11).style(muted_style).width(form::LABEL_WIDTH),
|
||||
iced::widget::pick_list(printer_sel, printer_opts, |value| value.to_string())
|
||||
.on_select(|choice| Message::PlotDlg(PlotDlgMsg::Printer(choice.raw)))
|
||||
.text_size(12)
|
||||
@@ -680,45 +1064,70 @@ pub fn view_window(
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center);
|
||||
if !s.to_file {
|
||||
output_row = output_row.push(
|
||||
.align_y(iced::Center)
|
||||
.width(Length::Fill);
|
||||
let printer_controls: Element<'_, Message> = if s.to_file {
|
||||
Space::new().height(0).into()
|
||||
} else {
|
||||
row![
|
||||
field_row(t!("Copies"), &s.copies, PlotDlgMsg::Copies, 60),
|
||||
Space::new().width(Length::Fill),
|
||||
button(text(t!("Properties…")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterProperties))
|
||||
.style(btn(false))
|
||||
.padding([4, 8]),
|
||||
);
|
||||
}
|
||||
let output_field = column![
|
||||
text(t!("Output")).size(11).style(muted_style),
|
||||
output_row,
|
||||
]
|
||||
.spacing(4)
|
||||
.width(Length::Fill);
|
||||
let copies_row: Element<'_, Message> = if s.to_file {
|
||||
Space::new().height(0).into()
|
||||
} else {
|
||||
field_row(t!("Copies"), &s.copies, PlotDlgMsg::Copies, 60)
|
||||
};
|
||||
let printer_panel = panel(
|
||||
column![
|
||||
section_label(t!("Printer / plotter")),
|
||||
output_field,
|
||||
copies_row,
|
||||
.padding([4, 10]),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
};
|
||||
// A printer with remembered driver choices says so under its row; the
|
||||
// editor itself unfolds there when Properties… is pressed.
|
||||
let printer_note: Element<'_, Message> = match (&s.printer_editor, &s.printer) {
|
||||
(Some(draft), _) => printer_options_editor(draft, width),
|
||||
(None, Some(printer)) if s.driver_options.get(printer).is_some_and(|o| !o.is_empty()) => {
|
||||
let count = s.driver_options[printer].len();
|
||||
text(crate::tf!("{count} driver option(s) set for this printer."))
|
||||
.size(10)
|
||||
.style(muted_style)
|
||||
.into()
|
||||
}
|
||||
_ => Space::new().height(0).into(),
|
||||
};
|
||||
let printer_panel = panel(column![output_field, printer_controls, printer_note].spacing(7));
|
||||
|
||||
// ── Paper, area, offset, scale ────────────────────────────────────────
|
||||
// The picker takes the whole row so long media names stay readable; the
|
||||
// custom-sheet buttons sit right-aligned beneath it, and the editor
|
||||
// unfolds in their place.
|
||||
let size_row = drop_row(t!("Paper size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, Length::Fill);
|
||||
let custom_controls: Element<'_, Message> = match &s.custom_editor {
|
||||
Some(draft) => custom_paper_editor(draft, width),
|
||||
None => {
|
||||
let mut buttons = row![Space::new().width(Length::Fill)].spacing(6);
|
||||
if selected_is_custom {
|
||||
buttons = buttons.push(
|
||||
button(text(t!("Remove")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Remove)))
|
||||
.style(btn(false))
|
||||
.padding([4, 10]),
|
||||
);
|
||||
}
|
||||
buttons
|
||||
.push(
|
||||
button(text(t!("Custom…")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Open)))
|
||||
.style(btn(false))
|
||||
.padding([4, 10]),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
};
|
||||
// Like the printer row: the section is its one picker, so heading and
|
||||
// control share a line.
|
||||
let paper_panel = panel(column![
|
||||
section_label(t!("Paper")),
|
||||
drop_row(
|
||||
t!("Size"),
|
||||
paper_opts.into_iter().map(PlotChoice::raw).collect(),
|
||||
Some(PlotChoice::raw(s.paper.clone())),
|
||||
PlotDlgMsg::Paper,
|
||||
width,
|
||||
),
|
||||
size_row,
|
||||
custom_controls,
|
||||
paper_source_note,
|
||||
paper_note,
|
||||
].spacing(7));
|
||||
|
||||
|
||||
Reference in new issue
Block a user