From 567eb909253d69a3e1874af879243686b23083fa Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:44:33 +0300 Subject: [PATCH 1/5] feat(plot): paper catalogue with AutoCAD canonical media names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutoCAD stores a layout's sheet as the plotter driver's canonical media name (`ISO_A4_(210.00_x_297.00_MM)`, `ANSI_full_bleed_B_(11.00_x_17.00_Inches)`), so a drawing saved here has to carry the same spelling for AutoCAD to find the sheet in its DWG To PDF list, and a name that arrives from AutoCAD must resolve to its dimensions and go back out untouched. - New `io::paper_catalog` replaces the eight-entry `paper_sizes`: ISO A/B, ANSI, ARCH (incl. E1) and JIS series with full-bleed / expand variants, Letter / Legal / Tabloid, millimetre and inch units, a parser that reconstructs any well-formed AutoCAD name (custom or driver-specific) from its embedded dimensions, driver / legacy-config aliases (`A4`, `Letter`, `Ledger`), and dimension matching that prefers the plain sheet. - The Plot dialog stores the canonical name and shows the sheet as `ISO A4 (210 × 297 mm)`; a sheet that only exists in the drawing is listed first so it stays selectable. - A sheet the user did not change keeps the drawing's own media name (`paper_name_for_settings`), so a Windows printer's `A4` is not rewritten to `ISO_A4_(…)` just by passing through the dialog. - Drops the unused `PlotFormat` / `PlotOrientation` messages, `paper_label_from_dims`, and three duplicated `match "A4" | …` tables. Tests: catalogue spelling, parser round-trip, aliases, dimension matching, display formatting, plus dialog ↔ layout round-trips for driver-named, catalogue and unknown sheets. Co-Authored-By: Claude Opus 5 --- src/app/mod.rs | 14 +- src/app/update/file.rs | 216 ++++++++---- src/app/update/mod.rs | 8 - src/app/update/util.rs | 21 -- src/io/mod.rs | 2 +- src/io/paper_catalog.rs | 712 ++++++++++++++++++++++++++++++++++++++++ src/io/paper_sizes.rs | 143 -------- src/ui/window/plot.rs | 46 ++- 8 files changed, 909 insertions(+), 253 deletions(-) create mode 100644 src/io/paper_catalog.rs delete mode 100644 src/io/paper_sizes.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index a46a17382..3a3107d67 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 @@ -3317,10 +3319,6 @@ pub enum Message { PlotExport, /// Callback after the user picks (or cancels) the export path. PlotExportPath(Option), - /// 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. @@ -3944,8 +3942,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, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 6d20f0430..4cb7e81e2 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -88,32 +88,60 @@ 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)) +} + +/// 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 AutoCAD 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 { + let unchanged = !stored.paper_size.is_empty() + && crate::io::paper_catalog::from_drawing( + &stored.paper_size, + stored.paper_width, + stored.paper_height, + ) + .canonical + == d.paper; + if unchanged { + stored.paper_size.clone() + } else { + d.paper.clone() } } @@ -2916,9 +2944,9 @@ impl OpenCADStudio { .clone() .or_else(|| self.tabs[i].scene.plot_settings_for(&layout_name)) .unwrap_or_else(|| PlotSettings::new("")); + ps.paper_size = paper_name_for_settings(&dialog, &ps); 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, @@ -3637,7 +3665,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 { - 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 @@ -3671,7 +3699,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", @@ -4100,10 +4129,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 +4194,9 @@ impl OpenCADStudio { settings } }; + ps.paper_size = paper_name_for_settings(d, &ps); 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, @@ -4281,16 +4311,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 AutoCAD 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; @@ -4381,25 +4418,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) { @@ -4651,7 +4672,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 { - 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 +4832,84 @@ 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 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)"); + } +} diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index bfff47d56..e79247d0c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -8497,14 +8497,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] diff --git a/src/app/update/util.rs b/src/app/update/util.rs index cb78c36ab..8115d3f40 100644 --- a/src/app/update/util.rs +++ b/src/app/update/util.rs @@ -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); diff --git a/src/io/mod.rs b/src/io/mod.rs index 55ae70b1f..886f49aee 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -20,7 +20,7 @@ pub mod xref_model; pub mod linetypes; pub mod patterns; pub mod update_check; -pub mod paper_sizes; +pub mod paper_catalog; pub mod thumbnail; #[cfg(target_arch = "wasm32")] mod web_worker; diff --git a/src/io/paper_catalog.rs b/src/io/paper_catalog.rs new file mode 100644 index 000000000..04e7164ee --- /dev/null +++ b/src/io/paper_catalog.rs @@ -0,0 +1,712 @@ +//! Paper catalogue with AutoCAD-compatible media names. +//! +//! AutoCAD 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 AutoCAD 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 AutoCAD (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. AutoCAD 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)] +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 AutoCAD 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 { + 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, +} + +/// AutoCAD 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 AutoCAD 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, +} + +/// One sheet of the catalogue, or a sheet reconstructed from a media name. +#[derive(Clone, PartialEq, Debug)] +pub struct PaperSize { + /// AutoCAD 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 AutoCAD 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 (width, height) = if width <= height { + (width, height) + } else { + (height, width) + }; + let label = match units { + PaperUnits::Millimeters => "UserDefinedMetric", + PaperUnits::Inches => "UserDefinedInches", + }; + Self { + canonical: Cow::Owned(canonical_name(label, width, height, units)), + label: Cow::Borrowed(label), + series: PaperSeries::Other, + variant: PaperVariant::Standard, + width, + height, + units, + } + } + + /// Rebuild a sheet from any media name that follows AutoCAD's + /// `_(_x__)` 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 { + 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_nan() + || height.is_nan() + { + return None; + } + let label = raw_label.replace('_', " "); + let (series, variant) = classify_label(&label); + Some(Self { + canonical: Cow::Owned(canonical_name( + &raw_label.replace(' ', "_"), + width, + height, + units, + )), + label: Cow::Owned(label), + series, + variant, + width, + 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}"), + } +} + +/// `_(_x__)` exactly as AutoCAD 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 AutoCAD writes it, sheet letter, +/// portrait width/height, units, and whether AutoCAD 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 AutoCAD'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 { + 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> = 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 AutoCAD name, which is reconstructed from its +/// embedded dimensions. `None` only for names that carry no size at all. +pub fn resolve(name: &str) -> Option { + 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(); + } + 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_autocad_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_autocad_names_are_reconstructed_and_preserved() { + // A custom size created in AutoCAD's 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(), (420.0, 297.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)); + } + + #[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 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}"); + } +} diff --git a/src/io/paper_sizes.rs b/src/io/paper_sizes.rs deleted file mode 100644 index 916a961e3..000000000 --- a/src/io/paper_sizes.rs +++ /dev/null @@ -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}"); - } -} diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index f4d744990..45f992c30 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -5,7 +5,7 @@ //! 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, PaperSize}; use iced::widget::{ button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, Space, @@ -28,25 +28,39 @@ pub const STYLE_NONE: &str = ""; /// 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, } impl PlotChoice { fn raw(value: impl Into) -> Self { - Self { raw: value.into(), localized: false } + Self { raw: value.into(), localized: false, display: None } } fn localized(value: impl Into) -> 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 { @@ -212,7 +226,7 @@ impl Default for PlotDialogState { printers: Vec::new(), 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(), @@ -659,9 +673,17 @@ pub fn view_window( None => PlotChoice::localized(OUT_DEFAULT), }) }; - let mut paper_opts: Vec = 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()); + // The picker lists 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 is always visible and re-selectable. + let selected_paper = paper_catalog::from_drawing(&s.paper, s.paper_width_mm, s.paper_height_mm); + let mut paper_opts: Vec = paper_catalog::catalog() + .iter() + .map(PlotChoice::paper) + .collect(); + let paper_sel = PlotChoice::paper(&selected_paper); + if !paper_opts.contains(&paper_sel) { + paper_opts.insert(0, paper_sel.clone()); } let paper_note: Element<'_, Message> = if s.area == "Layout" { text(t!("Layout plots the current sheet using the selected paper size.")) @@ -712,13 +734,7 @@ pub fn view_window( // ── Paper, area, offset, scale ──────────────────────────────────────── 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, - ), + drop_row(t!("Size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, width), paper_note, ].spacing(7)); From c03a40cb334c311d1bf99676874a733046f27ac2 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:22:45 +0300 Subject: [PATCH 2/5] feat(plot): plot device model with AutoCAD names and printable areas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page setup names its plotter, and AutoCAD looks that name up in its own configurations: `DWG To PDF.pc3` for PDF output, the spooler name for a system printer, `None` when nothing is assigned. Drawings saved here used to carry the dialog's label ("Save to PDF file…"), so AutoCAD reported a missing device on every layout plotted to PDF from Open CAD Studio. - New `io::plot_device`: `PlotDevice { None, Pdf, Printer }` with AutoCAD's spellings, a reader for stored names that keeps any plotter configuration AutoCAD wrote (an HP `.pc3`, `DWF6 ePlot.pc3` — plotted to PDF here but preserved for AutoCAD) and upgrades the legacy label. - Printable areas: the PDF driver's margins per sheet variant (full bleed edge to edge; standard / expand insets provisional until read off AutoCAD's plotter configuration), and for CUPS printers the media and margins the queue reports over IPP (`ipptool` `media-col-database`), parsed once per printer and cached. Windows enumeration follows with native GDI printing. - Page setups now store the device in AutoCAD's spelling and the margins of the chosen device/sheet, keeping the drawing's own values while neither changed (AutoCAD's `PAPERUPDATE` behaviour). The paper-space printable-area rectangle follows. - Plot dialog: the default entry names the printer it resolves to; a printer that answered lists its own sheets with a note; the media query runs in the background and answers for a since-deselected printer are dropped. Tests: device names, stored-name resolution, PDF margin table, IPP media parsing (sizes, margins, borderless folding, default sheet), PWG names, plus dialog ↔ layout round-trips for PDF, legacy label, preserved plotter configuration, sheet change → margins, and printer selection. Co-Authored-By: Claude Opus 5 --- locales/ar-SA/opencadstudio.ftl | 1 + locales/bg-BG/opencadstudio.ftl | 1 + locales/cs-CZ/opencadstudio.ftl | 1 + locales/de-DE/opencadstudio.ftl | 1 + locales/el-GR/opencadstudio.ftl | 1 + locales/en-US/opencadstudio.ftl | 1 + locales/es-ES/opencadstudio.ftl | 1 + locales/fi-FI/opencadstudio.ftl | 1 + locales/fr-FR/opencadstudio.ftl | 1 + locales/hi-IN/opencadstudio.ftl | 1 + locales/hu-HU/opencadstudio.ftl | 1 + locales/it-IT/opencadstudio.ftl | 1 + locales/ja-JP/opencadstudio.ftl | 1 + locales/ko-KR/opencadstudio.ftl | 1 + locales/nl-NL/opencadstudio.ftl | 1 + locales/pl-PL/opencadstudio.ftl | 1 + locales/pt-BR/opencadstudio.ftl | 1 + locales/ru-RU/opencadstudio.ftl | 1 + locales/tr-TR/opencadstudio.ftl | 1 + locales/zh-CN/opencadstudio.ftl | 1 + locales/zh-TW/opencadstudio.ftl | 1 + src/app/update/file.rs | 238 ++++++++++++++-- src/io/mod.rs | 1 + src/io/plot_device.rs | 482 ++++++++++++++++++++++++++++++++ src/locale_catalog.rs | 1 + src/ui/window/plot.rs | 55 +++- 26 files changed, 760 insertions(+), 38 deletions(-) create mode 100644 src/io/plot_device.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 393bce6b8..f54eb5651 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -5025,6 +5025,7 @@ plot = .export-pdf = تصدير PDF .fit-to-paper = ملاءمة للورق .layout-plots-the-current-sheet-using-the-selec = يطبع التخطيط الورقة الحالية باستخدام حجم الورق المحدد. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = دمج الخطوط المتداخلة .object-lineweights = سماكات خطوط الكائنات .orientation = الاتجاه diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index fa07928a7..4cd537a82 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -5013,6 +5013,7 @@ plot = .export-pdf = Експортирай PDF .fit-to-paper = Побери в хартията .layout-plots-the-current-sheet-using-the-selec = Оформлението плотира текущия лист, използвайки избрания размер хартия. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Обедини припокриващи се линии .object-lineweights = Дебелини на линии на обект .orientation = Ориентация diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 657eadb7a..cd37022e6 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -5010,6 +5010,7 @@ plot = .export-pdf = Exportujte PDF .fit-to-paper = Přizpůsobit papíru .layout-plots-the-current-sheet-using-the-selec = Rozvržení vykreslí aktuální list pomocí zvolené velikosti papíru. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Sloučit překrývající se čáry .object-lineweights = Tloušťky čar objektů .orientation = Orientace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 2ee047680..e41fa3534 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -5011,6 +5011,7 @@ plot = .export-pdf = PDF exportieren .fit-to-paper = An Papier anpassen .layout-plots-the-current-sheet-using-the-selec = Das Layout plottet das aktuelle Blatt mit dem ausgewählten Papierformat. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Zusammenführung überlappender Leitungen .object-lineweights = Objektliniengewichte .orientation = Ausrichtung diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 77a87777c..5911988ce 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -5013,6 +5013,7 @@ plot = .export-pdf = Εξαγωγή PDF .fit-to-paper = Προσαρμογή στο χαρτί .layout-plots-the-current-sheet-using-the-selec = Η επιλογή Διάταξη εκτυπώνει το τρέχον φύλλο με το επιλεγμένο μέγεθος χαρτιού. + .sheet-sizes-and-printable-areas-reported-by-th = Μεγέθη φύλλων και εκτυπώσιμες περιοχές όπως τα αναφέρει ο εκτυπωτής. .merge-overlapping-lines = Συγχώνευση επικαλυπτόμενων γραμμών .object-lineweights = Πάχη γραμμών αντικειμένων .orientation = Προσανατολισμός diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index c767a4e9d..eddfd0028 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -5014,6 +5014,7 @@ plot = .export-pdf = Export PDF .fit-to-paper = Fit to paper .layout-plots-the-current-sheet-using-the-selec = Layout plots the current sheet using the selected paper size. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Merge overlapping lines .object-lineweights = Object lineweights .orientation = Orientation diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 7aebd0d2e..2a52f63aa 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -5013,6 +5013,7 @@ plot = .export-pdf = Exportar PDF .fit-to-paper = Ajustar al papel .layout-plots-the-current-sheet-using-the-selec = La presentación traza la hoja actual con el tamaño de papel seleccionado. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Fusionar líneas solapadas .object-lineweights = Grosores de línea de objetos .orientation = Orientación diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index b9404a17a..e3fbfd865 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -5012,6 +5012,7 @@ plot = .export-pdf = Vie PDF .fit-to-paper = Sovita paperille .layout-plots-the-current-sheet-using-the-selec = Asettelu piirtää nykyisen arkin käyttämällä valittua paperikokoa. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Yhdistä päällekkäiset rivit .object-lineweights = Objektin viivan painot .orientation = Suuntautuminen diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index cae171e76..0cbfa07b4 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -5013,6 +5013,7 @@ plot = .export-pdf = Exportation PDF .fit-to-paper = Ajuster au papier .layout-plots-the-current-sheet-using-the-selec = La présentation trace la feuille courante avec le format de papier sélectionné. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Fusionner les lignes qui se chevauchent .object-lineweights = Poids de ligne d'objet .orientation = Orientation diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 01edb1512..5a2a4f100 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -5001,6 +5001,7 @@ plot = .export-pdf = निर्यात PDF .fit-to-paper = कागज़ पर फ़िट करें .layout-plots-the-current-sheet-using-the-selec = लेआउट चयनित कागज़ आकार पर वर्तमान शीट प्लॉट करता है। + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = मर्ज ओवरलैपिंग लाइन .object-lineweights = ऑब्जेक्ट लाइनवेट .orientation = अभिविन्यास diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 4b13d66b2..5222d1072 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -5012,6 +5012,7 @@ plot = .export-pdf = PDF exportálása .fit-to-paper = Papírra illeszthető .layout-plots-the-current-sheet-using-the-selec = Az Elrendezés az aktuális lapot a kiválasztott papírmérettel ábrázolja. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Egyesítse az átfedő vonalakat .object-lineweights = Objektumvonalvastagságok .orientation = Tájékozódás diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index af9406dff..d5277f8b7 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -5012,6 +5012,7 @@ plot = .export-pdf = Esporta PDF .fit-to-paper = Adatta alla carta .layout-plots-the-current-sheet-using-the-selec = Layout traccia il foglio corrente utilizzando il formato carta selezionato. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Unisci linee sovrapposte .object-lineweights = Spessori linea degli oggetti .orientation = Orientamento diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 13ee9599a..4d85663b0 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -5001,6 +5001,7 @@ plot = .export-pdf = PDF 書き出し .fit-to-paper = 用紙にフィット .layout-plots-the-current-sheet-using-the-selec = 選択した用紙サイズで現在のシートを印刷します。 + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = 重複線を結合 .object-lineweights = オブジェクトの線の太さ .orientation = 方向 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index c93a7cc5d..22cb62a7c 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -5012,6 +5012,7 @@ plot = .export-pdf = PDF 내보내기 .fit-to-paper = 종이에 맞추기 .layout-plots-the-current-sheet-using-the-selec = 레이아웃은 선택한 용지 크기를 사용하여 현재 시트를 플롯합니다. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = 겹치는 선 병합 .object-lineweights = 객체 선가중치 .orientation = 오리엔테이션 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 77d3c1c86..7cd127c2f 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -5011,6 +5011,7 @@ plot = .export-pdf = Exporteren PDF .fit-to-paper = Aan papier aanpassen .layout-plots-the-current-sheet-using-the-selec = De lay-out plot het huidige vel op het geselecteerde papierformaat. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Overlaplijnen samenvoegen .object-lineweights = Objectlijndiktes .orientation = Oriëntatie diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index eb4ed4594..c215855f1 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -5012,6 +5012,7 @@ plot = .export-pdf = Eksportuj PDF .fit-to-paper = Dopasuj do papieru .layout-plots-the-current-sheet-using-the-selec = Układ drukuje bieżący arkusz przy użyciu wybranego rozmiaru papieru. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Połącz nakładające się linie .object-lineweights = Szerokości linii obiektu .orientation = Orientacja diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index bc244f710..5e306b0ea 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -5013,6 +5013,7 @@ plot = .export-pdf = Exportar PDF .fit-to-paper = Ajustar ao papel .layout-plots-the-current-sheet-using-the-selec = Layout plota a folha atual usando o tamanho de papel selecionado. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Mesclar linhas sobrepostas .object-lineweights = Espessuras de linha dos objetos .orientation = Orientação diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 31b35ab9a..92727daae 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -5014,6 +5014,7 @@ plot = .export-pdf = Экспорт PDF .fit-to-paper = Вписать в лист .layout-plots-the-current-sheet-using-the-selec = Лист печатается на выбранном формате бумаги. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Слияние пересекающихся линий .object-lineweights = Весы для объектов .orientation = Ориентация diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 96a534d60..f24afc924 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -4953,6 +4953,7 @@ plot = .export-pdf = PDF'ye Aktar .fit-to-paper = Kağıda sığdır .layout-plots-the-current-sheet-using-the-selec = Yerleşim, geçerli sayfayı seçilen kağıt boyutuyla çizer. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = Çakışan çizgileri birleştir .object-lineweights = Nesne çizgi kalınlıkları .orientation = Yönelim diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 2f1bc69af..34bc20fc7 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -4996,6 +4996,7 @@ plot = .export-pdf = 导出 PDF .fit-to-paper = 布满图纸 .layout-plots-the-current-sheet-using-the-selec = 布局使用所选纸张尺寸打印当前图纸。 + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = 合并重叠线 .object-lineweights = 对象线宽 .orientation = 方向 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index a26fd898d..49f85db3c 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -5008,6 +5008,7 @@ plot = .export-pdf = 匯出 PDF .fit-to-paper = 布滿圖紙 .layout-plots-the-current-sheet-using-the-selec = 配置使用所選紙張尺寸列印目前圖紙。 + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .merge-overlapping-lines = 合併重疊線 .object-lineweights = 物件線寬 .orientation = 方向 diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 4cb7e81e2..9a32a2dbc 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -120,6 +120,21 @@ fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f 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 @@ -130,21 +145,62 @@ fn paper_name_for_settings( d: &crate::ui::window::plot::PlotDialogState, stored: &acadrust::objects::PlotSettings, ) -> String { - let unchanged = !stored.paper_size.is_empty() - && crate::io::paper_catalog::from_drawing( - &stored.paper_size, - stored.paper_width, - stored.paper_height, - ) - .canonical - == d.paper; - if unchanged { + 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 AutoCAD +/// 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 AutoCAD'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 AutoCAD's driver and we have +/// no better source), otherwise what our device reports for the new sheet — +/// the equivalent of AutoCAD'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)); + 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 = ( @@ -2944,7 +3000,14 @@ impl OpenCADStudio { .clone() .or_else(|| self.tabs[i].scene.plot_settings_for(&layout_name)) .unwrap_or_else(|| PlotSettings::new("")); - ps.paper_size = paper_name_for_settings(&dialog, &ps); + // 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.plot_type = match plot_area { @@ -2983,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; @@ -3688,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; @@ -3771,7 +3830,29 @@ 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 { + 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)), + ) } fn normalize_common_plot_dialog(&mut self) { @@ -3805,6 +3886,13 @@ 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 => { @@ -3967,7 +4055,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(); @@ -4194,7 +4283,12 @@ impl OpenCADStudio { settings } }; - ps.paper_size = paper_name_for_settings(d, &ps); + 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.plot_type = match d.area.as_str() { @@ -4242,11 +4336,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; @@ -4402,12 +4491,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; @@ -4897,6 +4993,90 @@ mod plot_paper_tests { assert_eq!(app.plot_paper.canonical, "ISO_A3_(297.00_x_420.00_MM)"); } + #[test] + fn pdf_output_is_stored_as_the_autocad_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, 5.0, 5.0, 5.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_autocad_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, "AutoCAD'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, 5.0, 5.0, 5.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, 0.0, 0.0, 0.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, 5.0, 5.0, 5.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 an_unknown_sheet_from_a_drawing_keeps_its_own_dimensions() { let mut app = app_with_printer_named_sheet(); diff --git a/src/io/mod.rs b/src/io/mod.rs index 886f49aee..8bc9c7506 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -21,6 +21,7 @@ pub mod linetypes; pub mod patterns; pub mod update_check; pub mod paper_catalog; +pub mod plot_device; pub mod thumbnail; #[cfg(target_arch = "wasm32")] mod web_worker; diff --git a/src/io/plot_device.rs b/src/io/plot_device.rs new file mode 100644 index 000000000..a428c0f39 --- /dev/null +++ b/src/io/plot_device.rs @@ -0,0 +1,482 @@ +//! Plot devices: what a page setup names as its plotter, and what that device +//! can print. +//! +//! AutoCAD 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 AutoCAD attaches the +//! layout to its own PDF driver instead of reporting a missing device; and a +//! device name that arrives from AutoCAD (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 AutoCAD 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. + +use crate::io::paper_catalog::{self, PaperSize, PaperUnits, PaperVariant}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +/// AutoCAD's plotter configuration name for PDF output. +pub const PDF_DEVICE_NAME: &str = "DWG To PDF.pc3"; +/// AutoCAD's spelling for "no plotter assigned". +pub const NO_DEVICE_NAME: &str = "None"; +/// 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 (AutoCAD `None`). We plot to the system default + /// printer in that case, which is what the plot dialog offers. + None, + /// PDF file output, AutoCAD's `DWG To PDF.pc3`. + Pdf, + /// A system printer by its queue / spooler name. + Printer(String), +} + +impl PlotDevice { + /// The name AutoCAD would store for this device. + pub fn canonical_name(&self) -> String { + match self { + PlotDevice::None => NO_DEVICE_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 AutoCAD 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 AutoCAD; 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 == LEGACY_PDF_LABEL { + return (PlotDevice::Pdf, false); + } + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".pc3") { + // Any AutoCAD plotter configuration: PDF ones are ours to drive, + // the rest plot to PDF here but keep their name for AutoCAD. + return (PlotDevice::Pdf, true); + } + (PlotDevice::Printer(name.to_string()), true) + } + + /// Unprintable margins the device imposes on `paper`, in millimetres. + pub fn margins_mm(&self, paper: &PaperSize) -> Margins { + match self { + PlotDevice::Pdf => pdf_margins_mm(paper), + PlotDevice::Printer(name) => printer_margins_mm(name, paper), + // No device: AutoCAD keeps the margins of the last device or its + // defaults; without one we show the full sheet as printable. + PlotDevice::None => Margins::ZERO, + } + } +} + +/// Unprintable margins in millimetres, in the order AutoCAD stores them. +#[derive(Clone, Copy, Debug, PartialEq)] +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, + } + } +} + +// ── PDF driver ──────────────────────────────────────────────────────────── + +/// Margins of AutoCAD's `DWG To PDF.pc3` sheets. +/// +/// Full-bleed sheets print edge to edge by definition. The standard and +/// expand values are provisional — the driver's own printable areas have to +/// be read off AutoCAD's Plotter Configuration Editor (Modify Standard Paper +/// Sizes) and copied here; until then a symmetric inset keeps the printable +/// rectangle visibly distinct from the sheet. +const PDF_STANDARD_MARGIN_MM: f64 = 5.0; +const PDF_EXPAND_MARGIN_MM: f64 = 2.0; + +fn pdf_margins_mm(paper: &PaperSize) -> Margins { + match paper.variant { + PaperVariant::FullBleed => Margins::ZERO, + PaperVariant::Expand => Margins::uniform(PDF_EXPAND_MARGIN_MM), + PaperVariant::Standard => Margins::uniform(PDF_STANDARD_MARGIN_MM), + } +} + +// ── 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, + /// The printer's default sheet, when reported. + pub default_paper: Option, +} + +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 { + 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>> { + static CACHE: OnceLock>>> = 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> { + 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> { + 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) +} + +fn printer_margins_mm(printer: &str, paper: &PaperSize) -> Margins { + cached_printer_capabilities(printer) + .and_then(|caps| caps.margins_for(paper)) + .unwrap_or(Margins::ZERO) +} + +/// 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 { + // `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 { + None +} + +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn query_printer_capabilities(printer: &str) -> Option { + // 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 { + 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 { + let database = attribute_value(text, "media-col-database")?; + let mut media: Vec = 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 { + 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::().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_autocad() { + assert_eq!(PlotDevice::Pdf.canonical_name(), "DWG To PDF.pc3"); + assert_eq!(PlotDevice::None.canonical_name(), "None"); + 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("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("AutoCAD 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_depend_on_the_sheet_variant() { + let a4 = paper_catalog::resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap(); + let bleed = paper_catalog::resolve("ISO_full_bleed_A4_(210.00_x_297.00_MM)").unwrap(); + let expand = paper_catalog::resolve("ISO_expand_A4_(210.00_x_297.00_MM)").unwrap(); + assert_eq!(PlotDevice::Pdf.margins_mm(&a4), Margins::uniform(5.0)); + assert_eq!(PlotDevice::Pdf.margins_mm(&bleed), Margins::ZERO); + assert_eq!(PlotDevice::Pdf.margins_mm(&expand), Margins::uniform(2.0)); + assert_eq!(PlotDevice::None.margins_mm(&a4), Margins::ZERO); + } + + #[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); + } +} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index ee157405b..4ae3bd537 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1456,6 +1456,7 @@ 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")), "Layout renamed: '{old_name}' → '{new_name}'" => Some(("common", "layout-renamed-old-name-new-name")), "Layouts" => Some(("layout", "layouts")), "Leader" => Some(("common", "leader")), diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index 45f992c30..ee6064326 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -6,6 +6,7 @@ use crate::app::Message; use crate::io::paper_catalog::{self, PaperSize}; +use crate::io::plot_device::PrinterCapabilities; use iced::widget::{ button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, Space, @@ -113,6 +114,10 @@ pub enum PlotDlgMsg { Preview, PrinterProperties, 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>), Paper(String), Orientation(String), Area(String), @@ -161,6 +166,13 @@ pub struct PlotDialogState { /// sentinels. #[serde(skip)] pub printers: Vec, + /// Name of the system default printer, shown next to the default entry. + #[serde(skip)] + pub default_printer: Option, + /// 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>, /// Chosen printer name, or `None` for the system default. pub printer: Option, /// Output goes to a PDF file instead of a printer. @@ -224,6 +236,8 @@ impl Default for PlotDialogState { fn default() -> Self { Self { printers: Vec::new(), + default_printer: None, + printer_media: None, printer: None, to_file: false, paper: paper_catalog::default_paper().canonical.to_string(), @@ -662,7 +676,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 { @@ -670,21 +694,33 @@ pub fn view_window( } else { Some(match &s.printer { Some(printer) => PlotChoice::raw(printer.clone()), - None => PlotChoice::localized(OUT_DEFAULT), + None => default_entry, }) }; - // The picker lists 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 is always visible and re-selectable. + // 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 mut paper_opts: Vec = paper_catalog::catalog() - .iter() - .map(PlotChoice::paper) - .collect(); + let printer_sheets = (!s.to_file).then_some(()).and(s.printer_media.as_deref()); + let mut paper_opts: Vec = match printer_sheets { + Some(caps) => caps.media.iter().map(|media| PlotChoice::paper(&media.paper)).collect(), + None => paper_catalog::catalog().iter().map(PlotChoice::paper).collect(), + }; let paper_sel = PlotChoice::paper(&selected_paper); if !paper_opts.contains(&paper_sel) { paper_opts.insert(0, paper_sel.clone()); } + 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) @@ -735,6 +771,7 @@ pub fn view_window( let paper_panel = panel(column![ section_label(t!("Paper")), drop_row(t!("Size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, width), + paper_source_note, paper_note, ].spacing(7)); From a7c89eca33a908fdc932b63dd8e1cd94427d4283 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:17:11 +0300 Subject: [PATCH 3/5] fix(plot): use AutoCAD's DWG To PDF printable areas Replace the provisional PDF margins with the values AutoCAD's Plotter Configuration Editor lists for `DWG To PDF.pc3`. They depend on the sheet family and variant, not the size: ISO sheets 5/17/6/18 mm (left, bottom, right, top), ISO expand 5/10/6/11 mm, ISO full bleed 0/1/0/1 mm; ANSI and ARCH sheets 0.23/0.70 in, expand 0.23/0.42 in, full bleed 0.03 in all round. Inch sets are stored in millimetres like the sheet itself. Co-Authored-By: Claude Opus 5 --- src/app/update/file.rs | 8 +-- src/io/plot_device.rs | 141 ++++++++++++++++++++++++++++++++++------- 2 files changed, 123 insertions(+), 26 deletions(-) diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 9a32a2dbc..463d4f5d4 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -5002,7 +5002,7 @@ mod plot_paper_tests { 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, 5.0, 5.0, 5.0)); + assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0)); } #[test] @@ -5043,18 +5043,18 @@ mod plot_paper_tests { 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, 5.0, 5.0, 5.0); + 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, 0.0, 0.0, 0.0)); + 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, 5.0, 5.0, 5.0)); + 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)); } diff --git a/src/io/plot_device.rs b/src/io/plot_device.rs index a428c0f39..ac652f899 100644 --- a/src/io/plot_device.rs +++ b/src/io/plot_device.rs @@ -109,26 +109,68 @@ impl Margins { 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), + } + } } // ── PDF driver ──────────────────────────────────────────────────────────── -/// Margins of AutoCAD's `DWG To PDF.pc3` sheets. -/// -/// Full-bleed sheets print edge to edge by definition. The standard and -/// expand values are provisional — the driver's own printable areas have to -/// be read off AutoCAD's Plotter Configuration Editor (Modify Standard Paper -/// Sizes) and copied here; until then a symmetric inset keeps the printable -/// rectangle visibly distinct from the sheet. -const PDF_STANDARD_MARGIN_MM: f64 = 5.0; -const PDF_EXPAND_MARGIN_MM: f64 = 2.0; +/// Unprintable margins of AutoCAD'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 { - match paper.variant { - PaperVariant::FullBleed => Margins::ZERO, - PaperVariant::Expand => Margins::uniform(PDF_EXPAND_MARGIN_MM), - PaperVariant::Standard => Margins::uniform(PDF_STANDARD_MARGIN_MM), - } + 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 ─────────────────────────────────────────────────────── @@ -416,14 +458,69 @@ mod tests { } #[test] - fn pdf_margins_depend_on_the_sheet_variant() { - let a4 = paper_catalog::resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap(); - let bleed = paper_catalog::resolve("ISO_full_bleed_A4_(210.00_x_297.00_MM)").unwrap(); - let expand = paper_catalog::resolve("ISO_expand_A4_(210.00_x_297.00_MM)").unwrap(); - assert_eq!(PlotDevice::Pdf.margins_mm(&a4), Margins::uniform(5.0)); - assert_eq!(PlotDevice::Pdf.margins_mm(&bleed), Margins::ZERO); - assert_eq!(PlotDevice::Pdf.margins_mm(&expand), Margins::uniform(2.0)); - assert_eq!(PlotDevice::None.margins_mm(&a4), Margins::ZERO); + fn pdf_margins_follow_autocad_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] From 5e271e2dbba6ef4c5a11f7918d1e492f46b430e1 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:33:57 +0300 Subject: [PATCH 4/5] feat(plot): custom paper sizes with printable margins AutoCAD lets a plotter configuration carry user-defined sheets; a drawing then names them like any other media (`Roll_24_(609.60_x_1500.00_MM)`). The plot dialog now defines such sheets inline, under the Size row: name, width/height, unit, and the printable margins in that unit. Added sheets are selected at once, offered for every device after the device's own list, remembered in the plot config across sessions, and written to the layout with their own margins (a printer that reports the same size still wins with its hardware margins). A selected user-defined sheet can be removed; the sheet itself stays selectable in the drawing that uses it. `CustomPaper` / `Margins` live in the paper catalogue with serde support; `PlotDevice::margins_mm` consults the user's definitions. The Plot dialog's form helpers (button and field styles, dividers, `label : control` rows) move to `ui::style::form` so the next dialogs share them instead of copying. Tests: draft validation and seeding, catalogue naming of user sheets, device margins with user definitions, and dialog round-trips (add, select, persist, write, replace, remove, refuse an invalid size). Co-Authored-By: Claude Opus 5 --- locales/ar-SA/opencadstudio.ftl | 3 + locales/bg-BG/opencadstudio.ftl | 3 + locales/cs-CZ/opencadstudio.ftl | 3 + locales/de-DE/opencadstudio.ftl | 3 + locales/el-GR/opencadstudio.ftl | 3 + locales/en-US/opencadstudio.ftl | 3 + locales/es-ES/opencadstudio.ftl | 3 + locales/fi-FI/opencadstudio.ftl | 3 + locales/fr-FR/opencadstudio.ftl | 3 + locales/hi-IN/opencadstudio.ftl | 3 + locales/hu-HU/opencadstudio.ftl | 3 + locales/it-IT/opencadstudio.ftl | 3 + locales/ja-JP/opencadstudio.ftl | 3 + locales/ko-KR/opencadstudio.ftl | 3 + locales/nl-NL/opencadstudio.ftl | 3 + locales/pl-PL/opencadstudio.ftl | 3 + locales/pt-BR/opencadstudio.ftl | 3 + locales/ru-RU/opencadstudio.ftl | 3 + locales/tr-TR/opencadstudio.ftl | 3 + locales/zh-CN/opencadstudio.ftl | 3 + locales/zh-TW/opencadstudio.ftl | 3 + src/app/update/file.rs | 145 ++++++++++- src/io/paper_catalog.rs | 154 +++++++++++- src/io/plot_device.rs | 109 ++++---- src/locale_catalog.rs | 3 + src/ui/style/form.rs | 206 ++++++++++++++++ src/ui/style/mod.rs | 1 + src/ui/window/plot.rs | 424 +++++++++++++++++++++++--------- 28 files changed, 930 insertions(+), 175 deletions(-) create mode 100644 src/ui/style/form.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index f54eb5651..7130e80c8 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -5026,6 +5026,9 @@ plot = .fit-to-paper = ملاءمة للورق .layout-plots-the-current-sheet-using-the-selec = يطبع التخطيط الورقة الحالية باستخدام حجم الورق المحدد. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = دمج الخطوط المتداخلة .object-lineweights = سماكات خطوط الكائنات .orientation = الاتجاه diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 4cd537a82..a24eb249c 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -5014,6 +5014,9 @@ plot = .fit-to-paper = Побери в хартията .layout-plots-the-current-sheet-using-the-selec = Оформлението плотира текущия лист, използвайки избрания размер хартия. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Обедини припокриващи се линии .object-lineweights = Дебелини на линии на обект .orientation = Ориентация diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index cd37022e6..7d7364d18 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -5011,6 +5011,9 @@ plot = .fit-to-paper = Přizpůsobit papíru .layout-plots-the-current-sheet-using-the-selec = Rozvržení vykreslí aktuální list pomocí zvolené velikosti papíru. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Sloučit překrývající se čáry .object-lineweights = Tloušťky čar objektů .orientation = Orientace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index e41fa3534..2612d5f56 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -5012,6 +5012,9 @@ plot = .fit-to-paper = An Papier anpassen .layout-plots-the-current-sheet-using-the-selec = Das Layout plottet das aktuelle Blatt mit dem ausgewählten Papierformat. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Zusammenführung überlappender Leitungen .object-lineweights = Objektliniengewichte .orientation = Ausrichtung diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 5911988ce..a5664898f 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -5014,6 +5014,9 @@ plot = .fit-to-paper = Προσαρμογή στο χαρτί .layout-plots-the-current-sheet-using-the-selec = Η επιλογή Διάταξη εκτυπώνει το τρέχον φύλλο με το επιλεγμένο μέγεθος χαρτιού. .sheet-sizes-and-printable-areas-reported-by-th = Μεγέθη φύλλων και εκτυπώσιμες περιοχές όπως τα αναφέρει ο εκτυπωτής. + .custom-paper-size = Προσαρμοσμένο μέγεθος χαρτιού + .printable-margins = Εκτυπώσιμα περιθώρια + .enter-a-positive-width-and-height = Δώστε θετικό πλάτος και ύψος. .merge-overlapping-lines = Συγχώνευση επικαλυπτόμενων γραμμών .object-lineweights = Πάχη γραμμών αντικειμένων .orientation = Προσανατολισμός diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index eddfd0028..dbcc88cd7 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -5015,6 +5015,9 @@ plot = .fit-to-paper = Fit to paper .layout-plots-the-current-sheet-using-the-selec = Layout plots the current sheet using the selected paper size. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Merge overlapping lines .object-lineweights = Object lineweights .orientation = Orientation diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 2a52f63aa..8faefd7a0 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -5014,6 +5014,9 @@ plot = .fit-to-paper = Ajustar al papel .layout-plots-the-current-sheet-using-the-selec = La presentación traza la hoja actual con el tamaño de papel seleccionado. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Fusionar líneas solapadas .object-lineweights = Grosores de línea de objetos .orientation = Orientación diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index e3fbfd865..9de9ec986 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -5013,6 +5013,9 @@ plot = .fit-to-paper = Sovita paperille .layout-plots-the-current-sheet-using-the-selec = Asettelu piirtää nykyisen arkin käyttämällä valittua paperikokoa. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Yhdistä päällekkäiset rivit .object-lineweights = Objektin viivan painot .orientation = Suuntautuminen diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 0cbfa07b4..c2e716831 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -5014,6 +5014,9 @@ plot = .fit-to-paper = Ajuster au papier .layout-plots-the-current-sheet-using-the-selec = La présentation trace la feuille courante avec le format de papier sélectionné. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Fusionner les lignes qui se chevauchent .object-lineweights = Poids de ligne d'objet .orientation = Orientation diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 5a2a4f100..6e34cface 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -5002,6 +5002,9 @@ plot = .fit-to-paper = कागज़ पर फ़िट करें .layout-plots-the-current-sheet-using-the-selec = लेआउट चयनित कागज़ आकार पर वर्तमान शीट प्लॉट करता है। .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = मर्ज ओवरलैपिंग लाइन .object-lineweights = ऑब्जेक्ट लाइनवेट .orientation = अभिविन्यास diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 5222d1072..b204a6052 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -5013,6 +5013,9 @@ plot = .fit-to-paper = Papírra illeszthető .layout-plots-the-current-sheet-using-the-selec = Az Elrendezés az aktuális lapot a kiválasztott papírmérettel ábrázolja. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Egyesítse az átfedő vonalakat .object-lineweights = Objektumvonalvastagságok .orientation = Tájékozódás diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index d5277f8b7..68d841798 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -5013,6 +5013,9 @@ plot = .fit-to-paper = Adatta alla carta .layout-plots-the-current-sheet-using-the-selec = Layout traccia il foglio corrente utilizzando il formato carta selezionato. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Unisci linee sovrapposte .object-lineweights = Spessori linea degli oggetti .orientation = Orientamento diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 4d85663b0..6bf94b41a 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -5002,6 +5002,9 @@ plot = .fit-to-paper = 用紙にフィット .layout-plots-the-current-sheet-using-the-selec = 選択した用紙サイズで現在のシートを印刷します。 .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = 重複線を結合 .object-lineweights = オブジェクトの線の太さ .orientation = 方向 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 22cb62a7c..a79acfaa8 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -5013,6 +5013,9 @@ plot = .fit-to-paper = 종이에 맞추기 .layout-plots-the-current-sheet-using-the-selec = 레이아웃은 선택한 용지 크기를 사용하여 현재 시트를 플롯합니다. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = 겹치는 선 병합 .object-lineweights = 객체 선가중치 .orientation = 오리엔테이션 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 7cd127c2f..4a7bbc633 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -5012,6 +5012,9 @@ plot = .fit-to-paper = Aan papier aanpassen .layout-plots-the-current-sheet-using-the-selec = De lay-out plot het huidige vel op het geselecteerde papierformaat. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Overlaplijnen samenvoegen .object-lineweights = Objectlijndiktes .orientation = Oriëntatie diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index c215855f1..ede80253e 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -5013,6 +5013,9 @@ plot = .fit-to-paper = Dopasuj do papieru .layout-plots-the-current-sheet-using-the-selec = Układ drukuje bieżący arkusz przy użyciu wybranego rozmiaru papieru. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Połącz nakładające się linie .object-lineweights = Szerokości linii obiektu .orientation = Orientacja diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 5e306b0ea..68e04964a 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -5014,6 +5014,9 @@ plot = .fit-to-paper = Ajustar ao papel .layout-plots-the-current-sheet-using-the-selec = Layout plota a folha atual usando o tamanho de papel selecionado. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Mesclar linhas sobrepostas .object-lineweights = Espessuras de linha dos objetos .orientation = Orientação diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 92727daae..cbd2bfe63 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -5015,6 +5015,9 @@ plot = .fit-to-paper = Вписать в лист .layout-plots-the-current-sheet-using-the-selec = Лист печатается на выбранном формате бумаги. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Слияние пересекающихся линий .object-lineweights = Весы для объектов .orientation = Ориентация diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index f24afc924..954050cba 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -4954,6 +4954,9 @@ plot = .fit-to-paper = Kağıda sığdır .layout-plots-the-current-sheet-using-the-selec = Yerleşim, geçerli sayfayı seçilen kağıt boyutuyla çizer. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Çakışan çizgileri birleştir .object-lineweights = Nesne çizgi kalınlıkları .orientation = Yönelim diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 34bc20fc7..97d63c729 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -4997,6 +4997,9 @@ plot = .fit-to-paper = 布满图纸 .layout-plots-the-current-sheet-using-the-selec = 布局使用所选纸张尺寸打印当前图纸。 .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = 合并重叠线 .object-lineweights = 对象线宽 .orientation = 方向 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 49f85db3c..63ffd2c16 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -5009,6 +5009,9 @@ plot = .fit-to-paper = 布滿圖紙 .layout-plots-the-current-sheet-using-the-selec = 配置使用所選紙張尺寸列印目前圖紙。 .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = 合併重疊線 .object-lineweights = 物件線寬 .orientation = 方向 diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 463d4f5d4..6ac933b02 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -197,7 +197,7 @@ fn margins_for_settings( if paper_unchanged(d, stored) && stored_device == device { return stored.margins; } - let margins = device.margins_mm(&plot_dialog_paper(d)); + let margins = device.margins_mm(&plot_dialog_paper(d), &d.custom_papers); acadrust::objects::PaperMargin::new(margins.left, margins.bottom, margins.right, margins.top) } @@ -3855,6 +3855,86 @@ impl OpenCADStudio { ) } + /// 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 (AutoCAD'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; + } + } + fn normalize_common_plot_dialog(&mut self) { self.plot_dialog.center = true; self.plot_dialog.offset_x = "0.0".into(); @@ -3895,6 +3975,10 @@ impl OpenCADStudio { } Task::none() } + M::CustomPaper(msg) => { + self.on_custom_paper_msg(msg); + Task::none() + } M::PrinterProperties => { match crate::io::print_to_printer::open_printer_properties( self.plot_dialog.printer.as_deref(), @@ -5077,6 +5161,65 @@ mod plot_paper_tests { 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 an_unknown_sheet_from_a_drawing_keeps_its_own_dimensions() { let mut app = app_with_printer_named_sheet(); diff --git a/src/io/paper_catalog.rs b/src/io/paper_catalog.rs index 04e7164ee..1532b2354 100644 --- a/src/io/paper_catalog.rs +++ b/src/io/paper_catalog.rs @@ -16,7 +16,7 @@ use std::sync::OnceLock; /// Unit the media is defined in. AutoCAD 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)] +#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, serde::Serialize, serde::Deserialize)] pub enum PaperUnits { Millimeters, Inches, @@ -107,6 +107,88 @@ pub enum Orientation { Landscape, } +/// Unprintable margins of a sheet, in the order AutoCAD 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 +/// AutoCAD 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 { @@ -156,18 +238,43 @@ impl PaperSize { /// 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 AutoCAD'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 label = match units { - PaperUnits::Millimeters => "UserDefinedMetric", - PaperUnits::Inches => "UserDefinedInches", - }; + let cleaned: String = name + .trim() + .chars() + .filter(|c| !matches!(c, '(' | ')')) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + if cleaned.is_empty() { + return Self::custom(width, height, units); + } Self { - canonical: Cow::Owned(canonical_name(label, width, height, units)), - label: Cow::Borrowed(label), + canonical: Cow::Owned(canonical_name( + &cleaned.replace(' ', "_"), + width, + height, + units, + )), + label: Cow::Owned(cleaned), series: PaperSeries::Other, variant: PaperVariant::Standard, width, @@ -688,6 +795,39 @@ mod tests { 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 AutoCAD 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: diff --git a/src/io/plot_device.rs b/src/io/plot_device.rs index ac652f899..670e5218b 100644 --- a/src/io/plot_device.rs +++ b/src/io/plot_device.rs @@ -15,7 +15,8 @@ //! a fixed table; a CUPS printer reports its media and margins over IPP //! (`media-col-database`), which is queried once per printer and cached. -use crate::io::paper_catalog::{self, PaperSize, PaperUnits, PaperVariant}; +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}; @@ -73,51 +74,23 @@ impl PlotDevice { } /// Unprintable margins the device imposes on `paper`, in millimetres. - pub fn margins_mm(&self, paper: &PaperSize) -> Margins { + /// 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 => pdf_margins_mm(paper), - PlotDevice::Printer(name) => printer_margins_mm(name, paper), + 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: AutoCAD keeps the margins of the last device or its // defaults; without one we show the full sheet as printable. - PlotDevice::None => Margins::ZERO, - } - } -} - -/// Unprintable margins in millimetres, in the order AutoCAD stores them. -#[derive(Clone, Copy, Debug, PartialEq)] -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), + PlotDevice::None => user_defined.unwrap_or(Margins::ZERO), } } } @@ -234,12 +207,6 @@ pub fn printer_capabilities(printer: &str) -> Option> { Some(caps) } -fn printer_margins_mm(printer: &str, paper: &PaperSize) -> Margins { - cached_printer_capabilities(printer) - .and_then(|caps| caps.margins_for(paper)) - .unwrap_or(Margins::ZERO) -} - /// 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 { @@ -459,7 +426,8 @@ mod tests { #[test] fn pdf_margins_follow_autocad_per_family_and_variant() { - let m = |name: &str| PlotDevice::Pdf.margins_mm(&paper_catalog::resolve(name).unwrap()); + 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, @@ -517,12 +485,49 @@ mod tests { 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()), + 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"); diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 4ae3bd537..576363792 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1457,6 +1457,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "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")), "Layout renamed: '{old_name}' → '{new_name}'" => Some(("common", "layout-renamed-old-name-new-name")), "Layouts" => Some(("layout", "layouts")), "Leader" => Some(("common", "leader")), diff --git a/src/ui/style/form.rs b/src/ui/style/form.rs new file mode 100644 index 000000000..cc9eff39b --- /dev/null +++ b/src/ui/style/form.rs @@ -0,0 +1,206 @@ +//! 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, + selected: Option, + 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, + selected: Option, + 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 short unlabeled text field for a compact multi-value row (`L B R T`). +pub fn compact_field<'a>( + placeholder: &'a str, + value: &'a str, + on_input: impl Fn(String) -> Message + 'a, + width: f32, +) -> Element<'a, Message> { + text_input(placeholder, value) + .on_input(on_input) + .style(field_style) + .size(12) + .padding([3, 6]) + .width(width) + .into() +} diff --git a/src/ui/style/mod.rs b/src/ui/style/mod.rs index f8c3fa3c7..2a86accac 100644 --- a/src/ui/style/mod.rs +++ b/src/ui/style/mod.rs @@ -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 { diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index ee6064326..24712901e 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -5,8 +5,10 @@ //! PDF. Styled to match the other OCS dialogs (dark pills + fields). use crate::app::Message; -use crate::io::paper_catalog::{self, 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, @@ -75,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() { @@ -119,6 +159,8 @@ pub enum PlotDlgMsg { /// the user has since left is ignored. PrinterMedia(String, Option>), Paper(String), + /// Edits of the inline "custom paper size" editor under the Size row. + CustomPaper(CustomPaperMsg), Orientation(String), Area(String), Scale(String), @@ -153,6 +195,121 @@ 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, +} + +#[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 { + let number = |text: &str| text.trim().replace(',', ".").parse::().ok(); + let (Some(width), Some(height)) = (number(&self.width), number(&self.height)) else { + return Err(CustomPaperError::Size); + }; + if !(width > 0.0) || !(height > 0.0) || !width.is_finite() || !height.is_finite() { + 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]), + }, + }) + } +} + /// 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 @@ -173,6 +330,12 @@ pub struct PlotDialogState { /// while unknown, when the platform cannot ask, or for PDF output. #[serde(skip)] pub printer_media: Option>, + /// Sheets the user defined, offered for every device and remembered + /// across sessions. + pub custom_papers: Vec, + /// The inline custom-sheet editor while it is open. + #[serde(skip)] + pub custom_editor: Option, /// Chosen printer name, or `None` for the system default. pub printer: Option, /// Output goes to a PDF file instead of a printer. @@ -238,6 +401,8 @@ impl Default for PlotDialogState { printers: Vec::new(), default_printer: None, printer_media: None, + custom_papers: Vec::new(), + custom_editor: None, printer: None, to_file: false, paper: paper_catalog::default_paper().canonical.to_string(), @@ -318,80 +483,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>( @@ -404,7 +508,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) @@ -442,15 +546,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>( @@ -461,20 +557,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. @@ -484,17 +574,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>( @@ -504,19 +584,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) @@ -553,6 +623,92 @@ fn choices(items: &[&str]) -> Vec { items.iter().map(|&value| PlotChoice::localized(value)).collect() } +/// The inline editor for a user-defined sheet: name, dimensions and unit, +/// printable margins, Add / Cancel. Numbers stay text until Add validates +/// them. +fn custom_paper_editor<'a>(draft: &'a CustomPaperDraft, width: Length) -> Element<'a, Message> { + let msg = |m: CustomPaperMsg| Message::PlotDlg(PlotDlgMsg::CustomPaper(m)); + let units_label = |inches: bool| { + if inches { + crate::i18n::translate("Inches") + } else { + crate::i18n::translate("Millimeters") + } + }; + let unit_options: Vec = vec![ + PlotChoice::localized("Millimeters"), + PlotChoice::localized("Inches"), + ]; + let unit_selected = Some(PlotChoice::localized(if draft.inches { "Inches" } else { "Millimeters" })); + let margin_field = |side: MarginSide, index: usize, hint: &'a str| { + form::compact_field(hint, &draft.margins[index], move |v| msg(CustomPaperMsg::Margin(side, v)), 56.0) + }; + 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(), + }; + let unit_hint = text(units_label(draft.inches)).size(10).style(muted_style); + container( + column![ + section_label(t!("Custom paper size")), + form::labeled_field(t!("Name"), &draft.name, move |v| msg(CustomPaperMsg::Name(v)), 220.0), + row![ + form::labeled_field(t!("Width"), &draft.width, move |v| msg(CustomPaperMsg::Width(v)), 70.0), + form::labeled_field(t!("Height"), &draft.height, move |v| msg(CustomPaperMsg::Height(v)), 70.0), + ] + .spacing(12), + form::labeled_pick_list( + t!("Units"), + unit_options, + unit_selected, + move |choice| msg(CustomPaperMsg::Units(choice.raw)), + Length::Fixed(140.0), + ), + row![ + text(t!("Printable margins")).size(11).style(muted_style).width(form::LABEL_WIDTH), + margin_field(MarginSide::Left, 0, "L"), + margin_field(MarginSide::Bottom, 1, "B"), + margin_field(MarginSide::Right, 2, "R"), + margin_field(MarginSide::Top, 3, "T"), + unit_hint, + ] + .spacing(6) + .align_y(iced::Center), + 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, @@ -599,7 +755,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), @@ -708,10 +864,21 @@ pub fn view_window( 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) @@ -768,9 +935,36 @@ pub fn view_window( ); // ── Paper, area, offset, scale ──────────────────────────────────────── + // Size row with the custom-sheet controls; the editor unfolds below it. + let mut size_row = row![ + drop_row(t!("Size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, Length::Fill), + ] + .spacing(6) + .align_y(iced::Center); + if s.custom_editor.is_none() { + size_row = size_row.push( + button(text(t!("Custom…")).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Open))) + .style(btn(false)) + .padding([4, 8]), + ); + if selected_is_custom { + size_row = size_row.push( + button(text(t!("Remove")).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Remove))) + .style(btn(false)) + .padding([4, 8]), + ); + } + } + let custom_editor: Element<'_, Message> = match &s.custom_editor { + Some(draft) => custom_paper_editor(draft, width), + None => Space::new().height(0).into(), + }; let paper_panel = panel(column![ section_label(t!("Paper")), - drop_row(t!("Size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, width), + size_row, + custom_editor, paper_source_note, paper_note, ].spacing(7)); From 737666b89452a22dcb6c1cbf2c4dc237b298b729 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:04:52 +0300 Subject: [PATCH 5/5] feat(plot): printer driver properties and a roomier plot dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Properties… next to the printer used to open the CUPS web interface in a browser. It now edits the driver options of the *selected* printer the way LibreOffice's Properties… does, without leaving the plot dialog: - CUPS platforms (Linux, any desktop; macOS): an in-line editor lists the printer's PPD options as `lpoptions -l` reports them — media type, colour mode, quality, duplex, trays, scaling — with readable choice labels, Reset / Cancel / Apply. Apply keeps only the choices that differ from the driver defaults, per printer, in the plot config, and every job for that printer passes them as `lp -o key=value`. Where CUPS cannot answer, the desktop's printer settings panel opens instead of a browser. - Windows: the driver's document-properties sheet (`DocumentPropertiesW` with prompt, owned by the main window) and, on OK, the result stored as the user's printing preferences for that printer (`SetPrinter` level 9), which the job honours. `printui /e` (printing preferences) replaces the device-properties sheet as the fallback. The desktop print portal was tried first and dropped: GTK 4's print dialog loses the preselected printer when the default queue is listed before it, so the dialog always opened on the wrong printer. Dialog layout: the modal grows to 940×620; Printer / plotter and Paper size share a line with their pickers (long queue and media names now fit); Copies + Properties… and Custom… sit beneath; the custom-sheet editor labels each margin (Left / Right, Top / Bottom) and shows the unit. Also recognises `none_device`, AutoCAD's stored spelling for "no plotter", and writes it back unchanged. Tests: `lpoptions` parsing and keyword humanising, the editor flow (load, stale answer ignored, set, apply, remember, reset, error, cancel), device name round-trips including `none_device`. Co-Authored-By: Claude Opus 5 --- Cargo.toml | 2 +- locales/ar-SA/opencadstudio.ftl | 9 + locales/bg-BG/opencadstudio.ftl | 9 + locales/cs-CZ/opencadstudio.ftl | 9 + locales/de-DE/opencadstudio.ftl | 9 + locales/el-GR/opencadstudio.ftl | 9 + locales/en-US/opencadstudio.ftl | 9 + locales/es-ES/opencadstudio.ftl | 9 + locales/fi-FI/opencadstudio.ftl | 9 + locales/fr-FR/opencadstudio.ftl | 9 + locales/hi-IN/opencadstudio.ftl | 9 + locales/hu-HU/opencadstudio.ftl | 9 + locales/it-IT/opencadstudio.ftl | 9 + locales/ja-JP/opencadstudio.ftl | 9 + locales/ko-KR/opencadstudio.ftl | 9 + locales/nl-NL/opencadstudio.ftl | 9 + locales/pl-PL/opencadstudio.ftl | 9 + locales/pt-BR/opencadstudio.ftl | 9 + locales/ru-RU/opencadstudio.ftl | 9 + locales/tr-TR/opencadstudio.ftl | 9 + locales/zh-CN/opencadstudio.ftl | 9 + locales/zh-TW/opencadstudio.ftl | 9 + src/app/update/file.rs | 227 ++++++++++++++++++++++- src/app/view/modal.rs | 4 +- src/io/plot_device.rs | 21 ++- src/io/print_to_printer.rs | 306 ++++++++++++++++++++++++++++++- src/locale_catalog.rs | 9 + src/ui/style/form.rs | 29 +-- src/ui/window/plot.rs | 316 ++++++++++++++++++++++++-------- 29 files changed, 1000 insertions(+), 103 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1c1536f7d..e3e7ba349 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ naga = { version = "27", features = ["wgsl-in"] } [target.'cfg(target_os = "windows")'.dependencies] libloading = "0.8" -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] } +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi"] } [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 7130e80c8..c11676a47 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -5029,6 +5029,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = دمج الخطوط المتداخلة .object-lineweights = سماكات خطوط الكائنات .orientation = الاتجاه diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index a24eb249c..1eb0a901d 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -5017,6 +5017,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Обедини припокриващи се линии .object-lineweights = Дебелини на линии на обект .orientation = Ориентация diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 7d7364d18..5157d3f79 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -5014,6 +5014,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Sloučit překrývající se čáry .object-lineweights = Tloušťky čar objektů .orientation = Orientace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 2612d5f56..9c4911cd8 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -5015,6 +5015,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Zusammenführung überlappender Leitungen .object-lineweights = Objektliniengewichte .orientation = Ausrichtung diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index a5664898f..edb6d9e6e 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -5017,6 +5017,15 @@ plot = .custom-paper-size = Προσαρμοσμένο μέγεθος χαρτιού .printable-margins = Εκτυπώσιμα περιθώρια .enter-a-positive-width-and-height = Δώστε θετικό πλάτος και ύψος. + .paper-size = Μέγεθος χαρτιού + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ επιλογή(-ές) driver ορισμένες για αυτόν τον εκτυπωτή. + .printer-properties-printer = Ιδιότητες εκτυπωτή — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = Ο εκτυπωτής δεν αναφέρει επιλογές driver. + .could-not-read-the-printer-s-options-error = Αδυναμία ανάγνωσης των επιλογών του εκτυπωτή: __ocs_fmt_0__ + .reading-the-printer-s-options = Ανάγνωση των επιλογών του εκτυπωτή… + .printing-preferences-saved-for-printer = Οι προτιμήσεις εκτύπωσης αποθηκεύτηκαν για τον εκτυπωτή __ocs_fmt_0__. + .printing-preferences-unchanged = Οι προτιμήσεις εκτύπωσης δεν άλλαξαν. + .printer-properties = Ιδιότητες εκτυπωτή .merge-overlapping-lines = Συγχώνευση επικαλυπτόμενων γραμμών .object-lineweights = Πάχη γραμμών αντικειμένων .orientation = Προσανατολισμός diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index dbcc88cd7..559a55db3 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -5018,6 +5018,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Merge overlapping lines .object-lineweights = Object lineweights .orientation = Orientation diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 8faefd7a0..597e224bd 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -5017,6 +5017,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Fusionar líneas solapadas .object-lineweights = Grosores de línea de objetos .orientation = Orientación diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 9de9ec986..8c306f69b 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -5016,6 +5016,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Yhdistä päällekkäiset rivit .object-lineweights = Objektin viivan painot .orientation = Suuntautuminen diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index c2e716831..b9f0044c8 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -5017,6 +5017,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Fusionner les lignes qui se chevauchent .object-lineweights = Poids de ligne d'objet .orientation = Orientation diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 6e34cface..d799d20d0 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -5005,6 +5005,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = मर्ज ओवरलैपिंग लाइन .object-lineweights = ऑब्जेक्ट लाइनवेट .orientation = अभिविन्यास diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index b204a6052..4e2820102 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -5016,6 +5016,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Egyesítse az átfedő vonalakat .object-lineweights = Objektumvonalvastagságok .orientation = Tájékozódás diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 68d841798..4cc8190c9 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -5016,6 +5016,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Unisci linee sovrapposte .object-lineweights = Spessori linea degli oggetti .orientation = Orientamento diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 6bf94b41a..ce01e9dda 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -5005,6 +5005,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = 重複線を結合 .object-lineweights = オブジェクトの線の太さ .orientation = 方向 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index a79acfaa8..e5a6fb083 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -5016,6 +5016,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = 겹치는 선 병합 .object-lineweights = 객체 선가중치 .orientation = 오리엔테이션 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 4a7bbc633..f22b95fe8 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -5015,6 +5015,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Overlaplijnen samenvoegen .object-lineweights = Objectlijndiktes .orientation = Oriëntatie diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index ede80253e..eb1491f71 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -5016,6 +5016,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Połącz nakładające się linie .object-lineweights = Szerokości linii obiektu .orientation = Orientacja diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 68e04964a..43df0a1d6 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -5017,6 +5017,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Mesclar linhas sobrepostas .object-lineweights = Espessuras de linha dos objetos .orientation = Orientação diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index cbd2bfe63..b89d617d4 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -5018,6 +5018,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Слияние пересекающихся линий .object-lineweights = Весы для объектов .orientation = Ориентация diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 954050cba..fb9160dcc 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -4957,6 +4957,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Çakışan çizgileri birleştir .object-lineweights = Nesne çizgi kalınlıkları .orientation = Yönelim diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 97d63c729..77a8561f4 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -5000,6 +5000,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = 合并重叠线 .object-lineweights = 对象线宽 .orientation = 方向 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 63ffd2c16..7925576ab 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -5012,6 +5012,15 @@ plot = .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = 合併重疊線 .object-lineweights = 物件線寬 .orientation = 方向 diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 6ac933b02..eae7b5b5d 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -3935,6 +3935,85 @@ impl OpenCADStudio { } } + /// 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 { + 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 { + 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) { self.plot_dialog.center = true; self.plot_dialog.offset_x = "0.0".into(); @@ -3957,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 { @@ -3979,17 +4061,73 @@ impl OpenCADStudio { self.on_custom_paper_msg(msg); 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::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); @@ -4720,6 +4858,12 @@ impl OpenCADStudio { printer: d.printer.clone(), copies: d.copies.trim().parse::().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(), } } @@ -5220,6 +5364,73 @@ mod plot_paper_tests { 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(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 993c7ec9d..9b0f5d305 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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, diff --git a/src/io/plot_device.rs b/src/io/plot_device.rs index 670e5218b..0d553db39 100644 --- a/src/io/plot_device.rs +++ b/src/io/plot_device.rs @@ -22,8 +22,10 @@ use std::sync::{Arc, Mutex, OnceLock}; /// AutoCAD's plotter configuration name for PDF output. pub const PDF_DEVICE_NAME: &str = "DWG To PDF.pc3"; -/// AutoCAD's spelling for "no plotter assigned". +/// AutoCAD'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. @@ -44,7 +46,7 @@ impl PlotDevice { /// The name AutoCAD would store for this device. pub fn canonical_name(&self) -> String { match self { - PlotDevice::None => NO_DEVICE_NAME.to_string(), + PlotDevice::None => NO_DEVICE_STORED_NAME.to_string(), PlotDevice::Pdf => PDF_DEVICE_NAME.to_string(), PlotDevice::Printer(name) => name.clone(), } @@ -61,6 +63,10 @@ impl PlotDevice { 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) { + // AutoCAD's own spelling: keep it rather than rewriting it. + return (PlotDevice::None, true); + } if name == LEGACY_PDF_LABEL { return (PlotDevice::Pdf, false); } @@ -387,7 +393,8 @@ mod tests { #[test] fn canonical_names_follow_autocad() { assert_eq!(PlotDevice::Pdf.canonical_name(), "DWG To PDF.pc3"); - assert_eq!(PlotDevice::None.canonical_name(), "None"); + // What AutoCAD writes for "None" in its plot dialog. + assert_eq!(PlotDevice::None.canonical_name(), "none_device"); assert_eq!( PlotDevice::Printer("L3270".into()).canonical_name(), "L3270" @@ -401,6 +408,14 @@ mod tests { 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) diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 35129dd61..d7282b547 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -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, + /// 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 ( "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) -> (&'static str, Vec) -> 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()) @@ -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, +} + +#[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, 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, 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 { + 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 = 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 = 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)> = 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 { + 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 = 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::(), + 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::(), + current.as_ptr().cast::(), + 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::(), + }; + // 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::(), 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"); + } +} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 576363792..ca31119d5 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1460,6 +1460,15 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "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")), diff --git a/src/ui/style/form.rs b/src/ui/style/form.rs index cc9eff39b..6f79d4fec 100644 --- a/src/ui/style/form.rs +++ b/src/ui/style/form.rs @@ -189,18 +189,25 @@ pub fn labeled_field_enabled<'a>( .into() } -/// A short unlabeled text field for a compact multi-value row (`L B R T`). -pub fn compact_field<'a>( - placeholder: &'a str, +/// 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, - width: f32, + label_width: f32, + field_width: f32, ) -> Element<'a, Message> { - text_input(placeholder, value) - .on_input(on_input) - .style(field_style) - .size(12) - .padding([3, 6]) - .width(width) - .into() + 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() } diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index 24712901e..594263e54 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -153,6 +153,18 @@ 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, 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 @@ -291,7 +303,7 @@ impl CustomPaperDraft { let (Some(width), Some(height)) = (number(&self.width), number(&self.height)) else { return Err(CustomPaperError::Size); }; - if !(width > 0.0) || !(height > 0.0) || !width.is_finite() || !height.is_finite() { + 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); @@ -310,6 +322,36 @@ impl CustomPaperDraft { } } +/// 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>, + /// Current choice keyword per option key. + pub choices: std::collections::BTreeMap, + /// Why the options could not be listed, when they could not. + pub error: Option, +} + +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 { + 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 @@ -336,6 +378,13 @@ pub struct PlotDialogState { /// The inline custom-sheet editor while it is open. #[serde(skip)] pub custom_editor: Option, + /// 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>, + /// The in-line printer-properties editor while it is open. + #[serde(skip)] + pub printer_editor: Option, /// Chosen printer name, or `None` for the system default. pub printer: Option, /// Output goes to a PDF file instead of a printer. @@ -403,6 +452,8 @@ impl Default for PlotDialogState { 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: paper_catalog::default_paper().canonical.to_string(), @@ -623,25 +674,125 @@ fn choices(items: &[&str]) -> Vec { 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 = 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. +/// 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 units_label = |inches: bool| { - if inches { - crate::i18n::translate("Inches") - } else { - crate::i18n::translate("Millimeters") - } - }; + let unit = if draft.inches { "in" } else { "mm" }; let unit_options: Vec = vec![ PlotChoice::localized("Millimeters"), PlotChoice::localized("Inches"), ]; let unit_selected = Some(PlotChoice::localized(if draft.inches { "Inches" } else { "Millimeters" })); - let margin_field = |side: MarginSide, index: usize, hint: &'a str| { - form::compact_field(hint, &draft.margins[index], move |v| msg(CustomPaperMsg::Margin(side, v)), 56.0) + 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.")) @@ -652,33 +803,37 @@ fn custom_paper_editor<'a>(draft: &'a CustomPaperDraft, width: Length) -> Elemen .into(), None => Space::new().height(0).into(), }; - let unit_hint = text(units_label(draft.inches)).size(10).style(muted_style); container( column![ section_label(t!("Custom paper size")), - form::labeled_field(t!("Name"), &draft.name, move |v| msg(CustomPaperMsg::Name(v)), 220.0), - row![ - form::labeled_field(t!("Width"), &draft.width, move |v| msg(CustomPaperMsg::Width(v)), 70.0), - form::labeled_field(t!("Height"), &draft.height, move |v| msg(CustomPaperMsg::Height(v)), 70.0), - ] - .spacing(12), + 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(140.0), + Length::Fixed(150.0), ), row![ - text(t!("Printable margins")).size(11).style(muted_style).width(form::LABEL_WIDTH), - margin_field(MarginSide::Left, 0, "L"), - margin_field(MarginSide::Bottom, 1, "B"), - margin_field(MarginSide::Right, 2, "R"), - margin_field(MarginSide::Top, 3, "T"), - unit_hint, + dimension(t!("Width"), &draft.width, CustomPaperMsg::Width), + dimension(t!("Height"), &draft.height, CustomPaperMsg::Height), + text(unit).size(11).style(muted_style), ] - .spacing(6) + .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), @@ -897,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) @@ -905,66 +1064,69 @@ 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 ──────────────────────────────────────── - // Size row with the custom-sheet controls; the editor unfolds below it. - let mut size_row = row![ - drop_row(t!("Size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, Length::Fill), - ] - .spacing(6) - .align_y(iced::Center); - if s.custom_editor.is_none() { - size_row = size_row.push( - button(text(t!("Custom…")).size(11)) - .on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Open))) - .style(btn(false)) - .padding([4, 8]), - ); - if selected_is_custom { - size_row = size_row.push( - button(text(t!("Remove")).size(11)) - .on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Remove))) - .style(btn(false)) - .padding([4, 8]), - ); - } - } - let custom_editor: Element<'_, Message> = match &s.custom_editor { + // 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 => Space::new().height(0).into(), + 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")), size_row, - custom_editor, + custom_controls, paper_source_note, paper_note, ].spacing(7));